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.
- inline_core/__init__.py +7 -0
- inline_core/components/__init__.py +1 -0
- inline_core/components/conditioning.py +20 -0
- inline_core/components/interfaces.py +91 -0
- inline_core/config.py +33 -0
- inline_core/device/__init__.py +1 -0
- inline_core/device/auto.py +35 -0
- inline_core/device/detect.py +41 -0
- inline_core/device/memory.py +168 -0
- inline_core/device/policy.py +82 -0
- inline_core/device/types.py +31 -0
- inline_core/errors.py +42 -0
- inline_core/graph/__init__.py +1 -0
- inline_core/graph/cache.py +83 -0
- inline_core/graph/descriptor.py +81 -0
- inline_core/graph/executor.py +102 -0
- inline_core/graph/primitives.py +137 -0
- inline_core/graph/registry.py +61 -0
- inline_core/graph/runners.py +72 -0
- inline_core/graph/schema.py +136 -0
- inline_core/graph/topo.py +49 -0
- inline_core/graph/validate.py +56 -0
- inline_core/importer/__init__.py +1 -0
- inline_core/importer/comfy.py +162 -0
- inline_core/media.py +11 -0
- inline_core/models/__init__.py +8 -0
- inline_core/models/catalog.py +98 -0
- inline_core/models/zimage/__init__.py +11 -0
- inline_core/models/zimage/requirements.py +180 -0
- inline_core/models/zimage/runner.py +386 -0
- inline_core/parallel/__init__.py +13 -0
- inline_core/parallel/config.py +50 -0
- inline_core/parallel/group.py +136 -0
- inline_core/parallel/launch.py +44 -0
- inline_core/parallel/protocol.py +53 -0
- inline_core/parallel/registry.py +31 -0
- inline_core/parallel/worker.py +75 -0
- inline_core/runtime/__init__.py +1 -0
- inline_core/runtime/context.py +31 -0
- inline_core/runtime/file_store.py +51 -0
- inline_core/runtime/progress.py +83 -0
- inline_core/runtime/run.py +113 -0
- inline_core/runtime/store.py +18 -0
- inline_core/sampling/__init__.py +1 -0
- inline_core/sampling/batch.py +116 -0
- inline_core/server/__init__.py +1 -0
- inline_core/server/__main__.py +61 -0
- inline_core/server/app.py +327 -0
- inline_core/server/assets.py +47 -0
- inline_core/server/bootstrap.py +21 -0
- inline_core/server/frontend.py +37 -0
- inline_core/server/manager.py +195 -0
- inline_core/server/rpc.py +60 -0
- inline_core/server/run_store.py +155 -0
- inline_core/server/serialize.py +144 -0
- inline_core/studio/__init__.py +7 -0
- inline_core/studio/assets.py +194 -0
- inline_core/studio/config.py +29 -0
- inline_core/studio/fal.py +237 -0
- inline_core/studio/frames.py +552 -0
- inline_core/studio/generation.py +136 -0
- inline_core/studio/graph_build.py +109 -0
- inline_core/studio/handlers.py +236 -0
- inline_core/studio/models.py +173 -0
- inline_core/studio/moodboard.py +400 -0
- inline_core/studio/schema.py +278 -0
- inline_core/studio/store.py +291 -0
- inline_core/studio/timeline/__init__.py +6 -0
- inline_core/studio/timeline/compose.py +130 -0
- inline_core/studio/timeline/ffmpeg.py +76 -0
- inline_core/studio/timeline/render.py +120 -0
- inline_core/studio/timeline/resolve.py +189 -0
- inline_core/takes.py +31 -0
- inline_core-1.1.1.dist-info/METADATA +35 -0
- inline_core-1.1.1.dist-info/RECORD +77 -0
- inline_core-1.1.1.dist-info/WHEEL +4 -0
- 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
|