inline-core 1.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. inline_core/__init__.py +7 -0
  2. inline_core/components/__init__.py +1 -0
  3. inline_core/components/conditioning.py +20 -0
  4. inline_core/components/interfaces.py +91 -0
  5. inline_core/config.py +33 -0
  6. inline_core/device/__init__.py +1 -0
  7. inline_core/device/auto.py +35 -0
  8. inline_core/device/detect.py +41 -0
  9. inline_core/device/memory.py +168 -0
  10. inline_core/device/policy.py +82 -0
  11. inline_core/device/types.py +31 -0
  12. inline_core/errors.py +42 -0
  13. inline_core/graph/__init__.py +1 -0
  14. inline_core/graph/cache.py +83 -0
  15. inline_core/graph/descriptor.py +81 -0
  16. inline_core/graph/executor.py +102 -0
  17. inline_core/graph/primitives.py +137 -0
  18. inline_core/graph/registry.py +61 -0
  19. inline_core/graph/runners.py +72 -0
  20. inline_core/graph/schema.py +136 -0
  21. inline_core/graph/topo.py +49 -0
  22. inline_core/graph/validate.py +56 -0
  23. inline_core/importer/__init__.py +1 -0
  24. inline_core/importer/comfy.py +162 -0
  25. inline_core/media.py +11 -0
  26. inline_core/models/__init__.py +8 -0
  27. inline_core/models/catalog.py +98 -0
  28. inline_core/models/zimage/__init__.py +11 -0
  29. inline_core/models/zimage/requirements.py +180 -0
  30. inline_core/models/zimage/runner.py +386 -0
  31. inline_core/parallel/__init__.py +13 -0
  32. inline_core/parallel/config.py +50 -0
  33. inline_core/parallel/group.py +136 -0
  34. inline_core/parallel/launch.py +44 -0
  35. inline_core/parallel/protocol.py +53 -0
  36. inline_core/parallel/registry.py +31 -0
  37. inline_core/parallel/worker.py +75 -0
  38. inline_core/runtime/__init__.py +1 -0
  39. inline_core/runtime/context.py +31 -0
  40. inline_core/runtime/file_store.py +51 -0
  41. inline_core/runtime/progress.py +83 -0
  42. inline_core/runtime/run.py +113 -0
  43. inline_core/runtime/store.py +18 -0
  44. inline_core/sampling/__init__.py +1 -0
  45. inline_core/sampling/batch.py +116 -0
  46. inline_core/server/__init__.py +1 -0
  47. inline_core/server/__main__.py +61 -0
  48. inline_core/server/app.py +327 -0
  49. inline_core/server/assets.py +47 -0
  50. inline_core/server/bootstrap.py +21 -0
  51. inline_core/server/frontend.py +37 -0
  52. inline_core/server/manager.py +195 -0
  53. inline_core/server/rpc.py +60 -0
  54. inline_core/server/run_store.py +155 -0
  55. inline_core/server/serialize.py +144 -0
  56. inline_core/studio/__init__.py +7 -0
  57. inline_core/studio/assets.py +194 -0
  58. inline_core/studio/config.py +29 -0
  59. inline_core/studio/fal.py +237 -0
  60. inline_core/studio/frames.py +552 -0
  61. inline_core/studio/generation.py +136 -0
  62. inline_core/studio/graph_build.py +109 -0
  63. inline_core/studio/handlers.py +236 -0
  64. inline_core/studio/models.py +173 -0
  65. inline_core/studio/moodboard.py +400 -0
  66. inline_core/studio/schema.py +278 -0
  67. inline_core/studio/store.py +291 -0
  68. inline_core/studio/timeline/__init__.py +6 -0
  69. inline_core/studio/timeline/compose.py +130 -0
  70. inline_core/studio/timeline/ffmpeg.py +76 -0
  71. inline_core/studio/timeline/render.py +120 -0
  72. inline_core/studio/timeline/resolve.py +189 -0
  73. inline_core/takes.py +31 -0
  74. inline_core-1.1.1.dist-info/METADATA +35 -0
  75. inline_core-1.1.1.dist-info/RECORD +77 -0
  76. inline_core-1.1.1.dist-info/WHEEL +4 -0
  77. inline_core-1.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,109 @@
1
+ """Serialize a canvas subgraph into an Inline Core graph (schemaVersion 1) — ported from the Studio
2
+ ``electron/main/core/workflow.ts``. Walks the connector graph upstream from a target node's closure:
3
+
4
+ - a ``core`` item -> its Core node type + params (handles are already Core port ids)
5
+ - a ``prompt`` item -> an ``input/text`` source node
6
+ - an ``asset`` item -> an ``input/image`` source node (local path ref)
7
+
8
+ Connectors become typed edges (source output port -> target input port). Node ids are the canvas
9
+ item ids, so a produced take's ``node_id`` maps straight back to the item that made it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sqlite3
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from . import moodboard as mb
20
+
21
+
22
+ def _source_output_port(source: dict[str, Any] | None, source_handle: str | None) -> str:
23
+ if source and source["type"] == "prompt":
24
+ return "text"
25
+ if source and source["type"] == "asset":
26
+ return "image"
27
+ return source_handle or "out" # a 'core' item's handles already are Core port ids
28
+
29
+
30
+ def _edges_for(
31
+ item_id: str, connectors: list[dict[str, Any]], by_id: dict[str, dict[str, Any]]
32
+ ) -> dict[str, dict[str, str]]:
33
+ inputs: dict[str, dict[str, str]] = {}
34
+ for c in connectors:
35
+ if c["toItemId"] != item_id:
36
+ continue
37
+ data = c.get("data") or {}
38
+ target_handle = data.get("targetHandle") or "in"
39
+ inputs[target_handle] = {
40
+ "from": c["fromItemId"],
41
+ "output": _source_output_port(by_id.get(c["fromItemId"]), data.get("sourceHandle")),
42
+ }
43
+ return inputs
44
+
45
+
46
+ def _item_to_node(
47
+ item: dict[str, Any],
48
+ connectors: list[dict[str, Any]],
49
+ by_id: dict[str, dict[str, Any]],
50
+ resolve_asset_path: Callable[[str], str | None],
51
+ ) -> dict[str, Any] | None:
52
+ data = item.get("data") or {}
53
+ if item["type"] == "core" and data.get("core"):
54
+ return {
55
+ "id": item["id"],
56
+ "type": data["core"]["type"],
57
+ "params": data["core"].get("params") or {},
58
+ "inputs": _edges_for(item["id"], connectors, by_id),
59
+ }
60
+ if item["type"] == "prompt":
61
+ text = data.get("promptText") or ""
62
+ return {"id": item["id"], "type": "input/text", "params": {"text": text}}
63
+ if item["type"] == "asset" and item.get("assetId"):
64
+ path = resolve_asset_path(item["assetId"])
65
+ if not path:
66
+ return None
67
+ return {
68
+ "id": item["id"],
69
+ "type": "input/image",
70
+ "params": {"asset": {"ref": "path", "path": path}},
71
+ }
72
+ return None
73
+
74
+
75
+ def _upstream_closure(target: str, connectors: list[dict[str, Any]]) -> set[str]:
76
+ seen: set[str] = set()
77
+ stack = [target]
78
+ while stack:
79
+ node_id = stack.pop()
80
+ if node_id in seen:
81
+ continue
82
+ seen.add(node_id)
83
+ for c in connectors:
84
+ if c["toItemId"] == node_id and c["fromItemId"] not in seen:
85
+ stack.append(c["fromItemId"])
86
+ return seen
87
+
88
+
89
+ def build_workflow_graph(
90
+ conn: sqlite3.Connection, folder: Path, target_item_id: str
91
+ ) -> tuple[dict[str, Any], str]:
92
+ """Build the Core graph for a canvas node from the open project's board."""
93
+ board = mb.list_board(conn)
94
+ items, connectors = board["items"], board["connectors"]
95
+ by_id = {i["id"]: i for i in items}
96
+
97
+ def resolve_asset_path(asset_id: str) -> str | None:
98
+ row = conn.execute("SELECT file_path FROM assets WHERE id = ?", (asset_id,)).fetchone()
99
+ return str(folder / row["file_path"]) if row else None
100
+
101
+ nodes: list[dict[str, Any]] = []
102
+ for node_id in _upstream_closure(target_item_id, connectors):
103
+ item = by_id.get(node_id)
104
+ if item is None:
105
+ continue
106
+ node = _item_to_node(item, connectors, by_id, resolve_asset_path)
107
+ if node is not None:
108
+ nodes.append(node)
109
+ return {"schemaVersion": 1, "nodes": nodes}, target_item_id
@@ -0,0 +1,236 @@
1
+ """Register the Studio InlineStudioApi channels natively on the RpcRouter, backed by ``StudioStore``
2
+ and the domain modules. This is the strangler-fig flip: once these are registered, the SPA's app
3
+ backend is Core (Python), not the legacy Node server.
4
+
5
+ Channel args arrive as a positional list (the ``{channel, args}`` wire shape). Each handler unpacks
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".
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import inspect
14
+ from collections.abc import Callable
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from . import assets as ax
19
+ from . import config as cfg
20
+ from . import fal as _fal
21
+ from . import frames as fr
22
+ from . import moodboard as mb
23
+ from .store import StudioStore
24
+
25
+
26
+ def _unlink(folder: Path, relatives: list[str]) -> None:
27
+ for rel in relatives:
28
+ try:
29
+ (folder / rel).unlink(missing_ok=True)
30
+ except OSError:
31
+ pass
32
+
33
+
34
+ def register_studio_handlers(
35
+ rpc: Any,
36
+ store: StudioStore,
37
+ *,
38
+ core_models: Callable[[], dict[str, Any]],
39
+ core_status: Callable[[], dict[str, Any]],
40
+ generation: Any = None,
41
+ fal_generation: Any = None,
42
+ timeline: Any = None,
43
+ model_downloads: Any = None,
44
+ app_version: str = "1.0.0",
45
+ ) -> None:
46
+ def reg(channel: str, fn: Callable[..., Any]) -> None:
47
+ async def handler(args: list[Any]) -> Any:
48
+ result = fn(*args)
49
+ if inspect.isawaitable(result):
50
+ result = await result # async handlers (e.g. the ffmpeg timeline render)
51
+ return result
52
+
53
+ rpc.register(channel, handler)
54
+
55
+ def conn() -> Any:
56
+ return store.conn()
57
+
58
+ def not_wired(feature: str) -> Callable[..., Any]:
59
+ def fn(*_args: Any) -> Any:
60
+ raise RuntimeError(f"{feature} isn't available on the single-process path yet.")
61
+
62
+ return fn
63
+
64
+ # --- project + app-global -------------------------------------------------------------------
65
+ reg("project:create", lambda inp: store.create_project(inp["name"], inp.get("parentDir")))
66
+ reg("project:open", lambda path: store.open_project(path))
67
+ reg("project:openDialog", lambda: None) # no native folder picker in a browser
68
+ reg("project:openZip", lambda: None)
69
+ reg("project:listRecent", store.list_recent)
70
+ reg("project:current", store.current_project)
71
+ reg("project:mediaDirs", store.media_dirs)
72
+ reg("project:export", lambda _path: None) # zip export: pending (see plan)
73
+ reg("dialog:pickDirectory", lambda *_: str(cfg.workspace_dir()))
74
+ reg("app:version", lambda: app_version)
75
+ reg("settings:get", store.get_settings)
76
+ reg("settings:setComfyUrl", store.set_comfy_url)
77
+ reg("settings:setCoreUrl", store.set_core_url)
78
+ reg("core:status", core_status)
79
+ reg("core:models", core_models)
80
+
81
+ # --- model requirements + explicit downloads (the node's "missing models" popup) ------------
82
+ if model_downloads is not None:
83
+ reg("models:requirements", lambda node_type: model_downloads.requirements(node_type))
84
+ reg("models:download", lambda node_type, cid: model_downloads.download(node_type, cid))
85
+ else:
86
+ reg("models:requirements", lambda _node_type: {"components": [], "allPresent": True})
87
+ reg("models:download", not_wired("Model downloads"))
88
+
89
+ # --- folders --------------------------------------------------------------------------------
90
+ reg("folders:list", lambda: ax.list_folders(conn()))
91
+ reg("folders:create", lambda inp: ax.create_folder(conn(), inp["name"], inp.get("parentId")))
92
+ reg("folders:rename", lambda fid, name: ax.rename_folder(conn(), fid, name))
93
+ reg("folders:delete", lambda fid: ax.delete_folder(conn(), fid))
94
+
95
+ # --- assets ---------------------------------------------------------------------------------
96
+ reg("assets:list", lambda: ax.list_assets(conn()))
97
+ reg("assets:importDialog", lambda _folder_id=None: []) # browser uses /upload instead
98
+ reg(
99
+ "assets:importPaths",
100
+ lambda paths, folder_id: [
101
+ a
102
+ for a in (ax.import_file(conn(), store.folder(), p, folder_id) for p in paths if p)
103
+ if a
104
+ ],
105
+ )
106
+
107
+ def delete_asset(asset_id: str) -> None:
108
+ paths = ax.delete_asset(conn(), asset_id)
109
+ _unlink(store.folder(), paths)
110
+
111
+ reg("assets:delete", delete_asset)
112
+
113
+ # --- frames + takes + inputs ----------------------------------------------------------------
114
+ reg("frames:list", lambda: fr.list_frames(conn()))
115
+ reg("frames:importAsFrames", lambda: []) # browser has no import dialog
116
+ reg("frames:addFromAsset", lambda asset_id: fr.add_from_asset(conn(), asset_id))
117
+ reg("frames:rename", lambda fid, name: fr.rename_frame(conn(), fid, name))
118
+ reg("frames:reorder", lambda ids: fr.reorder_frames(conn(), ids))
119
+ reg("frames:clone", lambda fid: fr.clone_frame(conn(), fid))
120
+ reg("frames:unlink", lambda fid: fr.unlink_workflow(conn(), fid))
121
+ reg("frames:setHero", lambda fid, take_id: fr.set_hero(conn(), fid, take_id))
122
+ reg("frames:listTakes", lambda fid: fr.list_takes(conn(), fid))
123
+ reg("frames:heroTakes", lambda: fr.hero_takes(conn()))
124
+ reg("frames:listInputs", lambda: fr.list_inputs(conn()))
125
+ reg("frames:addInput", lambda fid, aid: fr.add_input(conn(), fid, aid))
126
+ reg("frames:addInputs", lambda fid, aids: fr.add_inputs(conn(), fid, aids))
127
+ reg("frames:addSourceInput", lambda fid, src: fr.add_source_input(conn(), fid, src))
128
+ reg("frames:removeInput", lambda fid, aid: fr.remove_input(conn(), fid, aid))
129
+ reg("frames:removeInputById", lambda fid, iid: fr.remove_input_by_id(conn(), fid, iid))
130
+ reg("frames:reorderInputs", lambda fid, aids: fr.reorder_inputs(conn(), fid, aids))
131
+ reg("frames:listAllTakes", lambda: fr.list_all_takes(conn()))
132
+ reg("frames:setFalParams", lambda fid, params: fr.set_fal_params(conn(), fid, params))
133
+ # Resolve a fal frame's inputs (media data URIs) + prompt so the browser can build the request.
134
+ reg(
135
+ "frames:resolveFalInputs",
136
+ lambda fid: _fal.resolve_fal_inputs(conn(), store.folder(), fid),
137
+ )
138
+ # Fal model metadata (kind/params/title) lives studio-side; until the frontend sends it,
139
+ # sensibly (kind image, empty params). The GenNode UI still renders params from its own def.
140
+ reg("frames:setModel", lambda fid, mid: fr.set_model(conn(), fid, mid, "image", {}))
141
+ reg(
142
+ "frames:setProvider",
143
+ lambda fid, provider, mid=None: fr.set_provider(
144
+ conn(), fid, provider, mid, "image" if provider == "fal" else None, {}
145
+ ),
146
+ )
147
+
148
+ def delete_frame(frame_id: str) -> None:
149
+ fr.delete_frame(conn(), frame_id)
150
+
151
+ reg("frames:delete", delete_frame)
152
+
153
+ def delete_take(take_id: str) -> None:
154
+ path = fr.delete_take(conn(), take_id)
155
+ if path:
156
+ _unlink(store.folder(), [path])
157
+
158
+ reg("frames:deleteTake", delete_take)
159
+
160
+ # --- moodboard ------------------------------------------------------------------------------
161
+ reg("moodboard:list", lambda: mb.list_board(conn()))
162
+ reg("moodboard:addAsset", lambda aid, x, y: mb.add_asset(conn(), aid, x, y))
163
+ reg("moodboard:addText", lambda x, y: mb.add_text(conn(), x, y))
164
+ reg("moodboard:addFrameFromAsset", lambda aid, x, y: mb.add_frame_from_asset(conn(), aid, x, y))
165
+ reg("moodboard:addEmptyFrame", lambda x, y: mb.add_empty_frame(conn(), x, y))
166
+ reg("moodboard:addFrameItem", lambda fid, x, y: mb.add_frame_item(conn(), fid, x, y))
167
+ reg("moodboard:addPreview", lambda x, y: mb.add_preview(conn(), x, y))
168
+ reg("moodboard:addLayer", lambda x, y: mb.add_layer(conn(), x, y))
169
+ reg("moodboard:addDirector", lambda x, y: mb.add_director(conn(), x, y))
170
+ reg("moodboard:addTrim", lambda x, y: mb.add_trim(conn(), x, y))
171
+ reg("moodboard:addPrompt", lambda x, y: mb.add_prompt(conn(), x, y))
172
+ reg("moodboard:addCoreNode", lambda t, x, y: mb.add_core_node(conn(), t, x, y))
173
+ reg(
174
+ "moodboard:addGenNode",
175
+ lambda mid, x, y: mb.add_gen_node(conn(), mid, x, y, kind="image", params={}, title=mid),
176
+ )
177
+ reg("moodboard:updateItem", lambda iid, patch: mb.update_item(conn(), iid, patch))
178
+ reg("moodboard:deleteItem", lambda iid: mb.delete_item(conn(), iid))
179
+ reg("moodboard:importAndPlace", lambda _x, _y: []) # browser uses /upload
180
+ reg(
181
+ "moodboard:createConnector",
182
+ lambda f, t, sh=None, th=None: mb.create_connector(conn(), f, t, sh, th),
183
+ )
184
+ reg("moodboard:deleteConnector", lambda cid: mb.delete_connector(conn(), cid))
185
+ reg("moodboard:setConnectorVolume", lambda cid, vol: mb.set_connector_volume(conn(), cid, vol))
186
+ reg(
187
+ "moodboard:replaceBoard",
188
+ lambda items, connectors: mb.replace_board(conn(), items, connectors),
189
+ )
190
+
191
+ # --- generation -----------------------------------------------------------------------------
192
+ if generation is not None:
193
+ reg("generation:runWorkflow", lambda item_id: generation.run_workflow(item_id))
194
+ else:
195
+ reg("generation:runWorkflow", not_wired("Core-node generation"))
196
+
197
+ # Fal: the browser passes a prebuilt request {endpoint, body, outputKind}; Core runs it.
198
+ if fal_generation is not None:
199
+ reg("generation:run", lambda frame_id, request: fal_generation.run(frame_id, request))
200
+ else:
201
+ reg("generation:run", not_wired("Fal generation"))
202
+
203
+ def cancel_generation(frame_id: str | None = None) -> None:
204
+ if generation is not None:
205
+ generation.cancel(frame_id)
206
+ if fal_generation is not None:
207
+ fal_generation.cancel(frame_id)
208
+
209
+ reg("generation:cancel", cancel_generation)
210
+ reg("generation:resumePending", lambda: None)
211
+
212
+ # --- fal settings (key stored server-side) --------------------------------------------------
213
+ reg("falSettings:status", store.fal_status)
214
+ reg("falSettings:setApiKey", store.set_fal_key)
215
+ 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
+ # --- timeline (director/trim/export via ffmpeg) + folder export -----------------------------
221
+ if timeline is not None:
222
+ from .timeline.render import export_frames
223
+
224
+ reg("timeline:resolve", lambda oid: timeline.resolve(oid))
225
+ reg("timeline:resolveTrim", lambda iid: timeline.resolve_trim(iid))
226
+ reg("timeline:setVolumes", lambda oid, l1, l2: timeline.set_volumes(oid, l1, l2))
227
+ reg("timeline:buildPreview", lambda oid: timeline.build_preview(oid))
228
+ reg("timeline:export", lambda oid: timeline.export(oid))
229
+ reg("export:exportFrames", lambda: export_frames(conn(), store.folder()))
230
+ else:
231
+ for ch in ("resolve", "resolveTrim", "setVolumes", "buildPreview", "export"):
232
+ reg(f"timeline:{ch}", not_wired("The video timeline"))
233
+ reg("export:exportFrames", lambda: None)
234
+
235
+ reg("updates:check", lambda: None)
236
+ reg("updates:quitAndInstall", lambda: None)
@@ -0,0 +1,173 @@
1
+ """Explicit, visible model downloads — the backend for the node's "missing models" popup.
2
+
3
+ **This is the only place in the engine that fetches a model over the network.** The runner loads
4
+ everything ``local_files_only=True`` (never downloads); here the user explicitly asks for a
5
+ component and we pull it from the reference repo **straight into ``models/<category>/``** (never the
6
+ hidden HF cache), streaming progress over the ``/events`` socket. So a model arrives by exactly two
7
+ paths: the user drops files under ``models/``, or this downloader writes them there.
8
+
9
+ Fire-and-forget, mirroring ``CoreGeneration``: ``requirements`` answers synchronously; ``download``
10
+ schedules a background thread and returns immediately, emitting ``events:modelDownload*`` frames.
11
+ The blocking Hugging Face calls run off the event loop, so progress is marshalled back with
12
+ ``loop.call_soon_threadsafe`` (``EventBroadcaster`` is not thread-safe).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import shutil
19
+ from collections.abc import Callable
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ _ZIMAGE_TYPE = "alibaba/z-image-turbo"
24
+ _SKIP_TOP_LEVEL = {".gitattributes", "readme.md", "license", "license.md", ".gitignore"}
25
+
26
+
27
+ class ModelDownloads:
28
+ """Answers "what's missing" and downloads components into the models dir on request."""
29
+
30
+ def __init__(self, events: Any, on_change: Callable[[], None] | None = None) -> None:
31
+ self._events = events
32
+ self._on_change = on_change # rescan the model catalog after a download lands
33
+
34
+ # --- requirements (the popup's data) --------------------------------------------------------
35
+
36
+ def requirements(self, node_type: str) -> dict[str, Any]:
37
+ """The node's model components with live presence. ``{components: [...], allPresent}``.
38
+
39
+ Returns an empty, all-present view for node types with no requirements (or when the model
40
+ runtime isn't installed — the node then shows its own "unavailable" state instead)."""
41
+ components = self._components(node_type)
42
+ return {
43
+ "components": [_component_json(c) for c in components],
44
+ "allPresent": all(c.present for c in components),
45
+ }
46
+
47
+ # --- download (explicit, user-triggered) ----------------------------------------------------
48
+
49
+ def download(self, node_type: str, component_id: str) -> None:
50
+ """Download one component (by id) or ``"all"`` missing ones, in a background thread."""
51
+ loop = asyncio.get_running_loop()
52
+ asyncio.create_task(asyncio.to_thread(self._run, node_type, component_id, loop))
53
+
54
+ def _run(self, node_type: str, component_id: str, loop: asyncio.AbstractEventLoop) -> None:
55
+ components = self._components(node_type)
56
+ if component_id == "all":
57
+ targets = [c for c in components if not c.present]
58
+ else:
59
+ targets = [c for c in components if c.id == component_id]
60
+ for comp in targets:
61
+ payload_id = comp.id
62
+ try:
63
+ self._download_component(
64
+ comp,
65
+ lambda frac, status, cid=payload_id: self._emit(
66
+ loop,
67
+ "events:modelDownloadProgress",
68
+ {
69
+ "nodeType": node_type,
70
+ "componentId": cid,
71
+ "fraction": frac,
72
+ "status": status,
73
+ },
74
+ ),
75
+ )
76
+ self._rescan()
77
+ self._emit(
78
+ loop,
79
+ "events:modelDownloadDone",
80
+ {"nodeType": node_type, "componentId": payload_id},
81
+ )
82
+ except Exception as error: # noqa: BLE001 — surface as a UI event, never crash the loop
83
+ self._emit(
84
+ loop,
85
+ "events:modelDownloadError",
86
+ {"nodeType": node_type, "componentId": payload_id, "error": str(error)},
87
+ )
88
+
89
+ # --- internals ------------------------------------------------------------------------------
90
+
91
+ def _components(self, node_type: str) -> list[Any]:
92
+ if node_type != _ZIMAGE_TYPE:
93
+ return []
94
+ try:
95
+ from ..models.zimage.requirements import zimage_requirements
96
+ except ImportError:
97
+ return [] # zimage runtime absent — the node shows "unavailable", no requirements
98
+ return zimage_requirements()
99
+
100
+ def _download_component(self, comp: Any, on_progress: Callable[[float, str], None]) -> None:
101
+ """Fetch a component's files from its repo into ``models/<category>/…``, flattening any
102
+ source subfolders. Downloads into a ``.part`` staging dir first, then moves into place, so a
103
+ half-finished download never looks installed."""
104
+ from huggingface_hub import HfApi, hf_hub_download
105
+
106
+ from ..models.zimage.requirements import download_target
107
+
108
+ target: Path = download_target(comp)
109
+ staging = target.parent / (target.name + ".part")
110
+ shutil.rmtree(staging, ignore_errors=True)
111
+
112
+ files = _wanted_files(HfApi(), comp)
113
+ total = sum(size for _, size in files) or 1
114
+ on_progress(0.0, f"Downloading {comp.label}…")
115
+
116
+ downloaded = 0
117
+ for rfilename, size in files:
118
+ hf_hub_download(comp.repo, rfilename, local_dir=str(staging))
119
+ downloaded += size
120
+ on_progress(min(0.99, downloaded / total), f"Downloading {comp.label}…")
121
+
122
+ target.mkdir(parents=True, exist_ok=True)
123
+ for rfilename, _ in files:
124
+ dest = target / _flatten_rel(rfilename, comp.subfolders)
125
+ dest.parent.mkdir(parents=True, exist_ok=True)
126
+ shutil.move(str(staging / rfilename), str(dest))
127
+ shutil.rmtree(staging, ignore_errors=True)
128
+ on_progress(1.0, f"{comp.label} ready")
129
+
130
+ def _rescan(self) -> None:
131
+ """Refresh the catalog so /v1/models options + the registry version see the new files."""
132
+ if self._on_change is not None:
133
+ self._on_change()
134
+
135
+ def _emit(self, loop: asyncio.AbstractEventLoop, channel: str, payload: Any) -> None:
136
+ loop.call_soon_threadsafe(self._events.broadcast, channel, payload)
137
+
138
+
139
+ def _component_json(component: Any) -> dict[str, Any]:
140
+ return {
141
+ "id": component.id,
142
+ "label": component.label,
143
+ "category": component.category,
144
+ "present": component.present,
145
+ "localPath": component.local_path,
146
+ "repo": component.repo,
147
+ }
148
+
149
+
150
+ def _wanted_files(api: Any, comp: Any) -> list[tuple[str, int]]:
151
+ """(rfilename, size) for the repo files this component needs — a subfolder subset, or the whole
152
+ repo (minus boilerplate) when it has no subfolders."""
153
+ info = api.model_info(comp.repo, files_metadata=True)
154
+ out: list[tuple[str, int]] = []
155
+ for sibling in info.siblings:
156
+ name = sibling.rfilename
157
+ if comp.subfolders:
158
+ if not any(name.startswith(sf + "/") for sf in comp.subfolders):
159
+ continue
160
+ elif "/" not in name and name.lower() in _SKIP_TOP_LEVEL:
161
+ continue
162
+ out.append((name, int(sibling.size or 0)))
163
+ return out
164
+
165
+
166
+ def _flatten_rel(rfilename: str, subfolders: tuple[str, ...]) -> str:
167
+ """Where a downloaded file lands under the target: subfolder components are flattened (strip the
168
+ leading ``vae/`` etc.); a whole-repo (pipeline) download keeps its layout."""
169
+ for sf in subfolders:
170
+ prefix = sf + "/"
171
+ if rfilename.startswith(prefix):
172
+ return rfilename[len(prefix) :]
173
+ return rfilename