inline-core 1.2.2__tar.gz → 1.2.3__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 (152) hide show
  1. {inline_core-1.2.2 → inline_core-1.2.3}/CLAUDE.md +3 -6
  2. {inline_core-1.2.2 → inline_core-1.2.3}/PKG-INFO +3 -4
  3. {inline_core-1.2.2 → inline_core-1.2.3}/README.md +2 -3
  4. {inline_core-1.2.2 → inline_core-1.2.3}/pyproject.toml +1 -1
  5. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/fetch.py +8 -2
  6. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/server/__main__.py +6 -1
  7. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/config.py +0 -1
  8. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/frames.py +4 -17
  9. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/graph_build.py +13 -2
  10. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/handlers.py +2 -8
  11. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/models.py +42 -59
  12. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/moodboard.py +8 -0
  13. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/store.py +0 -8
  14. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_extension_install.py +27 -0
  15. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_studio_frames.py +3 -2
  16. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_studio_generation.py +25 -1
  17. inline_core-1.2.3/tests/test_studio_models.py +90 -0
  18. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_studio_rpc.py +0 -3
  19. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_studio_store.py +2 -5
  20. inline_core-1.2.2/src/inline_core/importer/__init__.py +0 -1
  21. inline_core-1.2.2/src/inline_core/importer/comfy.py +0 -162
  22. inline_core-1.2.2/tests/test_comfy_import.py +0 -80
  23. inline_core-1.2.2/tests/test_studio_models.py +0 -84
  24. {inline_core-1.2.2 → inline_core-1.2.3}/.gitignore +0 -0
  25. {inline_core-1.2.2 → inline_core-1.2.3}/.python-version +0 -0
  26. {inline_core-1.2.2 → inline_core-1.2.3}/main.py +0 -0
  27. {inline_core-1.2.2 → inline_core-1.2.3}/scripts/reference.py +0 -0
  28. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/__init__.py +0 -0
  29. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/components/__init__.py +0 -0
  30. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/components/conditioning.py +0 -0
  31. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/components/interfaces.py +0 -0
  32. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/config.py +0 -0
  33. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/device/__init__.py +0 -0
  34. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/device/auto.py +0 -0
  35. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/device/detect.py +0 -0
  36. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/device/memory.py +0 -0
  37. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/device/policy.py +0 -0
  38. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/device/types.py +0 -0
  39. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/errors.py +0 -0
  40. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/__init__.py +0 -0
  41. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/api.py +0 -0
  42. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/constraints.py +0 -0
  43. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/handlers.py +0 -0
  44. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/importer.py +0 -0
  45. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/install.py +0 -0
  46. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/loader.py +0 -0
  47. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/manifest.py +0 -0
  48. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/models.py +0 -0
  49. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/paths.py +0 -0
  50. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/resolve.py +0 -0
  51. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/scanner.py +0 -0
  52. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/state.py +0 -0
  53. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/extensions/tools.py +0 -0
  54. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/graph/__init__.py +0 -0
  55. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/graph/cache.py +0 -0
  56. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/graph/descriptor.py +0 -0
  57. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/graph/executor.py +0 -0
  58. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/graph/loader_runners.py +0 -0
  59. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/graph/primitives.py +0 -0
  60. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/graph/registry.py +0 -0
  61. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/graph/runners.py +0 -0
  62. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/graph/schema.py +0 -0
  63. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/graph/topo.py +0 -0
  64. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/graph/validate.py +0 -0
  65. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/media.py +0 -0
  66. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/models/__init__.py +0 -0
  67. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/models/catalog.py +0 -0
  68. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/models/loaders.py +0 -0
  69. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/models/requirements.py +0 -0
  70. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/models/sampling.py +0 -0
  71. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/models/zimage/__init__.py +0 -0
  72. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/models/zimage/primitives.py +0 -0
  73. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/models/zimage/provider.py +0 -0
  74. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/models/zimage/requirements.py +0 -0
  75. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/models/zimage/runner.py +0 -0
  76. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/parallel/__init__.py +0 -0
  77. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/parallel/config.py +0 -0
  78. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/parallel/group.py +0 -0
  79. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/parallel/launch.py +0 -0
  80. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/parallel/protocol.py +0 -0
  81. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/parallel/registry.py +0 -0
  82. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/parallel/worker.py +0 -0
  83. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/runtime/__init__.py +0 -0
  84. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/runtime/context.py +0 -0
  85. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/runtime/file_store.py +0 -0
  86. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/runtime/progress.py +0 -0
  87. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/runtime/run.py +0 -0
  88. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/runtime/store.py +0 -0
  89. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/sampling/__init__.py +0 -0
  90. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/sampling/batch.py +0 -0
  91. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/server/__init__.py +0 -0
  92. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/server/app.py +0 -0
  93. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/server/assets.py +0 -0
  94. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/server/bootstrap.py +0 -0
  95. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/server/frontend.py +0 -0
  96. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/server/manager.py +0 -0
  97. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/server/rpc.py +0 -0
  98. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/server/run_store.py +0 -0
  99. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/server/serialize.py +0 -0
  100. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/__init__.py +0 -0
  101. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/assets.py +0 -0
  102. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/fal.py +0 -0
  103. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/generation.py +0 -0
  104. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/peaks.py +0 -0
  105. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/schema.py +0 -0
  106. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/timeline/__init__.py +0 -0
  107. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/timeline/compose.py +0 -0
  108. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/timeline/ffmpeg.py +0 -0
  109. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/timeline/render.py +0 -0
  110. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/studio/timeline/resolve.py +0 -0
  111. {inline_core-1.2.2 → inline_core-1.2.3}/src/inline_core/takes.py +0 -0
  112. {inline_core-1.2.2 → inline_core-1.2.3}/tests/helpers.py +0 -0
  113. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_cache.py +0 -0
  114. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_catalog.py +0 -0
  115. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_config.py +0 -0
  116. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_device_detect.py +0 -0
  117. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_executor.py +0 -0
  118. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_extension_api.py +0 -0
  119. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_extension_manifest.py +0 -0
  120. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_extension_resolve.py +0 -0
  121. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_extension_scanner.py +0 -0
  122. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_extension_spine.py +0 -0
  123. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_extension_state.py +0 -0
  124. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_file_store.py +0 -0
  125. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_frontend_serving.py +0 -0
  126. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_hidden_nodes.py +0 -0
  127. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_loader_runners.py +0 -0
  128. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_loaders.py +0 -0
  129. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_memory_policy.py +0 -0
  130. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_model_requirements.py +0 -0
  131. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_parallel_group.py +0 -0
  132. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_primitives.py +0 -0
  133. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_rpc_bridge.py +0 -0
  134. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_run_store.py +0 -0
  135. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_sampling.py +0 -0
  136. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_schema.py +0 -0
  137. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_server.py +0 -0
  138. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_studio_assets.py +0 -0
  139. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_studio_fal.py +0 -0
  140. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_studio_moodboard.py +0 -0
  141. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_studio_peaks.py +0 -0
  142. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_studio_schema.py +0 -0
  143. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_studio_timeline.py +0 -0
  144. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_take_bytes.py +0 -0
  145. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_topo.py +0 -0
  146. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_validate.py +0 -0
  147. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_xfuser_sampler.py +0 -0
  148. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_zimage_primitives.py +0 -0
  149. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_zimage_resolve.py +0 -0
  150. {inline_core-1.2.2 → inline_core-1.2.3}/tests/test_zimage_runner.py +0 -0
  151. {inline_core-1.2.2 → inline_core-1.2.3}/uv.lock +0 -0
  152. {inline_core-1.2.2 → inline_core-1.2.3}/webui.sh +0 -0
@@ -82,8 +82,6 @@ HTTP/WS → server/app.py → RunManager → Executor → Registry (desc
82
82
  - **`runtime/`** - `context.py` (`ExecutionContext`, `CancelToken`), `run.py` (`RunState`),
83
83
  `progress.py` (events + emitters), `store.py` / `file_store.py` (`TakeStore`: owns take bytes/hash/
84
84
  uri).
85
- - **`importer/`** - `comfy.py`: best-effort ComfyUI-workflow → Inline-Core-graph mapping. All ComfyUI
86
- format knowledge lives here.
87
85
  - **`config.py`** - all env config, small and explicit. **`takes.py`**, **`media.py`**, **`errors.py`**
88
86
  - domain primitives (`Take`/`AssetRef`, `MediaKind`, the error hierarchy).
89
87
 
@@ -200,13 +198,13 @@ real codec that moves tensors lives with the model runner.
200
198
  subpackages import torch/diffusers at module top **on purpose**: an absent extra makes the import
201
199
  raise, and `server/bootstrap.py` skips that model best-effort so a core install still boots and
202
200
  serves source nodes. Never import a heavy dep at package top level outside a runner subpackage.
203
- - **Engine isolation.** All ComfyUI-format knowledge lives in `importer/comfy.py`; all xDiT/worker
204
- knowledge behind `parallel/` and the sampler seam. Don't scatter it.
201
+ - **Engine isolation.** All xDiT/worker knowledge lives behind `parallel/` and the sampler seam.
202
+ Don't scatter it.
205
203
  - **Bring-your-own models.** Nothing is downloaded by the engine. The catalog scans; the user places
206
204
  files. A model picker is a `SELECT` param with `options_from="<category>"`.
207
205
  - **Tests (pytest).** Cover the logic that matters: graph validate/topo/executor/cache, the catalog
208
206
  scan, the run store + server contract, the device/memory policy, the parallel group + xfuser seam,
209
- the comfy importer, and each model runner (import-guarded, no GPU needed). See `tests/`.
207
+ and each model runner (import-guarded, no GPU needed). See `tests/`.
210
208
  - **Commits.** Conventional Commits (`feat:`, `fix:`, `chore:`), small and scoped.
211
209
 
212
210
  ## Commands
@@ -239,4 +237,3 @@ uv run pytest -q # tests (no GPU; model code is import-
239
237
  as `{error:{code,message}}` with the right status. Update the API list in `README.md`.
240
238
  - **New port/handle type** → `PortKind` in `graph/schema.py` (+ `port_satisfies` if it has coercions).
241
239
  - **New device/memory behaviour** → behind `DevicePolicy` in `device/`; never in a component.
242
- - **New ComfyUI import behaviour** → `importer/comfy.py` only.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inline-core
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: The generation engine behind Inline Studio.
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: numpy>=1.26
@@ -53,7 +53,7 @@ already supports, so the multi-GPU split works on it from the start).
53
53
  > Status: early, and running end to end against a stub engine. In place and tested: the graph engine,
54
54
  > the typed `/v1` HTTP + websocket API (durable runs, streamed progress, coalescing), the model-dir
55
55
  > scan, the device + memory policy (profiles, dtype, offload, int8), the low-level primitive node
56
- > vocabulary, and a ComfyUI workflow importer. The Z-Image loader is written and validates on a GPU.
56
+ > vocabulary. The Z-Image loader is written and validates on a GPU.
57
57
  > Cross-request batching, single-image multi-GPU (an xDiT worker group behind the sampler seam, with
58
58
  > the policy and IPC round-trip tested), and out-of-process custom nodes are built as seams but not
59
59
  > yet running on real hardware.
@@ -131,8 +131,7 @@ Underneath, a **low-level primitive vocabulary** exists (`load/diffusion-model`,
131
131
  `load/text-encoder`, `encode/text`, `latent/empty`, `sample`, `vae/decode`, `vae/encode`) - the
132
132
  ComfyUI-equivalent decomposed graph, kept for validation/execution - but these are marked **`hidden`**
133
133
  and never appear in the add-node menu. Engine handles (`model`, `vae`, `text-encoder`, `conditioning`,
134
- `latent`) are typed sockets between nodes; only media outputs become Frames with take history. A
135
- best-effort ComfyUI importer maps existing workflows onto the primitives.
134
+ `latent`) are typed sockets between nodes; only media outputs become Frames with take history.
136
135
 
137
136
  ## Multi-GPU: split one image across GPUs
138
137
 
@@ -11,7 +11,7 @@ already supports, so the multi-GPU split works on it from the start).
11
11
  > Status: early, and running end to end against a stub engine. In place and tested: the graph engine,
12
12
  > the typed `/v1` HTTP + websocket API (durable runs, streamed progress, coalescing), the model-dir
13
13
  > scan, the device + memory policy (profiles, dtype, offload, int8), the low-level primitive node
14
- > vocabulary, and a ComfyUI workflow importer. The Z-Image loader is written and validates on a GPU.
14
+ > vocabulary. The Z-Image loader is written and validates on a GPU.
15
15
  > Cross-request batching, single-image multi-GPU (an xDiT worker group behind the sampler seam, with
16
16
  > the policy and IPC round-trip tested), and out-of-process custom nodes are built as seams but not
17
17
  > yet running on real hardware.
@@ -89,8 +89,7 @@ Underneath, a **low-level primitive vocabulary** exists (`load/diffusion-model`,
89
89
  `load/text-encoder`, `encode/text`, `latent/empty`, `sample`, `vae/decode`, `vae/encode`) - the
90
90
  ComfyUI-equivalent decomposed graph, kept for validation/execution - but these are marked **`hidden`**
91
91
  and never appear in the add-node menu. Engine handles (`model`, `vae`, `text-encoder`, `conditioning`,
92
- `latent`) are typed sockets between nodes; only media outputs become Frames with take history. A
93
- best-effort ComfyUI importer maps existing workflows onto the primitives.
92
+ `latent`) are typed sockets between nodes; only media outputs become Frames with take history.
94
93
 
95
94
  ## Multi-GPU: split one image across GPUs
96
95
 
@@ -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.2"
4
+ version = "1.2.3"
5
5
  description = "The generation engine behind Inline Studio."
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.11"
@@ -49,12 +49,18 @@ def remote_sha(url: str, ref: str) -> str | None:
49
49
  an update check must never fail the dialog."""
50
50
  try:
51
51
  _validate(url, ref)
52
- done = _git("ls-remote", url, ref, check=False)
52
+ # Also request the peeled ref: an exact-match pattern alone won't return the `^{}` line.
53
+ done = _git("ls-remote", url, ref, f"{ref}^{{}}", check=False)
53
54
  except (FetchError, OSError, subprocess.SubprocessError):
54
55
  return None
55
56
  if done.returncode != 0:
56
57
  return None
57
- line = done.stdout.strip().split("\n")[0]
58
+ lines = [ln for ln in done.stdout.splitlines() if ln.strip()]
59
+ # An annotated tag lists its own object sha first and the commit it points to on a `^{}` line.
60
+ # Prefer the peeled commit, since the installed sha is a commit (`_resolve` uses `^{commit}`) -
61
+ # otherwise every annotated-tag release reads as perpetually "update available".
62
+ peeled = next((ln for ln in lines if ln.rstrip().endswith("^{}")), None)
63
+ line = peeled or (lines[0] if lines else "")
58
64
  sha = line.split()[0] if line else ""
59
65
  return sha or None
60
66
 
@@ -10,6 +10,12 @@ import os as _os
10
10
  # `python -m inline_core.server`. A user-set value always wins.
11
11
  _os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
12
12
 
13
+ # XET download acceleration tracks its bytes internally, so a model download reports no per-chunk
14
+ # progress - the popup's bar would sit at 0% then snap to done. The plain HTTP path streams a real
15
+ # fraction (and still resumes). huggingface_hub reads this at import, so it must be set before then.
16
+ # A user-set value always wins.
17
+ _os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
18
+
13
19
  import uvicorn
14
20
 
15
21
  from ..config import data_dir, server_host, server_port
@@ -60,7 +66,6 @@ def main() -> None:
60
66
  store = StudioStore(
61
67
  studio_config.data_dir(),
62
68
  studio_config.workspace_dir(),
63
- default_comfy_url=studio_config.DEFAULT_COMFY_URL,
64
69
  default_core_url=studio_config.DEFAULT_CORE_URL,
65
70
  )
66
71
  print(f"Studio data: {studio_config.data_dir()} | workspace: {studio_config.workspace_dir()}")
@@ -12,7 +12,6 @@ from __future__ import annotations
12
12
  import os
13
13
  from pathlib import Path
14
14
 
15
- DEFAULT_COMFY_URL = os.environ.get("COMFYUI_URL") or "http://127.0.0.1:8188"
16
15
  DEFAULT_CORE_URL = os.environ.get("INLINE_CORE_URL") or "http://127.0.0.1:8848"
17
16
 
18
17
 
@@ -43,8 +43,8 @@ def _parse_params(raw: str | None) -> dict[str, Any]:
43
43
 
44
44
 
45
45
  def _row_to_frame(row: sqlite3.Row) -> dict[str, Any]:
46
- provider = row["provider"]
47
- provider = provider if provider in ("fal", "unset", "core") else "comfy"
46
+ # A legacy 'comfy' row (ComfyUI removed) passes through unchanged; the UI renders it as a plain
47
+ # viewer. NULL/empty defaults to 'unset'.
48
48
  return {
49
49
  "id": row["id"],
50
50
  "sequenceId": row["sequence_id"],
@@ -53,12 +53,10 @@ def _row_to_frame(row: sqlite3.Row) -> dict[str, Any]:
53
53
  "position": row["position"],
54
54
  "inputAssetId": row["input_asset_id"],
55
55
  "heroTakeId": row["hero_take_id"],
56
- "provider": provider,
56
+ "provider": row["provider"] or "unset",
57
57
  "modelId": row["model_id"],
58
58
  "params": _parse_params(row["params"]),
59
59
  "workflowTemplateId": row["workflow_template_id"],
60
- "comfyWorkflowName": row["comfy_workflow_name"],
61
- "comfyWorkflowReady": row["comfy_workflow_ready"] == 1,
62
60
  "createdAt": row["created_at"],
63
61
  "updatedAt": row["updated_at"],
64
62
  }
@@ -231,23 +229,12 @@ def set_provider(
231
229
  )
232
230
  else:
233
231
  conn.execute(
234
- "UPDATE frames SET provider = 'comfy', model_id = NULL, updated_at = ? WHERE id = ?",
232
+ "UPDATE frames SET provider = 'unset', model_id = NULL, updated_at = ? WHERE id = ?",
235
233
  (now, frame_id),
236
234
  )
237
235
  return get_frame(conn, frame_id)
238
236
 
239
237
 
240
- def unlink_workflow(conn: sqlite3.Connection, frame_id: str) -> dict[str, Any]:
241
- """Detach a frame's ComfyUI workflow link (desktop legacy; a reset on the web path)."""
242
- get_frame(conn, frame_id)
243
- conn.execute(
244
- "UPDATE frames SET comfy_workflow_name = NULL, comfy_workflow_ready = 0, "
245
- "updated_at = ? WHERE id = ?",
246
- (_now(), frame_id),
247
- )
248
- return get_frame(conn, frame_id)
249
-
250
-
251
238
  def set_fal_params(
252
239
  conn: sqlite3.Connection, frame_id: str, params: dict[str, Any]
253
240
  ) -> dict[str, Any]:
@@ -26,8 +26,8 @@ from . import moodboard as mb
26
26
  def _source_output_port(source: dict[str, Any] | None, source_handle: str | None) -> str:
27
27
  if source and source["type"] == "prompt":
28
28
  return "text"
29
- # An asset or a rendered frame both become an ``input/image`` source node (output port "image").
30
- if source and source["type"] in ("asset", "frame"):
29
+ # An asset, a rendered frame, or a Load Assets loader all become an ``input/image`` source node.
30
+ if source and source["type"] in ("asset", "frame", "loader"):
31
31
  return "image"
32
32
  return source_handle or "out" # a 'core' item's handles already are Core port ids
33
33
 
@@ -75,6 +75,17 @@ def _item_to_node(
75
75
  "type": "input/image",
76
76
  "params": {"asset": {"ref": "path", "path": path}},
77
77
  }
78
+ # A Load Assets loader feeds its hero (first) asset as a frozen image source.
79
+ if item["type"] == "loader":
80
+ asset_ids = data.get("assetIds") or []
81
+ path = resolve_asset_path(asset_ids[0]) if asset_ids else None
82
+ if not path:
83
+ return None
84
+ return {
85
+ "id": item["id"],
86
+ "type": "input/image",
87
+ "params": {"asset": {"ref": "path", "path": path}},
88
+ }
78
89
  # A rendered frame wired into a Core node feeds its output image as a frozen source (its hero
79
90
  # take), so nothing upstream of the frame is recomputed.
80
91
  if item["type"] == "frame" and item.get("frameId"):
@@ -4,8 +4,7 @@ backend is Core (Python), not the legacy Node server.
4
4
 
5
5
  Channel args arrive as a positional list (the ``{channel, args}`` wire shape). Each handler unpacks
6
6
  them and returns the value to wrap in Ok. Not-yet-ported surfaces (generation, timeline,
7
- export, embedded ComfyUI) register clear stubs so the UI degrades gracefully instead of a
8
- "no handler".
7
+ export) register clear stubs so the UI degrades gracefully instead of a "no handler".
9
8
  """
10
9
 
11
10
  from __future__ import annotations
@@ -73,7 +72,6 @@ def register_studio_handlers(
73
72
  reg("dialog:pickDirectory", lambda *_: str(cfg.workspace_dir()))
74
73
  reg("app:version", lambda: app_version)
75
74
  reg("settings:get", store.get_settings)
76
- reg("settings:setComfyUrl", store.set_comfy_url)
77
75
  reg("settings:setCoreUrl", store.set_core_url)
78
76
  reg("core:status", core_status)
79
77
  reg("core:models", core_models)
@@ -117,7 +115,6 @@ def register_studio_handlers(
117
115
  reg("frames:rename", lambda fid, name: fr.rename_frame(conn(), fid, name))
118
116
  reg("frames:reorder", lambda ids: fr.reorder_frames(conn(), ids))
119
117
  reg("frames:clone", lambda fid: fr.clone_frame(conn(), fid))
120
- reg("frames:unlink", lambda fid: fr.unlink_workflow(conn(), fid))
121
118
  reg("frames:setHero", lambda fid, take_id: fr.set_hero(conn(), fid, take_id))
122
119
  reg("frames:listTakes", lambda fid: fr.list_takes(conn(), fid))
123
120
  reg("frames:heroTakes", lambda: fr.hero_takes(conn()))
@@ -168,6 +165,7 @@ def register_studio_handlers(
168
165
  reg("moodboard:addLayer", lambda x, y: mb.add_layer(conn(), x, y))
169
166
  reg("moodboard:addDirector", lambda x, y: mb.add_director(conn(), x, y))
170
167
  reg("moodboard:addTrim", lambda x, y: mb.add_trim(conn(), x, y))
168
+ reg("moodboard:addLoader", lambda x, y: mb.add_loader(conn(), x, y))
171
169
  reg("moodboard:addPrompt", lambda x, y: mb.add_prompt(conn(), x, y))
172
170
  reg("moodboard:addCoreNode", lambda t, x, y: mb.add_core_node(conn(), t, x, y))
173
171
  reg(
@@ -213,10 +211,6 @@ def register_studio_handlers(
213
211
  reg("falSettings:status", store.fal_status)
214
212
  reg("falSettings:setApiKey", store.set_fal_key)
215
213
  reg("falSettings:clearApiKey", store.clear_fal_key)
216
- reg("comfy:status", lambda: {"reachable": False, "url": ""})
217
- for ch in ("linkFrame", "uploadInputs", "pullWorkflow", "saveLiveWorkflow", "pushWorkflow",
218
- "pullLatest", "latestRun", "captureOutput"):
219
- reg(f"comfy:{ch}", not_wired("Embedded ComfyUI"))
220
214
  # --- timeline (director/trim/export via ffmpeg) + folder export -----------------------------
221
215
  if timeline is not None:
222
216
  from .timeline.render import export_frames
@@ -16,7 +16,6 @@ from __future__ import annotations
16
16
 
17
17
  import asyncio
18
18
  import shutil
19
- import threading
20
19
  from collections.abc import Callable
21
20
  from pathlib import Path
22
21
  from typing import Any
@@ -138,45 +137,40 @@ class ModelDownloads:
138
137
  ) -> None:
139
138
  """Fetch a component's single file from its repo into ``models/<category>/`` (flat, so the
140
139
  node's dropdown lists it). Downloads into a ``.part`` staging dir first, then moves the file
141
- into place under its basename, so a half-finished download never looks installed."""
142
- from huggingface_hub import HfApi, hf_hub_download
140
+ into place under its basename, so a half-finished download never looks installed.
141
+
142
+ Progress comes from huggingface_hub's own download counter via ``tqdm_class`` - real
143
+ per-chunk motion, and it still resumes a partial file. (XET is disabled at process start so
144
+ the plain HTTP path is used; XET reports nothing to tqdm.)"""
145
+ from huggingface_hub import hf_hub_download
146
+ from huggingface_hub.utils import HfHubHTTPError
143
147
 
144
148
  category_dir: Path = provider.download_target(comp)
145
149
  staging = category_dir / (comp.filename + ".part")
146
- shutil.rmtree(staging, ignore_errors=True)
147
-
148
- files = _wanted_files(HfApi(), comp)
149
- total = sum(size for _, size in files)
150
150
  on_progress(0.0, f"Downloading {comp.label}…")
151
+ tqdm_class = _progress_tqdm(on_progress, comp.label)
152
+
153
+ def _fetch(token: bool | None) -> str:
154
+ return hf_hub_download(
155
+ comp.repo,
156
+ comp.repo_file,
157
+ local_dir=str(staging),
158
+ token=token,
159
+ tqdm_class=tqdm_class,
160
+ )
151
161
 
152
- # ``hf_hub_download`` blocks for the whole (multi-GB) file with no per-byte callback, so a
153
- # naive "emit after each file" jumps 0 → 99%. Instead, a watcher thread polls the staging
154
- # tree's byte size while the download runs and streams a real fraction. It only reports a
155
- # fraction when the repo metadata gave us a real total; otherwise size is unknown and we
156
- # leave the 0 → 1 endpoints (the status text still shows activity).
157
- stop = threading.Event()
158
-
159
- def _watch() -> None:
160
- while not stop.wait(0.5):
161
- if total <= 0:
162
- continue
163
- got = _dir_size(staging)
164
- if got > 0:
165
- on_progress(min(0.98, got / total), f"Downloading {comp.label}…")
166
-
167
- watcher = threading.Thread(target=_watch, name="model-download-progress", daemon=True)
168
- watcher.start()
169
162
  try:
170
- for rfilename, _size in files:
171
- hf_hub_download(comp.repo, rfilename, local_dir=str(staging))
172
- finally:
173
- stop.set()
174
- watcher.join(timeout=1.0)
163
+ path = _fetch(None) # ambient token, e.g. for a gated repo the user has access to
164
+ except HfHubHTTPError as error:
165
+ # A stale/invalid cached HF token 401s even on a public repo (HF masks it as "not
166
+ # found"). Retry anonymously so a bad token never blocks a public model download.
167
+ if getattr(getattr(error, "response", None), "status_code", None) not in (401, 403):
168
+ raise
169
+ shutil.rmtree(staging, ignore_errors=True)
170
+ path = _fetch(False)
175
171
 
176
172
  category_dir.mkdir(parents=True, exist_ok=True)
177
- for rfilename, _ in files:
178
- dest = category_dir / Path(rfilename).name
179
- shutil.move(str(staging / rfilename), str(dest))
173
+ shutil.move(path, str(category_dir / comp.filename))
180
174
  shutil.rmtree(staging, ignore_errors=True)
181
175
  on_progress(1.0, f"{comp.label} ready")
182
176
 
@@ -210,30 +204,19 @@ def _source_label(component: Any) -> str:
210
204
  return component.repo
211
205
 
212
206
 
213
- def _dir_size(path: Path) -> int:
214
- """Total bytes under ``path`` (recursively), for live download progress. Counts Hugging Face's
215
- ``.incomplete`` temp file as it grows, so the fraction advances during the blocking fetch.
216
- Best-effort: a file vanishing mid-walk (the final rename) is ignored."""
217
- total = 0
218
- try:
219
- for child in path.rglob("*"):
220
- try:
221
- if child.is_file():
222
- total += child.stat().st_size
223
- except OSError:
224
- continue
225
- except OSError:
226
- return total
227
- return total
228
-
229
-
230
- def _wanted_files(api: Any, comp: Any) -> list[tuple[str, int]]:
231
- """(rfilename, size) for the single file this component pulls, sized from the repo metadata.
232
-
233
- Falls back to size 0 (progress by count) if the file isn't in the listing - the download still
234
- proceeds; ``hf_hub_download`` is the source of truth for whether the path exists."""
235
- info = api.model_info(comp.repo, files_metadata=True)
236
- for sibling in info.siblings:
237
- if sibling.rfilename == comp.repo_file:
238
- return [(comp.repo_file, int(sibling.size or 0))]
239
- return [(comp.repo_file, 0)]
207
+ def _progress_tqdm(on_progress: Callable[[float, str], None], label: str) -> Any:
208
+ """A tqdm subclass handed to ``hf_hub_download`` as ``tqdm_class``; it forwards the download
209
+ counter to ``on_progress``. It sums ``n`` itself because ``tqdm.update`` short-circuits (never
210
+ touching ``self.n``) when its bar is disabled - which it is on a headless server. The 0.99 cap
211
+ leaves the final 1.0 for the post-move ``ready``."""
212
+ from tqdm.auto import tqdm
213
+
214
+ class _Tqdm(tqdm):
215
+ def update(self, n: float | None = 1) -> Any:
216
+ done = super().update(n)
217
+ self._forwarded = getattr(self, "_forwarded", 0.0) + (n or 0.0)
218
+ if self.total:
219
+ on_progress(min(0.99, self._forwarded / self.total), f"Downloading {label}…")
220
+ return done
221
+
222
+ return _Tqdm
@@ -233,6 +233,14 @@ def add_trim(conn: sqlite3.Connection, x: float, y: float) -> dict[str, Any]:
233
233
  )
234
234
 
235
235
 
236
+ def add_loader(conn: sqlite3.Connection, x: float, y: float) -> dict[str, Any]:
237
+ # A "Load Assets" node holds library asset refs in its data (no frame, no frame_inputs) and
238
+ # feeds its hero (first) asset downstream via graph_build.
239
+ return _insert_item(
240
+ conn, item_type="loader", x=x, y=y, width=220, height=200, data={"assetIds": []}
241
+ )
242
+
243
+
236
244
  def add_prompt(conn: sqlite3.Connection, x: float, y: float) -> dict[str, Any]:
237
245
  return _insert_item(
238
246
  conn, item_type="prompt", x=x, y=y, width=240, height=120, data={"promptText": ""}
@@ -53,14 +53,12 @@ class StudioStore:
53
53
  app_data_dir: str | Path,
54
54
  workspace_dir: str | Path,
55
55
  *,
56
- default_comfy_url: str = "http://127.0.0.1:8188",
57
56
  default_core_url: str = "http://127.0.0.1:8848",
58
57
  ) -> None:
59
58
  self._app_data = Path(app_data_dir)
60
59
  self._workspace = Path(workspace_dir)
61
60
  self._app_data.mkdir(parents=True, exist_ok=True)
62
61
  self._workspace.mkdir(parents=True, exist_ok=True)
63
- self._default_comfy_url = default_comfy_url
64
62
  self._default_core_url = default_core_url
65
63
  self._conn: sqlite3.Connection | None = None
66
64
  self._folder: Path | None = None
@@ -237,7 +235,6 @@ class StudioStore:
237
235
  def get_settings(self) -> dict[str, str]:
238
236
  saved = self._read_settings()
239
237
  return {
240
- "comfyUrl": self._non_empty(saved.get("comfyUrl")) or self._default_comfy_url,
241
238
  "coreUrl": self._non_empty(saved.get("coreUrl")) or self._default_core_url,
242
239
  }
243
240
 
@@ -245,11 +242,6 @@ class StudioStore:
245
242
  self._settings_file().write_text(json.dumps(settings, indent=2), encoding="utf-8")
246
243
  return settings
247
244
 
248
- def set_comfy_url(self, url: str) -> dict[str, str]:
249
- settings = self.get_settings()
250
- settings["comfyUrl"] = url.strip() or self._default_comfy_url
251
- return self._save_settings(settings)
252
-
253
245
  def set_core_url(self, url: str) -> dict[str, str]:
254
246
  settings = self.get_settings()
255
247
  settings["coreUrl"] = url.strip() or self._default_core_url
@@ -388,6 +388,33 @@ def test_reinstall_recovers_from_a_corrupt_cached_mirror(
388
388
  assert (mirror / "HEAD").is_file()
389
389
 
390
390
 
391
+ def test_remote_sha_peels_an_annotated_tag_to_its_commit(tmp_path: Path) -> None:
392
+ """An annotated tag's ls-remote lists the tag object first and the commit on a `^{}` line. The
393
+ update check compares against the installed *commit*, so remote_sha must peel - otherwise every
394
+ annotated-tag release reads as perpetually 'update available'."""
395
+ from inline_core.extensions.fetch import remote_sha
396
+
397
+ repo = tmp_path / "annotated-repo"
398
+ repo.mkdir()
399
+ _git("init", "-q", "-b", "main", cwd=repo)
400
+ _git("config", "user.email", "test@example.com", cwd=repo)
401
+ _git("config", "user.name", "Test", cwd=repo)
402
+ (repo / "file.txt").write_text("hi", encoding="utf-8")
403
+ _git("add", "-A", cwd=repo)
404
+ _git("commit", "-q", "-m", "initial", cwd=repo)
405
+ _git("tag", "-a", "v1.0.0", "-m", "release", cwd=repo) # annotated, not lightweight
406
+
407
+ commit = subprocess.run(
408
+ ["git", "rev-parse", "v1.0.0^{commit}"],
409
+ cwd=repo,
410
+ check=True,
411
+ capture_output=True,
412
+ text=True,
413
+ ).stdout.strip()
414
+
415
+ assert remote_sha(f"file://{repo}", "v1.0.0") == commit
416
+
417
+
391
418
  def test_switching_to_an_uninstalled_version_is_refused(
392
419
  installer: Installer, tmp_path: Path
393
420
  ) -> None:
@@ -62,8 +62,9 @@ def test_set_model_and_provider(conn) -> None:
62
62
  swapped = fr.set_model(conn, frame["id"], "m/2", "video", {"b": 2})
63
63
  assert swapped["modelId"] == "m/2" and swapped["kind"] == "video"
64
64
  assert swapped["params"] == {"b": 2}
65
- to_comfy = fr.set_provider(conn, frame["id"], "comfy")
66
- assert to_comfy["provider"] == "comfy" and to_comfy["modelId"] is None
65
+ # A non-fal set_provider resets the frame to 'unset' (the neutral, engine-less state).
66
+ reset = fr.set_provider(conn, frame["id"], "unset")
67
+ assert reset["provider"] == "unset" and reset["modelId"] is None
67
68
 
68
69
 
69
70
  def test_rename_reorder_delete(conn) -> None:
@@ -35,7 +35,7 @@ def test_build_workflow_graph_prompt_into_zimage(tmp_path) -> None:
35
35
 
36
36
  def test_build_workflow_graph_frame_output_into_zimage_image(tmp_path) -> None:
37
37
  """A rendered frame wired into Z-Image's image port becomes an input/image source node pointing
38
- at the frame's hero take - no dangling edge to a non-emitted node (the old "No node with id")."""
38
+ at the frame's hero take - no dangling edge to a non-emitted node."""
39
39
  store = _store(tmp_path)
40
40
  conn = store.conn()
41
41
  z = mb.add_core_node(conn, "alibaba/z-image-turbo", 400, 200)
@@ -55,6 +55,30 @@ def test_build_workflow_graph_frame_output_into_zimage_image(tmp_path) -> None:
55
55
  assert take["id"] # hero take exists
56
56
 
57
57
 
58
+ def test_build_workflow_graph_loader_into_zimage_image(tmp_path) -> None:
59
+ """A standalone Load Assets loader (its hero asset) wired into Z-Image becomes an input/image
60
+ source node - no frame involved."""
61
+ store = _store(tmp_path)
62
+ conn = store.conn()
63
+ conn.execute(
64
+ "INSERT INTO assets (id, project_id, name, file_path, kind, created_at) "
65
+ "VALUES ('a1', ?, 'a', 'assets/a.png', 'image', 0)",
66
+ (mb._project_id(conn),),
67
+ )
68
+ z = mb.add_core_node(conn, "alibaba/z-image-turbo", 400, 200)
69
+ loader = mb.add_loader(conn, 80, 200)
70
+ assert loader["type"] == "loader" and loader["data"]["assetIds"] == []
71
+ mb.update_item(conn, loader["id"], {"data": {"assetIds": ["a1"]}})
72
+ mb.create_connector(conn, loader["id"], z["id"], "out", "image")
73
+
74
+ graph, _ = build_workflow_graph(conn, store.folder(), z["id"])
75
+ by_id = {n["id"]: n for n in graph["nodes"]}
76
+ loader_node = by_id[loader["id"]]
77
+ assert loader_node["type"] == "input/image"
78
+ assert loader_node["params"]["asset"]["path"] == str(store.folder() / "assets/a.png")
79
+ assert by_id[z["id"]]["inputs"]["image"] == {"from": loader["id"], "output": "image"}
80
+
81
+
58
82
  class _Events:
59
83
  def __init__(self) -> None:
60
84
  self.sent: list[tuple[str, dict]] = []
@@ -0,0 +1,90 @@
1
+ """The explicit model-download backend (studio/models.py) - the torch-free bits: which repo file a
2
+ component pulls and the requirements view for unknown node types. The Hugging Face download itself
3
+ is not exercised here (no network); the runner/requirements tests cover presence logic."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import types
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from inline_core.studio.models import (
13
+ ModelDownloads,
14
+ _component_json,
15
+ _progress_tqdm,
16
+ )
17
+
18
+
19
+ def test_progress_tqdm_forwards_the_download_fraction():
20
+ """The tqdm subclass handed to hf_hub_download must turn its byte counter into a 0..0.99
21
+ fraction on our callback."""
22
+ pytest.importorskip("tqdm")
23
+ seen: list[float] = []
24
+ cls = _progress_tqdm(lambda frac, status: seen.append(frac), "VAE")
25
+ bar = cls(total=100, disable=True)
26
+ bar.update(50)
27
+ bar.update(50) # reaches total, but the cap keeps 1.0 for the post-move "ready"
28
+ assert seen == [0.5, 0.99]
29
+
30
+
31
+ def test_requirements_empty_for_unknown_node_type():
32
+ downloads = ModelDownloads(events=None)
33
+ assert downloads.requirements("no/such-node") == {
34
+ "components": [],
35
+ "allPresent": True,
36
+ "estimate": None, # no requirements + no policy -> no fit estimate
37
+ }
38
+
39
+
40
+ def test_download_retries_anonymously_when_a_cached_token_is_invalid(tmp_path, monkeypatch):
41
+ """A stale/invalid cached HF token 401s even on a public repo (masked as "not found"). The
42
+ download must drop the token and retry anonymously, not fail."""
43
+ hub = pytest.importorskip("huggingface_hub")
44
+ from huggingface_hub.utils import HfHubHTTPError
45
+
46
+ tokens_seen: list[object] = []
47
+
48
+ def fake_download(repo, rfilename, local_dir, token=None, tqdm_class=None):
49
+ tokens_seen.append(token)
50
+ if token is not False: # ambient (stale) token -> 401 the way HF does
51
+ response = types.SimpleNamespace(status_code=401, headers={}, request=None)
52
+ raise HfHubHTTPError("401 Client Error. Repository Not Found", response=response)
53
+ dest = Path(local_dir) / rfilename
54
+ dest.parent.mkdir(parents=True, exist_ok=True)
55
+ dest.write_bytes(b"data")
56
+ return str(dest)
57
+
58
+ monkeypatch.setattr(hub, "hf_hub_download", fake_download)
59
+
60
+ downloads = ModelDownloads(events=None)
61
+ comp = types.SimpleNamespace(repo="lokCX/4x-Ultrasharp", repo_file="w.pth", filename="w.pth",
62
+ label="4x UltraSharp")
63
+ provider = types.SimpleNamespace(download_target=lambda c: tmp_path)
64
+ downloads._download_component(provider, comp, lambda frac, status: None)
65
+
66
+ assert (tmp_path / "w.pth").read_bytes() == b"data"
67
+ assert tokens_seen == [None, False] # tried the ambient token, then fell back to anonymous
68
+
69
+
70
+ def test_component_json_shape():
71
+ comp = types.SimpleNamespace(
72
+ id="vae",
73
+ label="VAE",
74
+ category="vae",
75
+ present=False,
76
+ local_path="vae/ae.safetensors",
77
+ repo="Comfy-Org/z_image",
78
+ filename="ae.safetensors",
79
+ repo_file="split_files/vae/ae.safetensors",
80
+ )
81
+ assert _component_json(comp) == {
82
+ "id": "vae",
83
+ "label": "VAE",
84
+ "category": "vae",
85
+ "present": False,
86
+ "localPath": "vae/ae.safetensors",
87
+ "repo": "Comfy-Org/z_image",
88
+ # "which model" is the repo + the exact file this component pulls.
89
+ "source": "Comfy-Org/z_image/ae.safetensors",
90
+ }
@@ -97,9 +97,6 @@ def test_asset_upload_and_media_serving(client) -> None:
97
97
 
98
98
  def test_unported_channels_degrade_gracefully(client) -> None:
99
99
  rpc(client, "project:create", {"name": "P", "parentDir": None})
100
- # Embedded ComfyUI is desktop-only - a clear error, not a crash.
101
- cf = rpc(client, "comfy:linkFrame", "frame-x")
102
- assert cf["ok"] is False and "comfyui" in cf["error"].lower()
103
100
  # Cancel is a safe no-op.
104
101
  assert rpc(client, "generation:cancel")["ok"] is True
105
102