inline-core 1.2.31__tar.gz → 1.2.41__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 (162) hide show
  1. {inline_core-1.2.31 → inline_core-1.2.41}/PKG-INFO +9 -1
  2. {inline_core-1.2.31 → inline_core-1.2.41}/pyproject.toml +15 -1
  3. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/server/app.py +9 -0
  4. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/server/rpc.py +5 -0
  5. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/handlers.py +35 -2
  6. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/moodboard.py +106 -23
  7. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/schema.py +59 -3
  8. inline_core-1.2.41/src/inline_core/studio/system_stats.py +92 -0
  9. inline_core-1.2.41/src/inline_core/studio/training.py +458 -0
  10. inline_core-1.2.41/src/inline_core/studio/training_store.py +268 -0
  11. inline_core-1.2.41/src/inline_core/training/__init__.py +10 -0
  12. inline_core-1.2.41/src/inline_core/training/__main__.py +67 -0
  13. inline_core-1.2.41/src/inline_core/training/caption.py +127 -0
  14. inline_core-1.2.41/src/inline_core/training/dataset.py +100 -0
  15. inline_core-1.2.41/src/inline_core/training/models.py +125 -0
  16. inline_core-1.2.41/src/inline_core/training/protocol.py +49 -0
  17. inline_core-1.2.41/src/inline_core/training/trainer.py +236 -0
  18. inline_core-1.2.41/tests/test_studio_training.py +81 -0
  19. {inline_core-1.2.31 → inline_core-1.2.41}/uv.lock +171 -45
  20. {inline_core-1.2.31 → inline_core-1.2.41}/webui.sh +1 -1
  21. {inline_core-1.2.31 → inline_core-1.2.41}/.gitignore +0 -0
  22. {inline_core-1.2.31 → inline_core-1.2.41}/.python-version +0 -0
  23. {inline_core-1.2.31 → inline_core-1.2.41}/CLAUDE.md +0 -0
  24. {inline_core-1.2.31 → inline_core-1.2.41}/README.md +0 -0
  25. {inline_core-1.2.31 → inline_core-1.2.41}/main.py +0 -0
  26. {inline_core-1.2.31 → inline_core-1.2.41}/scripts/reference.py +0 -0
  27. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/__init__.py +0 -0
  28. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/components/__init__.py +0 -0
  29. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/components/conditioning.py +0 -0
  30. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/components/interfaces.py +0 -0
  31. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/config.py +0 -0
  32. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/device/__init__.py +0 -0
  33. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/device/auto.py +0 -0
  34. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/device/detect.py +0 -0
  35. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/device/memory.py +0 -0
  36. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/device/policy.py +0 -0
  37. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/device/types.py +0 -0
  38. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/errors.py +0 -0
  39. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/__init__.py +0 -0
  40. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/api.py +0 -0
  41. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/constraints.py +0 -0
  42. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/fetch.py +0 -0
  43. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/handlers.py +0 -0
  44. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/importer.py +0 -0
  45. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/install.py +0 -0
  46. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/loader.py +0 -0
  47. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/manifest.py +0 -0
  48. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/models.py +0 -0
  49. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/paths.py +0 -0
  50. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/resolve.py +0 -0
  51. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/scanner.py +0 -0
  52. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/state.py +0 -0
  53. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/extensions/tools.py +0 -0
  54. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/ffmpeg.py +0 -0
  55. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/graph/__init__.py +0 -0
  56. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/graph/cache.py +0 -0
  57. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/graph/descriptor.py +0 -0
  58. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/graph/executor.py +0 -0
  59. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/graph/loader_runners.py +0 -0
  60. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/graph/primitives.py +0 -0
  61. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/graph/registry.py +0 -0
  62. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/graph/runners.py +0 -0
  63. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/graph/schema.py +0 -0
  64. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/graph/topo.py +0 -0
  65. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/graph/validate.py +0 -0
  66. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/media.py +0 -0
  67. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/models/__init__.py +0 -0
  68. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/models/catalog.py +0 -0
  69. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/models/loaders.py +0 -0
  70. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/models/lora.py +0 -0
  71. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/models/requirements.py +0 -0
  72. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/models/sampling.py +0 -0
  73. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/models/zimage/__init__.py +0 -0
  74. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/models/zimage/primitives.py +0 -0
  75. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/models/zimage/provider.py +0 -0
  76. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/models/zimage/requirements.py +0 -0
  77. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/models/zimage/runner.py +0 -0
  78. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/parallel/__init__.py +0 -0
  79. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/parallel/config.py +0 -0
  80. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/parallel/group.py +0 -0
  81. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/parallel/launch.py +0 -0
  82. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/parallel/protocol.py +0 -0
  83. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/parallel/registry.py +0 -0
  84. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/parallel/worker.py +0 -0
  85. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/runtime/__init__.py +0 -0
  86. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/runtime/context.py +0 -0
  87. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/runtime/file_store.py +0 -0
  88. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/runtime/progress.py +0 -0
  89. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/runtime/run.py +0 -0
  90. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/runtime/store.py +0 -0
  91. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/sampling/__init__.py +0 -0
  92. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/sampling/batch.py +0 -0
  93. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/server/__init__.py +0 -0
  94. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/server/__main__.py +0 -0
  95. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/server/assets.py +0 -0
  96. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/server/bootstrap.py +0 -0
  97. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/server/frontend.py +0 -0
  98. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/server/manager.py +0 -0
  99. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/server/run_store.py +0 -0
  100. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/server/serialize.py +0 -0
  101. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/__init__.py +0 -0
  102. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/assets.py +0 -0
  103. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/config.py +0 -0
  104. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/fal.py +0 -0
  105. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/frames.py +0 -0
  106. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/generation.py +0 -0
  107. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/graph_build.py +0 -0
  108. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/models.py +0 -0
  109. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/peaks.py +0 -0
  110. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/store.py +0 -0
  111. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/timeline/__init__.py +0 -0
  112. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/timeline/compose.py +0 -0
  113. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/timeline/ffmpeg.py +0 -0
  114. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/timeline/render.py +0 -0
  115. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/studio/timeline/resolve.py +0 -0
  116. {inline_core-1.2.31 → inline_core-1.2.41}/src/inline_core/takes.py +0 -0
  117. {inline_core-1.2.31 → inline_core-1.2.41}/tests/helpers.py +0 -0
  118. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_cache.py +0 -0
  119. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_catalog.py +0 -0
  120. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_config.py +0 -0
  121. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_device_detect.py +0 -0
  122. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_executor.py +0 -0
  123. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_extension_api.py +0 -0
  124. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_extension_install.py +0 -0
  125. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_extension_manifest.py +0 -0
  126. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_extension_resolve.py +0 -0
  127. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_extension_scanner.py +0 -0
  128. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_extension_spine.py +0 -0
  129. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_extension_state.py +0 -0
  130. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_file_store.py +0 -0
  131. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_frontend_serving.py +0 -0
  132. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_hidden_nodes.py +0 -0
  133. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_loader_runners.py +0 -0
  134. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_loaders.py +0 -0
  135. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_lora.py +0 -0
  136. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_memory_policy.py +0 -0
  137. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_model_requirements.py +0 -0
  138. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_parallel_group.py +0 -0
  139. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_primitives.py +0 -0
  140. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_rpc_bridge.py +0 -0
  141. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_run_store.py +0 -0
  142. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_sampling.py +0 -0
  143. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_schema.py +0 -0
  144. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_server.py +0 -0
  145. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_studio_assets.py +0 -0
  146. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_studio_fal.py +0 -0
  147. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_studio_frames.py +0 -0
  148. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_studio_generation.py +0 -0
  149. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_studio_models.py +0 -0
  150. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_studio_moodboard.py +0 -0
  151. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_studio_peaks.py +0 -0
  152. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_studio_rpc.py +0 -0
  153. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_studio_schema.py +0 -0
  154. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_studio_store.py +0 -0
  155. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_studio_timeline.py +0 -0
  156. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_take_bytes.py +0 -0
  157. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_topo.py +0 -0
  158. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_validate.py +0 -0
  159. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_xfuser_sampler.py +0 -0
  160. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_zimage_primitives.py +0 -0
  161. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_zimage_resolve.py +0 -0
  162. {inline_core-1.2.31 → inline_core-1.2.41}/tests/test_zimage_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inline-core
3
- Version: 1.2.31
3
+ Version: 1.2.41
4
4
  Summary: The generation engine behind Inline Studio.
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: numpy>=1.26
@@ -38,6 +38,14 @@ Provides-Extra: server
38
38
  Requires-Dist: fastapi>=0.110; extra == 'server'
39
39
  Requires-Dist: imageio-ffmpeg>=0.4; extra == 'server'
40
40
  Requires-Dist: uvicorn[standard]>=0.29; extra == 'server'
41
+ Provides-Extra: training
42
+ Requires-Dist: bitsandbytes>=0.43; (platform_system != 'Darwin') and extra == 'training'
43
+ Requires-Dist: einops>=0.7; extra == 'training'
44
+ Requires-Dist: nvidia-ml-py>=12; extra == 'training'
45
+ Requires-Dist: peft>=0.11; extra == 'training'
46
+ Requires-Dist: pillow>=10; extra == 'training'
47
+ Requires-Dist: psutil>=5.9; extra == 'training'
48
+ Requires-Dist: timm>=1.0; extra == 'training'
41
49
  Description-Content-Type: text/markdown
42
50
 
43
51
  # Inline Core
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  # PyPI name; the import package is `inline_core` (src/inline_core).
3
3
  name = "inline-core"
4
- version = "1.2.31"
4
+ version = "1.2.41"
5
5
  description = "The generation engine behind Inline Studio."
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.11"
@@ -39,6 +39,20 @@ parallel = [
39
39
  "xfuser>=0.4",
40
40
  "nvidia-ml-py>=12",
41
41
  ]
42
+ # LoRA training (the Trainer tab): PEFT adapter training + local auto-caption + host/GPU telemetry.
43
+ # Install alongside `runtime`: `.[runtime,training]`. Reuses runtime's torch/diffusers/accelerate.
44
+ training = [
45
+ "peft>=0.11",
46
+ # 8-bit Adam keeps optimizer state small; no macOS wheels, so skip it there (AdamW fallback).
47
+ "bitsandbytes>=0.43; platform_system != 'Darwin'",
48
+ "Pillow>=10",
49
+ # Telemetry (CPU/RAM + per-GPU NVML) for the Trainer tab.
50
+ "psutil>=5.9",
51
+ "nvidia-ml-py>=12",
52
+ # Florence-2 auto-captioner is loaded via trust_remote_code and needs these.
53
+ "timm>=1.0",
54
+ "einops>=0.7",
55
+ ]
42
56
  dev = [
43
57
  "pytest>=8",
44
58
  "ruff>=0.6",
@@ -29,6 +29,7 @@ from ..graph.schema import SCHEMA_VERSION, parse_graph
29
29
  from ..models.catalog import ModelCatalog
30
30
  from ..models.requirements import RequirementsRegistry
31
31
  from ..runtime.file_store import FileTakeStore
32
+ from ..studio.system_stats import SystemStats
32
33
  from .assets import AssetStore
33
34
  from .manager import RunConflict, RunManager
34
35
  from .rpc import EventBroadcaster, RpcRouter
@@ -139,13 +140,19 @@ def create_app(
139
140
  manager = RunManager(registry, cache, policy, store=run_store, takes=FileTakeStore(takes_root))
140
141
  rpc = rpc or RpcRouter()
141
142
  events = events or EventBroadcaster()
143
+ # Host/GPU telemetry for the Trainer tab; only meaningful with the SPA (studio) backend wired.
144
+ stats = SystemStats(events) if studio_store is not None else None
142
145
 
143
146
  @asynccontextmanager
144
147
  async def lifespan(app: FastAPI): # noqa: ANN202
145
148
  manager.bind_loop(asyncio.get_running_loop())
146
149
  catalog.ensure_dirs()
147
150
  catalog.scan()
151
+ if stats is not None:
152
+ stats.start()
148
153
  yield
154
+ if stats is not None:
155
+ stats.stop()
149
156
  manager.shutdown()
150
157
 
151
158
  app = FastAPI(title="Inline Core", version="0.0.0", lifespan=lifespan)
@@ -298,6 +305,7 @@ def create_app(
298
305
  from ..studio.handlers import register_studio_handlers
299
306
  from ..studio.models import ModelDownloads
300
307
  from ..studio.timeline.render import Timeline
308
+ from ..studio.training import Training
301
309
 
302
310
  def core_models() -> dict[str, Any]:
303
311
  return {
@@ -316,6 +324,7 @@ def create_app(
316
324
  generation=CoreGeneration(studio_store, manager, events),
317
325
  fal_generation=FalGeneration(studio_store, events),
318
326
  timeline=Timeline(studio_store, events),
327
+ training=Training(studio_store, events, on_output=catalog.rescan),
319
328
  # Explicit model downloads write into models/; rescan so new files bump the registry.
320
329
  # The policy lets the requirements popup show a memory fit estimate before a load;
321
330
  # the requirements registry says which node types have models at all.
@@ -56,6 +56,11 @@ class EventBroadcaster:
56
56
  def __init__(self) -> None:
57
57
  self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
58
58
 
59
+ @property
60
+ def subscriber_count(self) -> int:
61
+ """Open ``/events`` sockets - lets pollers (telemetry) skip work when nobody listens."""
62
+ return len(self._subscribers)
63
+
59
64
  def add(self) -> asyncio.Queue[dict[str, Any]]:
60
65
  queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
61
66
  self._subscribers.add(queue)
@@ -39,6 +39,7 @@ def register_studio_handlers(
39
39
  generation: Any = None,
40
40
  fal_generation: Any = None,
41
41
  timeline: Any = None,
42
+ training: Any = None,
42
43
  model_downloads: Any = None,
43
44
  app_version: str = "1.0.0",
44
45
  ) -> None:
@@ -155,7 +156,9 @@ def register_studio_handlers(
155
156
  reg("frames:deleteTake", delete_take)
156
157
 
157
158
  # --- moodboard ------------------------------------------------------------------------------
158
- reg("moodboard:list", lambda: mb.list_board(conn()))
159
+ # `surface` defaults to the Studio moodboard so existing callers are unchanged; the Trainer tab
160
+ # passes "trainer" to get its own isolated canvas out of the same tables.
161
+ reg("moodboard:list", lambda surface=mb.STUDIO_SURFACE: mb.list_board(conn(), surface))
159
162
  reg("moodboard:addAsset", lambda aid, x, y: mb.add_asset(conn(), aid, x, y))
160
163
  reg("moodboard:addText", lambda x, y: mb.add_text(conn(), x, y))
161
164
  reg("moodboard:addFrameFromAsset", lambda aid, x, y: mb.add_frame_from_asset(conn(), aid, x, y))
@@ -183,7 +186,18 @@ def register_studio_handlers(
183
186
  reg("moodboard:setConnectorVolume", lambda cid, vol: mb.set_connector_volume(conn(), cid, vol))
184
187
  reg(
185
188
  "moodboard:replaceBoard",
186
- lambda items, connectors: mb.replace_board(conn(), items, connectors),
189
+ lambda items, connectors, surface=mb.STUDIO_SURFACE: mb.replace_board(
190
+ conn(), items, connectors, surface
191
+ ),
192
+ )
193
+ # Trainer-canvas nodes (plus the shared read-only resource node, which either canvas can host).
194
+ reg("moodboard:addTrainDataset", lambda x, y: mb.add_train_dataset(conn(), x, y))
195
+ reg("moodboard:addCaption", lambda x, y: mb.add_caption(conn(), x, y))
196
+ reg("moodboard:addTrainer", lambda x, y: mb.add_trainer(conn(), x, y))
197
+ reg("moodboard:addLossGraph", lambda x, y: mb.add_loss_graph(conn(), x, y))
198
+ reg(
199
+ "moodboard:addResource",
200
+ lambda x, y, surface=mb.STUDIO_SURFACE: mb.add_resource(conn(), x, y, surface),
187
201
  )
188
202
 
189
203
  # --- generation -----------------------------------------------------------------------------
@@ -207,6 +221,25 @@ def register_studio_handlers(
207
221
  reg("generation:cancel", cancel_generation)
208
222
  reg("generation:resumePending", lambda: None)
209
223
 
224
+ # --- LoRA training (dataset CRUD + the training run subprocess) ------------------------------
225
+ if training is not None:
226
+ reg("training:listDatasets", lambda: training.list_datasets())
227
+ reg("training:createDataset", lambda inp: training.create_dataset(inp))
228
+ reg("training:listItems", lambda did: training.list_items(did))
229
+ reg("training:addItems", lambda did, aids: training.add_items(did, aids))
230
+ reg("training:removeItem", lambda iid: training.remove_item(iid))
231
+ reg("training:setCaption", lambda iid, cap: training.set_caption(iid, cap))
232
+ reg("training:autoCaption", lambda did, overwrite: training.auto_caption(did, overwrite))
233
+ reg("training:listRuns", lambda: training.list_runs())
234
+ reg("training:start", lambda did, hp: training.start(did, hp))
235
+ reg("training:resume", lambda rid: training.resume(rid))
236
+ reg("training:cancel", lambda rid: training.cancel(rid))
237
+ reg("training:status", lambda rid: training.status(rid))
238
+ else:
239
+ for ch in ("listDatasets", "createDataset", "listItems", "addItems", "removeItem",
240
+ "setCaption", "autoCaption", "listRuns", "start", "resume", "cancel", "status"):
241
+ reg(f"training:{ch}", not_wired("LoRA training"))
242
+
210
243
  # --- fal settings (key stored server-side) --------------------------------------------------
211
244
  reg("falSettings:status", store.fal_status)
212
245
  reg("falSettings:setApiKey", store.set_fal_key)
@@ -17,10 +17,13 @@ from typing import Any
17
17
  from . import frames as fr
18
18
 
19
19
  _ITEM_COLUMNS = (
20
- "id, project_id, type, asset_id, frame_id, parent_id, data, x, y, width, height, rotation, "
21
- "z_index, created_at, updated_at"
20
+ "id, project_id, surface, type, asset_id, frame_id, parent_id, data, x, y, width, height, "
21
+ "rotation, z_index, created_at, updated_at"
22
22
  )
23
23
 
24
+ #: The Studio moodboard; the Trainer tab's graph is the other surface (see schema `surface`).
25
+ STUDIO_SURFACE = "studio"
26
+
24
27
  _DEFAULT_SIZE = {"image": (320, 180), "video": (360, 203), "audio": (320, 80)}
25
28
 
26
29
  _DEFAULT_TEXT = {
@@ -63,6 +66,7 @@ def _row_to_item(row: sqlite3.Row) -> dict[str, Any]:
63
66
  return {
64
67
  "id": row["id"],
65
68
  "projectId": row["project_id"],
69
+ "surface": row["surface"],
66
70
  "type": row["type"],
67
71
  "assetId": row["asset_id"],
68
72
  "frameId": row["frame_id"],
@@ -83,6 +87,7 @@ def _row_to_connector(row: sqlite3.Row) -> dict[str, Any]:
83
87
  return {
84
88
  "id": row["id"],
85
89
  "projectId": row["project_id"],
90
+ "surface": row["surface"],
86
91
  "fromItemId": row["from_item_id"],
87
92
  "toItemId": row["to_item_id"],
88
93
  "label": row["label"],
@@ -103,16 +108,28 @@ def list_items(conn: sqlite3.Connection) -> list[dict[str, Any]]:
103
108
  return [_row_to_item(r) for r in rows]
104
109
 
105
110
 
106
- def list_board(conn: sqlite3.Connection) -> dict[str, Any]:
107
- items = [_row_to_item(r) for r in conn.execute("SELECT * FROM moodboard_items").fetchall()]
111
+ def list_board(conn: sqlite3.Connection, surface: str = STUDIO_SURFACE) -> dict[str, Any]:
112
+ """One canvas's items + connectors. Surfaces are isolated: the Trainer graph never leaks into
113
+ the Studio moodboard (and vice versa)."""
114
+ items = [
115
+ _row_to_item(r)
116
+ for r in conn.execute(
117
+ "SELECT * FROM moodboard_items WHERE surface = ?", (surface,)
118
+ ).fetchall()
119
+ ]
108
120
  connectors = [
109
- _row_to_connector(r) for r in conn.execute("SELECT * FROM moodboard_connectors").fetchall()
121
+ _row_to_connector(r)
122
+ for r in conn.execute(
123
+ "SELECT * FROM moodboard_connectors WHERE surface = ?", (surface,)
124
+ ).fetchall()
110
125
  ]
111
126
  return {"items": items, "connectors": connectors}
112
127
 
113
128
 
114
- def _next_z(conn: sqlite3.Connection) -> int:
115
- row = conn.execute("SELECT MAX(z_index) AS z FROM moodboard_items").fetchone()
129
+ def _next_z(conn: sqlite3.Connection, surface: str = STUDIO_SURFACE) -> int:
130
+ row = conn.execute(
131
+ "SELECT MAX(z_index) AS z FROM moodboard_items WHERE surface = ?", (surface,)
132
+ ).fetchone()
116
133
  return (row["z"] or 0) + 1
117
134
 
118
135
 
@@ -128,11 +145,13 @@ def _insert_item(
128
145
  asset_id: str | None = None,
129
146
  frame_id: str | None = None,
130
147
  z_index: int | None = None,
148
+ surface: str = STUDIO_SURFACE,
131
149
  ) -> dict[str, Any]:
132
150
  now = _now()
133
151
  item = {
134
152
  "id": _uuid(),
135
153
  "project_id": _project_id(conn),
154
+ "surface": surface,
136
155
  "type": item_type,
137
156
  "asset_id": asset_id,
138
157
  "frame_id": frame_id,
@@ -143,14 +162,14 @@ def _insert_item(
143
162
  "width": width,
144
163
  "height": height,
145
164
  "rotation": 0,
146
- "z_index": z_index if z_index is not None else _next_z(conn),
165
+ "z_index": z_index if z_index is not None else _next_z(conn, surface),
147
166
  "created_at": now,
148
167
  "updated_at": now,
149
168
  }
150
169
  conn.execute(
151
170
  f"INSERT INTO moodboard_items ({_ITEM_COLUMNS}) VALUES "
152
- "(:id, :project_id, :type, :asset_id, :frame_id, :parent_id, :data, :x, :y, :width, "
153
- ":height, :rotation, :z_index, :created_at, :updated_at)",
171
+ "(:id, :project_id, :surface, :type, :asset_id, :frame_id, :parent_id, :data, :x, :y, "
172
+ ":width, :height, :rotation, :z_index, :created_at, :updated_at)",
154
173
  item,
155
174
  )
156
175
  return get_item(conn, item["id"])
@@ -209,6 +228,57 @@ def add_gen_node(
209
228
  )
210
229
 
211
230
 
231
+ #: Trainer-canvas nodes. They only ever exist on the `trainer` surface (the Trainer tab's graph);
232
+ #: `resource` is the exception - a read-only telemetry node usable on either canvas.
233
+ TRAINER_SURFACE = "trainer"
234
+
235
+
236
+ def add_train_dataset(
237
+ conn: sqlite3.Connection, x: float, y: float, surface: str = TRAINER_SURFACE
238
+ ) -> dict[str, Any]:
239
+ return _insert_item(
240
+ conn, item_type="trainDataset", x=x, y=y, width=280, height=240,
241
+ data={"datasetId": None}, surface=surface,
242
+ )
243
+
244
+
245
+ def add_caption(
246
+ conn: sqlite3.Connection, x: float, y: float, surface: str = TRAINER_SURFACE
247
+ ) -> dict[str, Any]:
248
+ return _insert_item(
249
+ conn, item_type="caption", x=x, y=y, width=280, height=200,
250
+ data={"datasetId": None, "overwrite": False}, surface=surface,
251
+ )
252
+
253
+
254
+ def add_trainer(
255
+ conn: sqlite3.Connection, x: float, y: float, surface: str = TRAINER_SURFACE
256
+ ) -> dict[str, Any]:
257
+ return _insert_item(
258
+ # Roomier than the other nodes on purpose: the body is a live log tail.
259
+ conn, item_type="trainer", x=x, y=y, width=420, height=340,
260
+ # `runId` is persisted so the node rebinds to its run after a reload and can still Resume.
261
+ data={"datasetId": None, "runId": None, "hyperparams": {}}, surface=surface,
262
+ )
263
+
264
+
265
+ def add_loss_graph(
266
+ conn: sqlite3.Connection, x: float, y: float, surface: str = TRAINER_SURFACE
267
+ ) -> dict[str, Any]:
268
+ return _insert_item(
269
+ conn, item_type="lossGraph", x=x, y=y, width=320, height=220,
270
+ data={"runId": None}, surface=surface,
271
+ )
272
+
273
+
274
+ def add_resource(
275
+ conn: sqlite3.Connection, x: float, y: float, surface: str = STUDIO_SURFACE
276
+ ) -> dict[str, Any]:
277
+ return _insert_item(
278
+ conn, item_type="resource", x=x, y=y, width=280, height=170, data={}, surface=surface
279
+ )
280
+
281
+
212
282
  def add_layer(conn: sqlite3.Connection, x: float, y: float) -> dict[str, Any]:
213
283
  return _insert_item(
214
284
  conn, item_type="layer", x=x, y=y, width=420, height=300, data={"name": "Layer"}, z_index=0
@@ -306,20 +376,29 @@ def delete_item(conn: sqlite3.Connection, item_id: str) -> None:
306
376
 
307
377
 
308
378
  def replace_board(
309
- conn: sqlite3.Connection, items: list[dict[str, Any]], connectors: list[dict[str, Any]]
379
+ conn: sqlite3.Connection,
380
+ items: list[dict[str, Any]],
381
+ connectors: list[dict[str, Any]],
382
+ surface: str = STUDIO_SURFACE,
310
383
  ) -> None:
311
- """Replace the whole board (undo/redo restore), preserving ids, in one transaction."""
384
+ """Replace ONE surface's board (undo/redo restore), preserving ids, in one transaction. Scoped
385
+ by surface so a Studio undo never wipes the Trainer canvas (they share the table)."""
312
386
  pid = _project_id(conn)
313
- conn.execute("DELETE FROM moodboard_connectors WHERE project_id = ?", (pid,))
314
- conn.execute("DELETE FROM moodboard_items WHERE project_id = ?", (pid,))
387
+ conn.execute(
388
+ "DELETE FROM moodboard_connectors WHERE project_id = ? AND surface = ?", (pid, surface)
389
+ )
390
+ conn.execute(
391
+ "DELETE FROM moodboard_items WHERE project_id = ? AND surface = ?", (pid, surface)
392
+ )
315
393
  for it in items:
316
394
  conn.execute(
317
395
  f"INSERT INTO moodboard_items ({_ITEM_COLUMNS}) VALUES "
318
- "(:id, :project_id, :type, :asset_id, :frame_id, :parent_id, :data, :x, :y, :width, "
319
- ":height, :rotation, :z_index, :created_at, :updated_at)",
396
+ "(:id, :project_id, :surface, :type, :asset_id, :frame_id, :parent_id, :data, :x, :y, "
397
+ ":width, :height, :rotation, :z_index, :created_at, :updated_at)",
320
398
  {
321
399
  "id": it["id"],
322
400
  "project_id": pid,
401
+ "surface": surface,
323
402
  "type": it["type"],
324
403
  "asset_id": it.get("assetId"),
325
404
  "frame_id": it.get("frameId"),
@@ -338,11 +417,12 @@ def replace_board(
338
417
  for c in connectors:
339
418
  conn.execute(
340
419
  "INSERT INTO moodboard_connectors "
341
- "(id, project_id, from_item_id, to_item_id, label, data, created_at) "
342
- "VALUES (:id, :project_id, :from_item_id, :to_item_id, :label, :data, :created_at)",
420
+ "(id, project_id, surface, from_item_id, to_item_id, label, data, created_at) VALUES "
421
+ "(:id, :project_id, :surface, :from_item_id, :to_item_id, :label, :data, :created_at)",
343
422
  {
344
423
  "id": c["id"],
345
424
  "project_id": pid,
425
+ "surface": surface,
346
426
  "from_item_id": c["fromItemId"],
347
427
  "to_item_id": c["toItemId"],
348
428
  "label": c.get("label"),
@@ -359,11 +439,14 @@ def create_connector(
359
439
  source_handle: str | None = None,
360
440
  target_handle: str | None = None,
361
441
  ) -> dict[str, Any]:
362
- get_item(conn, from_item_id)
442
+ source = get_item(conn, from_item_id)
363
443
  get_item(conn, to_item_id)
364
444
  connector = {
365
445
  "id": _uuid(),
366
446
  "projectId": _project_id(conn),
447
+ # A connector lives on the same canvas as the nodes it joins, so it's derived rather than
448
+ # passed in - the caller never has to know which surface it's drawing on.
449
+ "surface": source["surface"],
367
450
  "fromItemId": from_item_id,
368
451
  "toItemId": to_item_id,
369
452
  "label": None,
@@ -372,11 +455,11 @@ def create_connector(
372
455
  }
373
456
  conn.execute(
374
457
  "INSERT INTO moodboard_connectors "
375
- "(id, project_id, from_item_id, to_item_id, label, data, created_at) "
376
- "VALUES (?, ?, ?, ?, ?, ?, ?)",
458
+ "(id, project_id, surface, from_item_id, to_item_id, label, data, created_at) "
459
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
377
460
  (
378
- connector["id"], connector["projectId"], from_item_id, to_item_id, None,
379
- json.dumps(connector["data"]), connector["createdAt"],
461
+ connector["id"], connector["projectId"], connector["surface"], from_item_id,
462
+ to_item_id, None, json.dumps(connector["data"]), connector["createdAt"],
380
463
  ),
381
464
  )
382
465
  return connector
@@ -1,16 +1,18 @@
1
1
  """SQLite schema for a project's ``project.db`` - a faithful port of the Studio TypeScript
2
- ``electron/main/db/schema.ts`` (SCHEMA_VERSION 14). The DB is the source of truth for a project;
3
- "save" is implicit. Bumping ``SCHEMA_VERSION`` + adding a migration is how the schema evolves.
2
+ ``electron/main/db/schema.ts`` (ported at SCHEMA_VERSION 14). The DB is the source of truth for a
3
+ project; "save" is implicit. Bump ``SCHEMA_VERSION`` + add a migration to evolve the schema.
4
4
 
5
5
  Kept byte-compatible with the Node schema so Core can open existing ``.inlinestudio`` projects: same
6
6
  tables, same column names, same ``user_version`` stamping, and the same additive/rename migrations.
7
+ v15 adds the Core-only LoRA training tables (``training_datasets``/``_dataset_items``/``_runs``);
8
+ they are purely additive, so an older project gains them on next open.
7
9
  """
8
10
 
9
11
  from __future__ import annotations
10
12
 
11
13
  import sqlite3
12
14
 
13
- SCHEMA_VERSION = 14
15
+ SCHEMA_VERSION = 16
14
16
 
15
17
  SCHEMA_SQL = """
16
18
  CREATE TABLE IF NOT EXISTS project (
@@ -86,6 +88,9 @@ CREATE TABLE IF NOT EXISTS assets (
86
88
  CREATE TABLE IF NOT EXISTS moodboard_items (
87
89
  id TEXT PRIMARY KEY,
88
90
  project_id TEXT NOT NULL,
91
+ -- Which canvas the item lives on: the Studio moodboard or the Trainer tab's graph. One table,
92
+ -- two surfaces, so both canvases reuse the same item/connector plumbing.
93
+ surface TEXT NOT NULL DEFAULT 'studio',
89
94
  type TEXT NOT NULL DEFAULT 'asset',
90
95
  asset_id TEXT,
91
96
  frame_id TEXT,
@@ -104,6 +109,7 @@ CREATE TABLE IF NOT EXISTS moodboard_items (
104
109
  CREATE TABLE IF NOT EXISTS moodboard_connectors (
105
110
  id TEXT PRIMARY KEY,
106
111
  project_id TEXT NOT NULL,
112
+ surface TEXT NOT NULL DEFAULT 'studio',
107
113
  from_item_id TEXT NOT NULL,
108
114
  to_item_id TEXT NOT NULL,
109
115
  label TEXT,
@@ -137,8 +143,50 @@ CREATE INDEX IF NOT EXISTS idx_frame_inputs_frame ON frame_inputs(frame_id);
137
143
  CREATE INDEX IF NOT EXISTS idx_assets_project ON assets(project_id);
138
144
  CREATE INDEX IF NOT EXISTS idx_assets_folder ON assets(folder_id);
139
145
  CREATE INDEX IF NOT EXISTS idx_asset_folders_parent ON asset_folders(parent_id);
146
+ CREATE TABLE IF NOT EXISTS training_datasets (
147
+ id TEXT PRIMARY KEY,
148
+ project_id TEXT NOT NULL,
149
+ name TEXT NOT NULL,
150
+ trigger_word TEXT NOT NULL DEFAULT '',
151
+ created_at INTEGER NOT NULL,
152
+ updated_at INTEGER NOT NULL
153
+ );
154
+
155
+ CREATE TABLE IF NOT EXISTS training_dataset_items (
156
+ id TEXT PRIMARY KEY,
157
+ dataset_id TEXT NOT NULL,
158
+ asset_id TEXT NOT NULL,
159
+ caption TEXT NOT NULL DEFAULT '',
160
+ position INTEGER NOT NULL,
161
+ created_at INTEGER NOT NULL
162
+ );
163
+
164
+ CREATE TABLE IF NOT EXISTS training_runs (
165
+ id TEXT PRIMARY KEY,
166
+ project_id TEXT NOT NULL,
167
+ dataset_id TEXT NOT NULL,
168
+ name TEXT NOT NULL,
169
+ status TEXT NOT NULL DEFAULT 'queued',
170
+ base_mode TEXT NOT NULL DEFAULT 'turbo_adapter',
171
+ hyperparams TEXT NOT NULL DEFAULT '{}',
172
+ output_lora_path TEXT,
173
+ progress_fraction REAL NOT NULL DEFAULT 0,
174
+ progress_status TEXT,
175
+ step INTEGER NOT NULL DEFAULT 0,
176
+ total_steps INTEGER NOT NULL DEFAULT 0,
177
+ checkpoint_path TEXT,
178
+ gpu_ids TEXT NOT NULL DEFAULT '[]',
179
+ error TEXT,
180
+ created_at INTEGER NOT NULL,
181
+ updated_at INTEGER NOT NULL
182
+ );
183
+
140
184
  CREATE INDEX IF NOT EXISTS idx_moodboard_items_project ON moodboard_items(project_id);
141
185
  CREATE INDEX IF NOT EXISTS idx_moodboard_connectors_project ON moodboard_connectors(project_id);
186
+ CREATE INDEX IF NOT EXISTS idx_training_datasets_project ON training_datasets(project_id);
187
+ CREATE INDEX IF NOT EXISTS idx_training_dataset_items_dataset ON training_dataset_items(dataset_id);
188
+ CREATE INDEX IF NOT EXISTS idx_training_runs_project ON training_runs(project_id);
189
+ CREATE INDEX IF NOT EXISTS idx_training_runs_dataset ON training_runs(dataset_id);
142
190
  """
143
191
 
144
192
 
@@ -187,6 +235,14 @@ def _migrate_columns(conn: sqlite3.Connection) -> None:
187
235
 
188
236
  _add_column_if_missing(conn, "moodboard_items", "frame_id", "TEXT")
189
237
  _add_column_if_missing(conn, "moodboard_items", "parent_id", "TEXT")
238
+
239
+ # v15 -> v16: the Trainer tab gets its own canvas; existing items stay on the Studio surface.
240
+ _add_column_if_missing(
241
+ conn, "moodboard_items", "surface", "TEXT NOT NULL DEFAULT 'studio'"
242
+ )
243
+ _add_column_if_missing(
244
+ conn, "moodboard_connectors", "surface", "TEXT NOT NULL DEFAULT 'studio'"
245
+ )
190
246
  _add_column_if_missing(conn, "frame_inputs", "source_frame_id", "TEXT")
191
247
 
192
248
  _relax_frame_inputs_asset_id(conn) # v8 -> v9: asset_id must be nullable
@@ -0,0 +1,92 @@
1
+ """Periodic host + GPU telemetry, broadcast on ``events:systemStats`` for the Trainer tab.
2
+
3
+ Samples CPU/RAM (psutil) and per-GPU utilization/VRAM/temp (pynvml) about once a second and fans it
4
+ out to every ``/events`` subscriber. Skips sampling entirely when nobody is listening, so it costs
5
+ nothing with no client connected. GPU stats are NVIDIA-only; an MPS/CPU host reports host metrics
6
+ with an empty ``gpus`` list. Missing psutil/pynvml degrade to zeros rather than erroring.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from typing import Any
13
+
14
+ _INTERVAL_SECONDS = 1.5
15
+
16
+
17
+ class SystemStats:
18
+ """A background poller that broadcasts ``SystemStatsEvent`` frames while clients connect."""
19
+
20
+ def __init__(self, events: Any) -> None:
21
+ self._events = events
22
+ self._task: asyncio.Task[None] | None = None
23
+ self._nvml_ready = False
24
+
25
+ def start(self) -> None:
26
+ if self._task is None:
27
+ self._task = asyncio.create_task(self._loop())
28
+
29
+ def stop(self) -> None:
30
+ if self._task is not None:
31
+ self._task.cancel()
32
+ self._task = None
33
+
34
+ async def _loop(self) -> None:
35
+ try:
36
+ while True:
37
+ await asyncio.sleep(_INTERVAL_SECONDS)
38
+ if self._events.subscriber_count == 0:
39
+ continue # nobody watching - don't spend a sample
40
+ stats = await asyncio.to_thread(self._sample)
41
+ self._events.broadcast("events:systemStats", stats)
42
+ except asyncio.CancelledError:
43
+ pass
44
+
45
+ def _sample(self) -> dict[str, Any]:
46
+ cpu, ram_used, ram_total = self._host()
47
+ return {"cpu": cpu, "ramUsed": ram_used, "ramTotal": ram_total, "gpus": self._gpus()}
48
+
49
+ def _host(self) -> tuple[float, int, int]:
50
+ try:
51
+ import psutil
52
+
53
+ vm = psutil.virtual_memory()
54
+ return float(psutil.cpu_percent()), int(vm.used), int(vm.total)
55
+ except Exception: # noqa: BLE001 - psutil optional; telemetry degrades to zeros
56
+ return 0.0, 0, 0
57
+
58
+ def _gpus(self) -> list[dict[str, Any]]:
59
+ try:
60
+ import pynvml
61
+ except Exception: # noqa: BLE001 - no NVML (non-NVIDIA or extra not installed)
62
+ return []
63
+ try:
64
+ if not self._nvml_ready:
65
+ pynvml.nvmlInit()
66
+ self._nvml_ready = True
67
+ out: list[dict[str, Any]] = []
68
+ for i in range(pynvml.nvmlDeviceGetCount()):
69
+ handle = pynvml.nvmlDeviceGetHandleByIndex(i)
70
+ mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
71
+ util = pynvml.nvmlDeviceGetUtilizationRates(handle)
72
+ name = pynvml.nvmlDeviceGetName(handle)
73
+ out.append(
74
+ {
75
+ "index": i,
76
+ "name": name.decode() if isinstance(name, bytes) else name,
77
+ "utilization": float(util.gpu),
78
+ "memoryUsed": int(mem.used),
79
+ "memoryTotal": int(mem.total),
80
+ "temperature": _temperature(pynvml, handle),
81
+ }
82
+ )
83
+ return out
84
+ except Exception: # noqa: BLE001 - any NVML hiccup: report no GPUs rather than crash
85
+ return []
86
+
87
+
88
+ def _temperature(pynvml: Any, handle: Any) -> float | None:
89
+ try:
90
+ return float(pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU))
91
+ except Exception: # noqa: BLE001
92
+ return None