aioli-cli 0.1.43__tar.gz → 0.1.45__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 (121) hide show
  1. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/PKG-INFO +1 -1
  2. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/pyproject.toml +1 -1
  3. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_cli/main.py +9 -4
  4. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_contract/ui.py +115 -0
  5. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_image/cli.py +72 -17
  6. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_image/companions.py +22 -14
  7. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_image/config.py +131 -0
  8. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_image/data/models.toml +26 -11
  9. aioli_cli-0.1.45/src/aioli_image/doctor.py +150 -0
  10. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_image/gpu.py +5 -2
  11. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_image/launcher.py +284 -35
  12. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_image/models.py +11 -5
  13. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_image/models_data.py +84 -0
  14. aioli_cli-0.1.45/src/aioli_image/progress.py +397 -0
  15. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_image/ui.py +49 -14
  16. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_master/cli.py +59 -2
  17. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/cli_commands/backend_cmd.py +54 -1
  18. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/cli_commands/generate_cmd.py +134 -5
  19. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/generator.py +12 -3
  20. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/tui.py +125 -3
  21. aioli_cli-0.1.45/src/aioli_podcast/cli.py +303 -0
  22. aioli_cli-0.1.45/src/aioli_video/__init__.py +3 -0
  23. aioli_cli-0.1.45/src/aioli_video/cli.py +731 -0
  24. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/cli/doctor.py +1 -3
  25. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/cli/generate.py +216 -4
  26. aioli_cli-0.1.43/src/aioli_image/doctor.py +0 -59
  27. aioli_cli-0.1.43/src/aioli_podcast/cli.py +0 -153
  28. aioli_cli-0.1.43/src/aioli_video/cli.py +0 -292
  29. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/.gitignore +0 -0
  30. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/LICENSE +0 -0
  31. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/README.md +0 -0
  32. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_cli/_plugins.py +0 -0
  33. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_cli/fetch_ui.py +0 -0
  34. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_cli/readiness.py +0 -0
  35. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_cli/registry.py +0 -0
  36. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_cli/setup.py +0 -0
  37. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_cli/status.py +0 -0
  38. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_contract/__init__.py +0 -0
  39. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_contract/advise.py +0 -0
  40. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_contract/config.py +0 -0
  41. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_contract/doctor.py +0 -0
  42. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_contract/fetch.py +0 -0
  43. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_contract/probe.py +0 -0
  44. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_contract/readiness.py +0 -0
  45. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_image/__init__.py +0 -0
  46. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_image/plugin.py +0 -0
  47. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_image/presets.py +0 -0
  48. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_image/stub.py +0 -0
  49. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_master/doctor.py +0 -0
  50. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_master/ops.py +0 -0
  51. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_master/plugin.py +0 -0
  52. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/__init__.py +0 -0
  53. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/backends/__init__.py +0 -0
  54. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/backends/acestep.py +0 -0
  55. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/backends/base.py +0 -0
  56. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/backends/llm_caption.py +0 -0
  57. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/backends/minimax_comfy.py +0 -0
  58. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/backends/process.py +0 -0
  59. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/backends/stub.py +0 -0
  60. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/backends/yue2.py +0 -0
  61. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/cli_commands/__init__.py +0 -0
  62. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/cli_commands/inspire_cmd.py +0 -0
  63. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/cli_commands/models_cmd.py +0 -0
  64. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/client.py +0 -0
  65. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/config.py +0 -0
  66. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/config_data/default.toml +0 -0
  67. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/core/__init__.py +0 -0
  68. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/core/formats.py +0 -0
  69. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/core/generate.py +0 -0
  70. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/core/jobs.py +0 -0
  71. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/core/layout.py +0 -0
  72. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/doctor.py +0 -0
  73. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/history.py +0 -0
  74. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/jsonstore.py +0 -0
  75. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/models.py +0 -0
  76. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/models_data.py +0 -0
  77. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/paths.py +0 -0
  78. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/plugin.py +0 -0
  79. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/presets.py +0 -0
  80. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/prompts.py +0 -0
  81. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/server.py +0 -0
  82. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/transport/__init__.py +0 -0
  83. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_music/transport/client.py +0 -0
  84. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_podcast/cast.py +0 -0
  85. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_podcast/doctor.py +0 -0
  86. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_podcast/episode.py +0 -0
  87. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_podcast/feed.py +0 -0
  88. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_podcast/plugin.py +0 -0
  89. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_podcast/script.py +0 -0
  90. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_video/doctor.py +0 -0
  91. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_video/models_data.py +0 -0
  92. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_video/paths.py +0 -0
  93. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_video/plugin.py +0 -0
  94. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_video/stub.py +0 -0
  95. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_video/wgp.py +0 -0
  96. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_video/worker.py +0 -0
  97. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/__init__.py +0 -0
  98. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/cli/engines_cmd.py +0 -0
  99. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/cli/models_cmd.py +0 -0
  100. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/cli/profiles.py +0 -0
  101. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/config/__init__.py +0 -0
  102. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/config/settings.py +0 -0
  103. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/core/__init__.py +0 -0
  104. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/core/generation.py +0 -0
  105. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/core/model_manager.py +0 -0
  106. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/core/profiles.py +0 -0
  107. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/core/registry.py +0 -0
  108. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/core/types.py +0 -0
  109. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/devices.py +0 -0
  110. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/doctor.py +0 -0
  111. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/engines/__init__.py +0 -0
  112. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/engines/chatterbox.py +0 -0
  113. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/engines/kokoro.py +0 -0
  114. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/engines/moss.py +0 -0
  115. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/engines/piper.py +0 -0
  116. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/engines/qwen3.py +0 -0
  117. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/engines/stub.py +0 -0
  118. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/engines/voxcpm.py +0 -0
  119. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/engines/xtts.py +0 -0
  120. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/models_data.py +0 -0
  121. {aioli_cli-0.1.43 → aioli_cli-0.1.45}/src/aioli_voice/plugin.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aioli-cli
3
- Version: 0.1.43
3
+ Version: 0.1.45
4
4
  Summary: aioli — local-first AI media suite CLI (images, video, music, voice, podcast)
5
5
  Author: aioli contributors
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aioli-cli"
3
- version = "0.1.43"
3
+ version = "0.1.45"
4
4
  description = "aioli — local-first AI media suite CLI (images, video, music, voice, podcast)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -6,6 +6,7 @@ from typing import Any
6
6
  import typer
7
7
  from rich.console import Console
8
8
  from rich.table import Table
9
+ from rich.text import Text
9
10
 
10
11
  from aioli_cli.registry import registry
11
12
  from aioli_contract import AioliConfig, run_checks
@@ -67,8 +68,12 @@ def doctor() -> None:
67
68
  table.add_column("status")
68
69
  table.add_column("detail")
69
70
  ok = True
71
+ # Text(): las celdas de detalle son datos literales (los paths pueden
72
+ # traer '[') — nunca markup. Sin esto, la lista de raíces `[p1, p2]`
73
+ # se interpreta como tag de cierre y `aioli doctor` crashea con
74
+ # MarkupError (verify round 3, issue 1).
70
75
  for check in run_checks():
71
- table.add_row(check.name, "✅" if check.ok else "❌", check.hint)
76
+ table.add_row(check.name, "✅" if check.ok else "❌", Text(check.hint))
72
77
  ok = ok and check.ok
73
78
  for plugin in registry.all():
74
79
  if plugin.checks is None:
@@ -77,7 +82,7 @@ def doctor() -> None:
77
82
  table.add_row(
78
83
  f"{plugin.name}/{check.name}",
79
84
  "✅" if check.ok else "❌",
80
- check.hint,
85
+ Text(check.hint),
81
86
  )
82
87
  ok = ok and check.ok
83
88
  try:
@@ -124,12 +129,12 @@ def doctor() -> None:
124
129
  table.add_row(
125
130
  f"readiness/{check.name}",
126
131
  "✅" if check.ok else "❌",
127
- check.hint,
132
+ Text(check.hint),
128
133
  )
129
134
  ok = ok and check.ok
130
135
  except (ImportError, OSError, RuntimeError):
131
136
  pass
132
- table.add_row("models_dir", "📁", str(cfg.models_dir))
137
+ table.add_row("models_dir", "📁", Text(str(cfg.models_dir)))
133
138
  table.add_row("plugins", "🔌", ", ".join(registry.names()) or "(none yet)")
134
139
  console.print(table)
135
140
  if not ok:
@@ -34,11 +34,16 @@ __all__ = [
34
34
  "AIOLI_GREEN",
35
35
  "AIOLI_MARK",
36
36
  "PROMPT_STYLE",
37
+ "QUALITY_PRESETS",
38
+ "apply_quality_preset",
39
+ "ask_quality_preset",
37
40
  "confirm",
38
41
  "header",
39
42
  "is_interactive",
43
+ "measured_eta_hint",
40
44
  "number",
41
45
  "plain",
46
+ "quality_preset_steps",
42
47
  "select",
43
48
  "style",
44
49
  "summary_panel",
@@ -274,3 +279,113 @@ def confirm(
274
279
  amark=amark,
275
280
  **kwargs,
276
281
  )
282
+
283
+
284
+ # ── Presets de calidad rapido|calidad (un solo sitio) ─────────────────────
285
+ # `--preset` en image/video generate y pregunta opcional en sus wizards.
286
+ # El preset SOLO toca los pasos; el CFG queda estándar ("rapido = menos
287
+ # steps/CFG estándar; calidad = más steps"). Flags explícitos mandan siempre
288
+ # sobre el preset (ver `apply_quality_preset`).
289
+
290
+ QUALITY_PRESETS: tuple[str, str] = ("rapido", "calidad")
291
+
292
+ IMAGE_QUALITY_STEPS: dict[str, int] = {"rapido": 8, "calidad": 30}
293
+ VIDEO_QUALITY_STEPS: dict[str, int] = {"rapido": 4, "calidad": 16}
294
+
295
+ _QUALITY_STEPS_BY_DOMAIN: dict[str, dict[str, int]] = {
296
+ "image": IMAGE_QUALITY_STEPS,
297
+ "video": VIDEO_QUALITY_STEPS,
298
+ }
299
+
300
+
301
+ def quality_preset_steps(preset: str | None, domain: str) -> int | None:
302
+ """Pasos que impone un preset de calidad, o None sin preset.
303
+
304
+ `ValueError` con preset o dominio desconocidos: el llamador lo convierte
305
+ en error accionable (exit 2), nunca en traceback.
306
+ """
307
+ if preset is None:
308
+ return None
309
+ try:
310
+ table = _QUALITY_STEPS_BY_DOMAIN[domain]
311
+ except KeyError:
312
+ raise ValueError(f"dominio desconocido {domain!r}") from None
313
+ try:
314
+ return table[preset]
315
+ except KeyError:
316
+ raise ValueError(
317
+ f"preset desconocido {preset!r} (usa {'|'.join(QUALITY_PRESETS)})"
318
+ ) from None
319
+
320
+
321
+ def apply_quality_preset(preset: str | None, steps: int | None, domain: str) -> int | None:
322
+ """Resuelve pasos con precedencia flags > preset > defaults.
323
+
324
+ Un `--steps` explícito manda siempre; el preset solo rellena cuando no
325
+ hay paso explícito; sin preset se devuelve None (cada plugin aplica su
326
+ default). Valida el preset aunque haya paso explícito: un typo no pasa
327
+ en silencio.
328
+ """
329
+ preset_steps = quality_preset_steps(preset, domain)
330
+ if steps is not None:
331
+ return steps
332
+ return preset_steps
333
+
334
+
335
+ def ask_quality_preset() -> str | None:
336
+ """Pregunta opcional de preset para los wizards (misma copia en todos).
337
+
338
+ Devuelve None (estándar, el del modelo), "rapido" o "calidad".
339
+ Quien llama gatea TTY antes: esto bloquea en stdin.
340
+ """
341
+ return select(
342
+ "Calidad",
343
+ [
344
+ {"name": "Estándar (la del modelo)", "value": None},
345
+ {"name": "Rápida (menos pasos)", "value": "rapido"},
346
+ {"name": "Calidad (más pasos)", "value": "calidad"},
347
+ ],
348
+ instruction="rapido = menos pasos · calidad = más pasos (↑↓ elegir · Ctrl-C cancelar)",
349
+ qmark="⚡",
350
+ amark="⚡",
351
+ )
352
+
353
+
354
+ def measured_eta_hint(rel_path: str) -> str | None:
355
+ """Estimación honesta para el resumen del wizard, o None sin dato.
356
+
357
+ Busca el modelo en el probe store (`aioli image probe` guarda `secs`
358
+ medidos en ESTE equipo): si hay medición devuelve "~Ns (medido aquí)".
359
+ Sin medición devuelve None y el llamador OMITE la fila: jamás se inventa
360
+ un tiempo estimado (regla H3.1).
361
+ """
362
+ try:
363
+ from aioli_contract.probe import load_store # noqa: PLC0415
364
+ except Exception: # noqa: BLE001 — sin probe no hay ETA, nunca crash
365
+ return None
366
+ try:
367
+ store = load_store()
368
+ except Exception: # noqa: BLE001 — store corrupto: sin ETA, nunca crash
369
+ return None
370
+ best: float | None = None
371
+ try:
372
+ for _hw, plugins in store.items():
373
+ if not isinstance(plugins, dict):
374
+ continue
375
+ for _plugin, models in plugins.items():
376
+ if not isinstance(models, dict):
377
+ continue
378
+ entry = models.get(rel_path)
379
+ if not isinstance(entry, dict):
380
+ continue
381
+ for mode in entry.values():
382
+ if not isinstance(mode, dict):
383
+ continue
384
+ secs = mode.get("secs")
385
+ if isinstance(secs, (int, float)) and secs > 0:
386
+ best = secs if best is None else min(best, secs)
387
+ except Exception: # noqa: BLE001 — forma inesperada: sin ETA
388
+ return None
389
+ if best is None:
390
+ return None
391
+ return f"~{best:.0f}s (medido aquí)"
@@ -11,18 +11,20 @@ from pathlib import Path
11
11
 
12
12
  import typer
13
13
 
14
- from aioli_contract.ui import is_interactive
14
+ from aioli_contract.ui import apply_quality_preset, is_interactive
15
15
 
16
16
  from . import stub
17
17
  from .companions import CompanionError
18
18
  from .config import (
19
+ composed_scan_roots,
19
20
  ensure_user_config,
20
21
  load_config_safe,
21
22
  load_state,
22
- models_search_dirs,
23
+ toml_scan_roots,
23
24
  )
24
25
  from .launcher import (
25
26
  _companion_skip_note,
27
+ _scan_for_launch,
26
28
  build_probe_trials,
27
29
  cmd_doctor,
28
30
  launch_convert,
@@ -32,7 +34,7 @@ from .launcher import (
32
34
  launch_serve,
33
35
  )
34
36
  from .launcher import _scan_with_spinner as _scan_merged
35
- from .models import find_model_by_name
37
+ from .models import ModelInfo, find_model_by_name
36
38
  from .ui import console, models_table, print_banner, show_model_info
37
39
 
38
40
  app = typer.Typer(
@@ -40,10 +42,40 @@ app = typer.Typer(
40
42
  )
41
43
 
42
44
 
45
+ # `_toml_scan_roots` movió a `config.toml_scan_roots` (F1): el doctor la
46
+ # reúsa via `composed_scan_roots` para ver las mismas raíces que el CLI.
47
+ # Alias por compat con llamadores/tests existentes.
48
+ _toml_scan_roots = toml_scan_roots
49
+
50
+
43
51
  def _resolve_context(models_dir: Path | None) -> tuple[list[Path], dict]:
44
- """Seed user config, resolve models search dirs + merged config."""
52
+ """Seed user config, resolve models search dirs + merged config.
53
+
54
+ Roots = `composed_scan_roots`: `models_search_dirs` (explicit/--models-dir
55
+ > env > fresh + legacy) plus any `[paths]` roots declared in models.toml —
56
+ la misma composición que usa el doctor por defecto (F1); ver
57
+ `config.toml_scan_roots` para el rationale add-vs-replace (AC6/AC8).
58
+ """
45
59
  ensure_user_config()
46
- return models_search_dirs(models_dir), load_config_safe()
60
+ config = load_config_safe()
61
+ return composed_scan_roots(models_dir, config), config
62
+
63
+
64
+ def _scan_or_exit(mds: list[Path], config: dict, model_name: str | None = None) -> list[ModelInfo]:
65
+ """Scan models, failing with exit 3 ONLY when the selected model is broken.
66
+
67
+ Unrelated broken models degrade to warning+skip (same `skip_broken`
68
+ pattern as `list`): `info -m X` / `generate -m X` / `probe -m X` fail
69
+ only when X itself is the broken one; model-less flows (`model_name`
70
+ `None`: wizard, all-probe) never abort over other models. The
71
+ `CompanionError` for the selected model is shown cleanly (exit 3)
72
+ instead of leaking a raw traceback.
73
+ """
74
+ try:
75
+ return _scan_for_launch(mds, config, model_name)
76
+ except CompanionError as e: # safety net: _scan_for_launch already exits 3
77
+ console.print(f"[red]{e}[/]")
78
+ raise typer.Exit(3) from None
47
79
 
48
80
 
49
81
  def availability_lines(machine: object | None = None) -> list[str]:
@@ -84,8 +116,17 @@ def list_models(
84
116
  ) -> None:
85
117
  """List discovered diffusion models."""
86
118
  mds, config = _resolve_context(models_dir)
87
- wired = _scan_merged(mds, config)
119
+ # skip_broken: es una lista, no un lanzamiento. Un modelo con el companion
120
+ # declarado ausente se avisa y se omite; los demás siguen visibles.
121
+ # category="image": el wizard/lista/probe sin -m solo ofrece image (AC1/AC2).
122
+ wired = _scan_merged(mds, config, skip_broken=True, category="image")
88
123
  if not wired:
124
+ try:
125
+ _scan_merged(mds, config, category="image")
126
+ except CompanionError as e:
127
+ console.print(f"[yellow]⚠ {e}[/]")
128
+ console.print("[red]All models skipped: broken companions.[/]")
129
+ raise typer.Exit(1) from None
89
130
  console.print(f"[red]No models found in {', '.join(str(d) for d in mds)}[/]")
90
131
  raise typer.Exit(1)
91
132
  families = {m.family for m in wired}
@@ -103,7 +144,7 @@ def model_info(
103
144
  ) -> None:
104
145
  """Show details for one model."""
105
146
  mds, config = _resolve_context(models_dir)
106
- wired = _scan_merged(mds, config)
147
+ wired = _scan_or_exit(mds, config, model)
107
148
  m = find_model_by_name(model, wired)
108
149
  if m is None:
109
150
  console.print(f"[red]Model not found: {model}[/]")
@@ -126,6 +167,11 @@ def generate(
126
167
  height: int | None = typer.Option(None, "-H", "--height"),
127
168
  steps: int | None = typer.Option(None, "--steps"),
128
169
  cfg: float | None = typer.Option(None, "--cfg", help="CFG scale."),
170
+ preset: str | None = typer.Option(
171
+ None,
172
+ "--preset",
173
+ help="rapido|calidad: ajusta los pasos (CFG estándar); --steps manda.",
174
+ ),
129
175
  seed: int | None = typer.Option(None, "--seed"),
130
176
  sampling_method: str | None = typer.Option(None, "--sampling-method"),
131
177
  scheduler: str | None = typer.Option(None, "--scheduler"),
@@ -167,6 +213,14 @@ def generate(
167
213
  models_dir: Path | None = typer.Option(None, "--models-dir"),
168
214
  ) -> None:
169
215
  """Generate an image (or video with --video-frames > 1)."""
216
+ # isinstance: llamada directa (tests) deja el OptionInfo sin resolver;
217
+ # el CLI siempre entrega str|None.
218
+ if isinstance(preset, str):
219
+ try:
220
+ apply_quality_preset(preset, None, "image")
221
+ except ValueError as exc:
222
+ console.print(f"[red]{exc}[/]")
223
+ raise typer.Exit(2) from None
170
224
  mds, config = _resolve_context(models_dir)
171
225
  if prompt is None:
172
226
  # Sin prompt: wizard si hay TTY, error accionable si no (nunca colgar CI).
@@ -177,7 +231,7 @@ def generate(
177
231
  )
178
232
  raise typer.Exit(2)
179
233
  with console.status(" [dim]scanning models…[/]", spinner="dots"):
180
- wired = _scan_merged(mds, config)
234
+ wired = _scan_or_exit(mds, config)
181
235
  if not wired:
182
236
  console.print(f"[red]No models found in {', '.join(str(d) for d in mds)}[/]")
183
237
  raise typer.Exit(1)
@@ -195,11 +249,15 @@ def generate(
195
249
  height = wiz["height"]
196
250
  if output is None and wiz["output"]:
197
251
  output = Path(wiz["output"])
252
+ wiz_preset = preset if preset is not None else wiz.get("preset")
253
+ eff_steps = apply_quality_preset(wiz_preset, steps, "image")
198
254
  parts: list[str] = [f'aioli image generate "{prompt}"']
199
255
  if model:
200
256
  parts.append(f"-m {model}")
201
257
  if width is not None and height is not None:
202
258
  parts += [f"-W {width}", f"-H {height}"]
259
+ if wiz_preset is not None:
260
+ parts.append(f"--preset {wiz_preset}")
203
261
  if output is not None:
204
262
  parts.append(f"-o {output}")
205
263
  console.print(f"[dim]$ {' '.join(parts)}[/]")
@@ -215,7 +273,7 @@ def generate(
215
273
  out=output,
216
274
  width=width,
217
275
  height=height,
218
- steps=steps,
276
+ steps=eff_steps,
219
277
  cfg=cfg,
220
278
  seed=seed,
221
279
  sampling_method=sampling_method,
@@ -253,7 +311,7 @@ def generate(
253
311
  from aioli_contract.probe import hardware_fingerprint, lookup
254
312
 
255
313
  with console.status(" [dim]scanning models…[/]", spinner="dots"):
256
- known_models = _scan_merged(mds, config)
314
+ known_models = _scan_or_exit(mds, config, model)
257
315
  m = find_model_by_name(model, known_models)
258
316
  if m is not None:
259
317
  rec = lookup("image", m.rel_path, hardware_fingerprint())
@@ -270,7 +328,7 @@ def generate(
270
328
  out=output,
271
329
  width=width,
272
330
  height=height,
273
- steps=steps,
331
+ steps=apply_quality_preset(preset, steps, "image"),
274
332
  cfg=cfg,
275
333
  seed=seed,
276
334
  sampling_method=sampling_method,
@@ -378,7 +436,7 @@ def probe(
378
436
  sd_cli = require_sd_cli()
379
437
  hw = hardware_fingerprint()
380
438
  console.print(f"[dim]hardware: {hw}[/]")
381
- models = _scan_merged(mds, config)
439
+ models = _scan_or_exit(mds, config, model)
382
440
  if model:
383
441
  m = find_model_by_name(model, models)
384
442
  models = [m] if m else []
@@ -391,14 +449,11 @@ def probe(
391
449
  mroot = Path(m.root) if m.root else mds[0]
392
450
  try:
393
451
  trials = build_probe_trials(
394
- m, models_dir=mroot, config=config, sd_cli=sd_cli, modes=modes
452
+ m, models_dir=mroot, config=config, sd_cli=sd_cli, modes=modes, search_dirs=mds
395
453
  )
396
454
  except CompanionError as exc:
397
455
  # Modelo que no se puede cablear: se reporta y se sigue con los
398
- # demás (igual que el escaneo fail-soft), nunca traceback crudo.
399
- # Ojo: build_probe_trials resuelve los dirs de config, no las
400
- # raíces de `--models-dir`, así que puede fallar aunque el scan
401
- # haya pasado.
456
+ # demás (defensa en profundidad; el scan ya filtra los rotos).
402
457
  console.print(f" [red]⚠ {m.name}: {_companion_skip_note(exc)}[/]")
403
458
  continue
404
459
  for t in trials:
@@ -321,11 +321,12 @@ def find_companions(
321
321
  # substring collisions (e.g. "ae.safetensors" is a substring of
322
322
  # "z_image_vae.safetensors").
323
323
  if role == "llm" and model.preferred_llm:
324
+ hint_in = model.preferred_llm.lower()
324
325
  preferred = [
325
326
  m
326
327
  for m in matches
327
- if model.preferred_llm in m.name.lower()
328
- and not _would_be_substring_collision(model.preferred_llm, m.name.lower())
328
+ if hint_in in m.name.lower()
329
+ and not _would_be_substring_collision(hint_in, m.name.lower())
329
330
  ]
330
331
  if not preferred:
331
332
  # The hint is not in this dir: look in the other configured
@@ -413,19 +414,26 @@ def _preferred_llm_elsewhere(
413
414
  if tdir in seen or not tdir.is_dir():
414
415
  continue
415
416
  seen.add(tdir)
416
- for f in tdir.iterdir():
417
- if not f.is_file() or f.suffix.lower() != ".gguf":
418
- continue
419
- if f.name == model.name or not pattern.search(f.name):
420
- continue
421
- low = f.name.lower()
422
- if hint not in low or _would_be_substring_collision(hint, low):
423
- continue
424
- if model_group:
425
- cand_group = _candidate_group(f.name)
426
- if cand_group and cand_group != model_group:
417
+ try:
418
+ entries = list(tdir.iterdir())
419
+ except OSError:
420
+ continue
421
+ for f in entries:
422
+ try:
423
+ if not f.is_file() or f.suffix.lower() != ".gguf":
424
+ continue
425
+ if f.name == model.name or not pattern.search(f.name):
427
426
  continue
428
- size_gb = f.stat().st_size / (1024**3)
427
+ low = f.name.lower()
428
+ if hint not in low or _would_be_substring_collision(hint, low):
429
+ continue
430
+ if model_group:
431
+ cand_group = _candidate_group(f.name)
432
+ if cand_group and cand_group != model_group:
433
+ continue
434
+ size_gb = f.stat().st_size / (1024**3)
435
+ except OSError:
436
+ continue
429
437
  out.append(Companion(role="llm", flag="--llm", path=f, approx_gb=size_gb))
430
438
  if not out:
431
439
  roots = ", ".join(str(r) for r in [own_dir, *searched])
@@ -14,6 +14,7 @@ import json
14
14
  import os
15
15
  import shutil
16
16
  import tomllib
17
+ import warnings
17
18
  from pathlib import Path
18
19
  from typing import Any
19
20
 
@@ -92,6 +93,136 @@ def models_search_dirs(explicit: Path | None = None) -> list[Path]:
92
93
  return dirs or [legacy.resolve()]
93
94
 
94
95
 
96
+ def _category_subdir_or_flat(base: Path, cat: str) -> Path:
97
+ """`<base>/<cat>` si existe y no esta vacio; si no, `<base>` plano (E7)."""
98
+ from .models_data import CATEGORIES
99
+
100
+ if base.name in CATEGORIES:
101
+ return base
102
+ cand = base / cat
103
+ try:
104
+ if cand.is_dir() and any(cand.iterdir()):
105
+ return cand.resolve()
106
+ except OSError:
107
+ pass
108
+ return base
109
+
110
+
111
+ def resolve_category_dirs(
112
+ cat: str, explicit_root: Path | str | None = None, config: dict | None = None
113
+ ) -> list[Path]:
114
+ """Raices de scan para una categoria (`image`/`video`, extensible).
115
+
116
+ Precedencia (AC4): `[paths.<cat>]` > `--models-dir`/`IMAGE|AIOLI_MODELS_DIR`
117
+ como root+`/<cat>` > fresh XDG+legacy `~/ai/models`+`/<cat>`. `<root>/<cat>`
118
+ ausente o vacio implica scan plano de `<root>` (legacy flat + E7). `[]`
119
+ deshabilita la categoria sin fallback (E2). Un `explicit_root` que ya
120
+ termina en `/image|/video` es base final (E1). Entradas `[paths]` relativas
121
+ se resuelven contra CWD (E3, se recomienda absoluto). Categorias
122
+ desconocidas en `[paths]` se ignoran con warning (E6).
123
+ """
124
+ from .models_data import CATEGORIES
125
+
126
+ cfg = config if config is not None else load_config_safe()
127
+ raw_paths = cfg.get("paths", {}) or {}
128
+ if not isinstance(raw_paths, dict):
129
+ raw_paths = {}
130
+ unknown = [k for k in raw_paths if k not in CATEGORIES]
131
+ if unknown:
132
+ warnings.warn(
133
+ f"Unknown categor{'y' if len(unknown) == 1 else 'ies'} in [paths]: "
134
+ + ", ".join(sorted(unknown))
135
+ + " (ignored)",
136
+ UserWarning,
137
+ stacklevel=2,
138
+ )
139
+ if cat in raw_paths:
140
+ entries = raw_paths[cat]
141
+ if isinstance(entries, (str, os.PathLike)):
142
+ entries = [entries]
143
+ return [Path(e).expanduser().resolve() for e in entries]
144
+ if explicit_root is not None:
145
+ base = Path(explicit_root).expanduser().resolve()
146
+ if base.name in CATEGORIES:
147
+ return [base]
148
+ return [_category_subdir_or_flat(base, cat)]
149
+ for var in ("IMAGE_MODELS_DIR", "AIOLI_MODELS_DIR"):
150
+ override = os.environ.get(var, "").strip()
151
+ if override:
152
+ base = Path(override).expanduser().resolve()
153
+ if base.name in CATEGORIES:
154
+ return [base]
155
+ return [_category_subdir_or_flat(base, cat)]
156
+ bases: list[Path] = []
157
+ fresh = _xdg_data_home() / "aioli" / "models"
158
+ legacy = Path.home() / "ai" / "models"
159
+ if fresh.is_dir():
160
+ bases.append(fresh.resolve())
161
+ try:
162
+ legacy_resolved = legacy.resolve()
163
+ except OSError:
164
+ legacy_resolved = legacy
165
+ if legacy.is_dir() and legacy_resolved not in bases:
166
+ bases.append(legacy_resolved)
167
+ if not bases:
168
+ bases = [legacy_resolved]
169
+ out: list[Path] = []
170
+ for base in bases:
171
+ d = _category_subdir_or_flat(base, cat)
172
+ if d not in out:
173
+ out.append(d)
174
+ return out
175
+
176
+
177
+ def toml_scan_roots(models_dir: Path | None, config: dict) -> list[Path]:
178
+ """Extra scan roots contributed by `[paths]` in models.toml (AC3/AC4).
179
+
180
+ The CLI scan is cross-category ON PURPOSE (AC6: `-m <video>` must
181
+ resolve), so TOML entries are ADDED to the base `models_search_dirs`
182
+ roots and the engine's category filter keeps governing model-less
183
+ flows (wizard/list/probe). Replacing the base roots with
184
+ `resolve_category_dirs("image")` output would hide every video model
185
+ from `-m <video>` whenever `[paths].image` is declared — hence add.
186
+
187
+ Only TOML-declared roots are appended: the env/fresh/legacy fallback
188
+ branches of `resolve_category_dirs` already are (or are parents of)
189
+ the base roots, and adding `<root>/<cat>` would scan models twice
190
+ under different `rel_path` (`m.gguf` vs `image/m.gguf`), defeating
191
+ the rel_path dedupe. Without `[paths]` the result is byte-identical
192
+ to `models_search_dirs` (AC8). `[paths].<cat> = []` adds nothing
193
+ here: E2 disable is enforced by `resolve_category_dirs` at unit
194
+ level (documented divergence, see design.md E2).
195
+ """
196
+ paths = config.get("paths")
197
+ if not isinstance(paths, dict) or not paths:
198
+ return []
199
+ from .models_data import CATEGORIES # noqa: PLC0415
200
+
201
+ extra: list[Path] = []
202
+ for cat in CATEGORIES:
203
+ if cat not in paths:
204
+ continue
205
+ # Full config on purpose: a declared cat returns exactly its TOML
206
+ # entries (precedence AC4 inside resolve_category_dirs) and unknown
207
+ # `[paths]` keys still raise the E6 warning at the call site.
208
+ for root in resolve_category_dirs(cat, models_dir, config):
209
+ if root not in extra:
210
+ extra.append(root)
211
+ return extra
212
+
213
+
214
+ def composed_scan_roots(models_dir: Path | None = None, config: dict | None = None) -> list[Path]:
215
+ """Raíces de scan canónicas: `models_search_dirs` + extra `[paths]` TOML.
216
+
217
+ Composición compartida (F1): la usa `_resolve_context` (CLI) y
218
+ `category_checks` (doctor) como default, para que ambos vean las mismas
219
+ raíces — un `[paths].video` custom aparece también en el doctor.
220
+ `config=None` carga la config de usuario.
221
+ """
222
+ cfg = load_config_safe() if config is None else config
223
+ return models_search_dirs(models_dir) + toml_scan_roots(models_dir, cfg)
224
+
225
+
95
226
  def resolve_output_dir() -> Path:
96
227
  """Output directory for generated images.
97
228
 
@@ -323,9 +323,19 @@ preferred_llm = "gemma-3-12b"
323
323
  preferred_vae = "ltx-2.3-22b-dev_video_vae"
324
324
  encoder_mode = "balanced-v2"
325
325
 
326
- # MiniMax H3 Ref2VA — image-to-video + audio (ruta ai.manz.dev). Diffusion
327
- # 11.4 GB (pruned Q4) + qwen3vl-32B-minimax (13.1 GB Q2) + VAEs ≈ 27 GB →
328
- # pensado para las 2 GPUs (--gpu auto). En 1 GPU de 16 GB necesita offload.
326
+ # LTX-2.5-Distilled (22B) — video nativo, vía sd-cli vid_gen. Pesa 14.6 GiB
327
+ # el Q4_K_M y 11.8 GiB el Q3_K_S (16.0 / 13.2 GiB contando su VAE de 1.4 GiB),
328
+ # así que los dos llevan tier `high` — igual que los 22B de ltx-2.3: `_compute_
329
+ # encoder_mode` responde `balanced-v2` (diffusión en CPU) en auto/cuda0/cuda1 en
330
+ # esta doble tarjeta, o sea que NINGUNO queda residente en VRAM. El tier NO lo
331
+ # valida el probe de esta máquina: las 6 combinaciones abortan en ~1.1 s por
332
+ # validación de metadatos del runtime (falta el VAE variante conv y el Gemma4 en
333
+ # GGUF → docs/WEIGHTS.md "Pendientes"), antes de asignar VRAM. Se re-probea al
334
+ # entrar esos pesos.
335
+ # `no_llm`: el único companion utilizable del dir es el VAE; el
336
+ # `gemma4-...-comfy-int8-convrot.safetensors` es un proyector CLIP de ComfyUI,
337
+ # no un LLM de sd.cpp, así que NO se declara preferred_llm (degradación con
338
+ # VAE-only, nunca crash).
329
339
  [models."video/minimax_h3_ref2va_pruned-Q4_K_M.gguf"]
330
340
  family = "minimax"
331
341
  vram_tier = "high"
@@ -346,12 +356,15 @@ encoder_mode = "balanced-v2"
346
356
  # Stack: difusion Q4_K_M 4.19 GB + VAE 2.1 (NO intercambiable con el de 1.x) +
347
357
  # Qwen3-VL-8B-Instruct GGUF (~5 GB) + su mmproj para editar.
348
358
  # - dims multiplos de 32 y euler: handoff V8.4.
349
- # - cfg 6.0 PROVISIONAL: docs/qwen_image_2.1.md no esta en los checkouts
350
- # locales del engine (engine Aug-30 / engine-v3 2026-09-17); los docs de
351
- # qwen-image 1.x y Edit usan cfg 2.5 + --flow-shift 3. Fijar en el
352
- # smoke-test con el runtime day-0.
353
- # - encoder_mode sin curar: la familia manda balanced-v2 (seguro pero lento);
354
- # 4.2 GB + 5 GB LLM + 0.25 GB VAE ≈ 9.4 GB sugiere "balanced" o "gpu".
359
+ # - cfg 6.0, euler, dims %32 y "flow schedule segun resolucion" (sin
360
+ # --flow-shift) FIJADOS por docs/qwen_image_2.1.md de sd.cpp master, leida
361
+ # 2026-09-21 (los checkouts locales del engine son Aug-30 / 2026-09-17 y aun
362
+ # no la traen; 1.x y Edit usan cfg 2.5 + --flow-shift 3, NO vale aqui). Lo
363
+ # que la doc no fija sigue provisional: steps (20) y encoder_mode.
364
+ # - encoder_mode sin curar: el probe no pasa de la validacion de metadatos con
365
+ # el runtime vigente (runtimes-v4 / sd-cli de298c2), asi que no hay dato
366
+ # observational. Tamano real en disco: 3.9 difusor + 4.7 LLM + 1.1 mmproj +
367
+ # 0.6 VAE = 10.3 GB -> "balanced" o "gpu" siguen siendo la hipotesis.
355
368
  # `wants_llm_vision = true` es lo que deja adjuntar el mmproj con --llm_vision
356
369
  # (companions.py): sin flag, un mmproj en la carpeta no se toca nunca.
357
370
  [models."image/Qwen-Image-2.1-Q4_K_M.gguf"]
@@ -387,7 +400,8 @@ family = "ltx"
387
400
  experimental = true
388
401
  requires_runtime = "post-2026-09-20"
389
402
  vram_tier = "high"
390
- preferred_llm = "gemma4"
403
+ preferred_llm = ""
404
+ no_llm = true # hasta que exista el gemma4-...-Q8_0.gguf convertido
391
405
  preferred_vae = "ltx-2.5-video-vae-conv"
392
406
  preferred_audio_vae = "ltx-2.5-audio-vae"
393
407
  deny_roles = ["embeddings_connectors"]
@@ -400,7 +414,8 @@ family = "ltx"
400
414
  experimental = true
401
415
  requires_runtime = "post-2026-09-20"
402
416
  vram_tier = "high"
403
- preferred_llm = "gemma4"
417
+ preferred_llm = ""
418
+ no_llm = true # hasta que exista el gemma4-...-Q8_0.gguf convertido
404
419
  preferred_vae = "ltx-2.5-video-vae-conv"
405
420
  preferred_audio_vae = "ltx-2.5-audio-vae"
406
421
  deny_roles = ["embeddings_connectors"]