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,56 @@
|
|
|
1
|
+
"""Up-front graph validation (contract section 3). Reject before running, never mid-graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..errors import PortTypeError, UnknownNodeType
|
|
6
|
+
from .registry import Registry
|
|
7
|
+
from .schema import Graph, PortKind, port_satisfies
|
|
8
|
+
from .topo import topo_sort, upstream_closure
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate(graph: Graph, target: str, registry: Registry) -> None:
|
|
12
|
+
"""Raise GraphValidationError (with node/port) on the first problem; return None if valid."""
|
|
13
|
+
graph.node(target)
|
|
14
|
+
|
|
15
|
+
for node in graph.nodes:
|
|
16
|
+
if not registry.has(node.type):
|
|
17
|
+
raise UnknownNodeType(f"Unknown node type {node.type!r}.", node_id=node.id)
|
|
18
|
+
|
|
19
|
+
for node in graph.nodes:
|
|
20
|
+
descriptor = registry.get(node.type)
|
|
21
|
+
for port in descriptor.inputs:
|
|
22
|
+
if port.required and port.id not in node.inputs:
|
|
23
|
+
raise PortTypeError(
|
|
24
|
+
f"{descriptor.title} needs an input wired to {port.label!r}.",
|
|
25
|
+
node_id=node.id,
|
|
26
|
+
port=port.id,
|
|
27
|
+
)
|
|
28
|
+
for port_id, edges in node.inputs.items():
|
|
29
|
+
in_port = descriptor.input(port_id)
|
|
30
|
+
if in_port is None:
|
|
31
|
+
raise PortTypeError(
|
|
32
|
+
f"{node.type!r} has no input port {port_id!r}.", node_id=node.id, port=port_id
|
|
33
|
+
)
|
|
34
|
+
if len(edges) > 1 and in_port.kind is not PortKind.IMAGE_LIST:
|
|
35
|
+
raise PortTypeError(
|
|
36
|
+
f"Input {port_id!r} takes a single edge.", node_id=node.id, port=port_id
|
|
37
|
+
)
|
|
38
|
+
for edge in edges:
|
|
39
|
+
source = graph.node(edge.from_node)
|
|
40
|
+
out_port = registry.get(source.type).output(edge.output)
|
|
41
|
+
if out_port is None:
|
|
42
|
+
raise PortTypeError(
|
|
43
|
+
f"{source.type!r} has no output port {edge.output!r}.",
|
|
44
|
+
node_id=node.id,
|
|
45
|
+
port=port_id,
|
|
46
|
+
)
|
|
47
|
+
if not port_satisfies(out_port.kind, in_port.kind):
|
|
48
|
+
raise PortTypeError(
|
|
49
|
+
f"Cannot wire {out_port.kind.value} into {in_port.kind.value} "
|
|
50
|
+
f"at {node.id}.{port_id}.",
|
|
51
|
+
node_id=node.id,
|
|
52
|
+
port=port_id,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
closure = list(upstream_closure(target, graph.input_sources))
|
|
56
|
+
topo_sort(closure, graph.input_sources)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Best-effort importers that map foreign workflow formats onto our primitive node vocabulary."""
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Best-effort ComfyUI workflow importer: maps Comfy's API/prompt format onto our primitive
|
|
2
|
+
vocabulary (not LiteGraph). Comfy's shape is `{nodeId: {class_type, inputs}}`.
|
|
3
|
+
|
|
4
|
+
Comfy inputs are either literals (widget values) or edges `[nodeId, outputIndex]`. Two structural
|
|
5
|
+
gaps we bridge: Comfy bundles model+clip+vae in one `CheckpointLoaderSimple` (we split it into three
|
|
6
|
+
`load/*` nodes), and puts prompt text in a widget (lifted into an `input/text` node). Only known
|
|
7
|
+
node types map; unknown ones are reported in `skipped`, never silently dropped.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ImportResult:
|
|
19
|
+
graph: dict[str, Any]
|
|
20
|
+
skipped: list[str] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class _Ctx:
|
|
25
|
+
nodes: list[dict[str, Any]] = field(default_factory=list)
|
|
26
|
+
# (comfy_id, output_index) -> our edge {"from", "output"}
|
|
27
|
+
outputs: dict[tuple[str, int], dict[str, str]] = field(default_factory=dict)
|
|
28
|
+
# (our_node, our_input_port, comfy_edge) resolved after all nodes exist
|
|
29
|
+
pending: list[tuple[dict[str, Any], str, tuple[str, int]]] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_edge(value: Any) -> bool:
|
|
33
|
+
return isinstance(value, list) and len(value) == 2 and isinstance(value[0], str)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _wire(node: dict[str, Any], port: str, value: Any, ctx: _Ctx) -> None:
|
|
37
|
+
if _is_edge(value):
|
|
38
|
+
ctx.pending.append((node, port, (value[0], int(value[1]))))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _checkpoint(comfy_id: str, inputs: dict[str, Any], ctx: _Ctx) -> None:
|
|
42
|
+
ckpt = inputs.get("ckpt_name", "")
|
|
43
|
+
model_id, clip_id, vae_id = f"{comfy_id}:model", f"{comfy_id}:clip", f"{comfy_id}:vae"
|
|
44
|
+
ctx.nodes.append({"id": model_id, "type": "load/diffusion-model", "params": {"file": ckpt}})
|
|
45
|
+
ctx.nodes.append({"id": clip_id, "type": "load/text-encoder", "params": {"file": ckpt}})
|
|
46
|
+
ctx.nodes.append({"id": vae_id, "type": "load/vae", "params": {"file": ckpt}})
|
|
47
|
+
ctx.outputs[(comfy_id, 0)] = {"from": model_id, "output": "model"}
|
|
48
|
+
ctx.outputs[(comfy_id, 1)] = {"from": clip_id, "output": "text_encoder"}
|
|
49
|
+
ctx.outputs[(comfy_id, 2)] = {"from": vae_id, "output": "vae"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _clip_encode(comfy_id: str, inputs: dict[str, Any], ctx: _Ctx) -> None:
|
|
53
|
+
text_id = f"{comfy_id}:text"
|
|
54
|
+
ctx.nodes.append(
|
|
55
|
+
{"id": text_id, "type": "input/text", "params": {"text": inputs.get("text", "")}}
|
|
56
|
+
)
|
|
57
|
+
node: dict[str, Any] = {
|
|
58
|
+
"id": comfy_id,
|
|
59
|
+
"type": "encode/text",
|
|
60
|
+
"params": {},
|
|
61
|
+
"inputs": {"prompt": {"from": text_id, "output": "text"}},
|
|
62
|
+
}
|
|
63
|
+
ctx.nodes.append(node)
|
|
64
|
+
_wire(node, "text_encoder", inputs.get("clip"), ctx)
|
|
65
|
+
ctx.outputs[(comfy_id, 0)] = {"from": comfy_id, "output": "conditioning"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _empty_latent(comfy_id: str, inputs: dict[str, Any], ctx: _Ctx) -> None:
|
|
69
|
+
ctx.nodes.append(
|
|
70
|
+
{
|
|
71
|
+
"id": comfy_id,
|
|
72
|
+
"type": "latent/empty",
|
|
73
|
+
"params": {
|
|
74
|
+
"width": inputs.get("width", 1024),
|
|
75
|
+
"height": inputs.get("height", 1024),
|
|
76
|
+
"batch": inputs.get("batch_size", 1),
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
ctx.outputs[(comfy_id, 0)] = {"from": comfy_id, "output": "latent"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _ksampler(comfy_id: str, inputs: dict[str, Any], ctx: _Ctx) -> None:
|
|
84
|
+
node: dict[str, Any] = {
|
|
85
|
+
"id": comfy_id,
|
|
86
|
+
"type": "sample",
|
|
87
|
+
"params": {
|
|
88
|
+
"steps": inputs.get("steps", 20),
|
|
89
|
+
"cfg": inputs.get("cfg", 5.0),
|
|
90
|
+
"sampler": inputs.get("sampler_name", "euler"),
|
|
91
|
+
"scheduler": inputs.get("scheduler", "simple"),
|
|
92
|
+
"seed": inputs.get("seed", -1),
|
|
93
|
+
},
|
|
94
|
+
"inputs": {},
|
|
95
|
+
}
|
|
96
|
+
ctx.nodes.append(node)
|
|
97
|
+
_wire(node, "model", inputs.get("model"), ctx)
|
|
98
|
+
_wire(node, "positive", inputs.get("positive"), ctx)
|
|
99
|
+
_wire(node, "negative", inputs.get("negative"), ctx)
|
|
100
|
+
_wire(node, "latent", inputs.get("latent_image"), ctx)
|
|
101
|
+
ctx.outputs[(comfy_id, 0)] = {"from": comfy_id, "output": "latent"}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _vae_decode(comfy_id: str, inputs: dict[str, Any], ctx: _Ctx) -> None:
|
|
105
|
+
node: dict[str, Any] = {"id": comfy_id, "type": "vae/decode", "params": {}, "inputs": {}}
|
|
106
|
+
ctx.nodes.append(node)
|
|
107
|
+
_wire(node, "latent", inputs.get("samples"), ctx)
|
|
108
|
+
_wire(node, "vae", inputs.get("vae"), ctx)
|
|
109
|
+
ctx.outputs[(comfy_id, 0)] = {"from": comfy_id, "output": "image"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _vae_encode(comfy_id: str, inputs: dict[str, Any], ctx: _Ctx) -> None:
|
|
113
|
+
node: dict[str, Any] = {"id": comfy_id, "type": "vae/encode", "params": {}, "inputs": {}}
|
|
114
|
+
ctx.nodes.append(node)
|
|
115
|
+
_wire(node, "vae", inputs.get("vae"), ctx)
|
|
116
|
+
_wire(node, "image", inputs.get("pixels"), ctx)
|
|
117
|
+
ctx.outputs[(comfy_id, 0)] = {"from": comfy_id, "output": "latent"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _vae_loader(comfy_id: str, inputs: dict[str, Any], ctx: _Ctx) -> None:
|
|
121
|
+
ctx.nodes.append(
|
|
122
|
+
{"id": comfy_id, "type": "load/vae", "params": {"file": inputs.get("vae_name", "")}}
|
|
123
|
+
)
|
|
124
|
+
ctx.outputs[(comfy_id, 0)] = {"from": comfy_id, "output": "vae"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_Handler = Callable[[str, dict[str, Any], _Ctx], None]
|
|
128
|
+
|
|
129
|
+
_HANDLERS: dict[str, _Handler] = {
|
|
130
|
+
"CheckpointLoaderSimple": _checkpoint,
|
|
131
|
+
"CLIPTextEncode": _clip_encode,
|
|
132
|
+
"EmptyLatentImage": _empty_latent,
|
|
133
|
+
"KSampler": _ksampler,
|
|
134
|
+
"VAEDecode": _vae_decode,
|
|
135
|
+
"VAEEncode": _vae_encode,
|
|
136
|
+
"VAELoader": _vae_loader,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# Display sinks with no equivalent in our model (the decoded image is already the output/Frame).
|
|
140
|
+
_SINKS = {"SaveImage", "PreviewImage"}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def import_comfy_prompt(prompt: dict[str, Any]) -> ImportResult:
|
|
144
|
+
"""Convert a Comfy prompt-format workflow into our graph JSON (schemaVersion 1)."""
|
|
145
|
+
ctx = _Ctx()
|
|
146
|
+
skipped: list[str] = []
|
|
147
|
+
for comfy_id, node in prompt.items():
|
|
148
|
+
class_type = str(node.get("class_type", ""))
|
|
149
|
+
if class_type in _SINKS:
|
|
150
|
+
continue
|
|
151
|
+
handler = _HANDLERS.get(class_type)
|
|
152
|
+
if handler is None:
|
|
153
|
+
skipped.append(class_type)
|
|
154
|
+
continue
|
|
155
|
+
handler(comfy_id, node.get("inputs", {}), ctx)
|
|
156
|
+
|
|
157
|
+
for node, port, (src_id, src_index) in ctx.pending:
|
|
158
|
+
edge = ctx.outputs.get((src_id, src_index))
|
|
159
|
+
if edge is not None:
|
|
160
|
+
node.setdefault("inputs", {})[port] = edge
|
|
161
|
+
|
|
162
|
+
return ImportResult(graph={"schemaVersion": 1, "nodes": ctx.nodes}, skipped=skipped)
|
inline_core/media.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Installed-model catalog and the model runners.
|
|
2
|
+
|
|
3
|
+
`catalog.py` scans the models root (category subfolders of weight files the user drops in) and feeds
|
|
4
|
+
the dynamic `options_from` selects on node params. Runner subpackages (e.g. `zimage`) are optional
|
|
5
|
+
and imported best-effort by `server.bootstrap`, so a core install without their deps still boots.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""The installed-model catalog: what the user has dropped under the models root.
|
|
2
|
+
|
|
3
|
+
The root holds one subfolder per category (``diffusion_models``, ``vae``, ``loras``, ...). A scan
|
|
4
|
+
lists, per category, the weight files present (by filename) plus any subfolder that itself contains
|
|
5
|
+
weights (by folder name, e.g. a sharded ``qwen3-4b/`` text encoder). Non-weight files are ignored.
|
|
6
|
+
|
|
7
|
+
Two consumers: ``serialize.param_json`` fills a param's ``options_from`` select from ``list()``, and
|
|
8
|
+
the server folds ``fingerprint()`` into the registry version so dropping a weight in bumps it and
|
|
9
|
+
clients refetch ``/v1/models``. Nothing is downloaded; users place their own files.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# Category subfolders scanned under the models root. These are the keys a param's `options_from`
|
|
19
|
+
# may reference (see graph/primitives.py); ensure_dirs() creates them so drop-in is obvious.
|
|
20
|
+
CATEGORIES: tuple[str, ...] = (
|
|
21
|
+
"diffusion_models",
|
|
22
|
+
"checkpoints",
|
|
23
|
+
"vae",
|
|
24
|
+
"text_encoders",
|
|
25
|
+
"loras",
|
|
26
|
+
"clip_vision",
|
|
27
|
+
"controlnet",
|
|
28
|
+
"upscale_models",
|
|
29
|
+
"embeddings",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Extensions we treat as model weights. A folder counts as a model if it contains one of these.
|
|
33
|
+
_WEIGHT_SUFFIXES: frozenset[str] = frozenset(
|
|
34
|
+
{".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".onnx"}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_weight(path: Path) -> bool:
|
|
39
|
+
return path.is_file() and path.suffix.lower() in _WEIGHT_SUFFIXES
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _folder_has_weight(path: Path) -> bool:
|
|
43
|
+
return any(_is_weight(child) for child in path.rglob("*"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ModelCatalog:
|
|
47
|
+
"""Scans the models root and answers "what's installed" per category.
|
|
48
|
+
|
|
49
|
+
Cheap to construct; nothing touches disk until ``ensure_dirs`` or ``rescan``/``scan``. Results
|
|
50
|
+
are cached so ``list`` and ``fingerprint`` are hits between scans.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, root: Path) -> None:
|
|
54
|
+
self._root = Path(root)
|
|
55
|
+
self._entries: dict[str, list[str]] = {category: [] for category in CATEGORIES}
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def root(self) -> Path:
|
|
59
|
+
return self._root
|
|
60
|
+
|
|
61
|
+
def ensure_dirs(self) -> None:
|
|
62
|
+
"""Create the root and every category subfolder, so users have somewhere to drop weights."""
|
|
63
|
+
for category in CATEGORIES:
|
|
64
|
+
(self._root / category).mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
def rescan(self) -> dict[str, list[str]]:
|
|
67
|
+
"""Re-read every category from disk, cache the result, and return it."""
|
|
68
|
+
entries: dict[str, list[str]] = {}
|
|
69
|
+
for category in CATEGORIES:
|
|
70
|
+
entries[category] = self._scan_category(self._root / category)
|
|
71
|
+
self._entries = entries
|
|
72
|
+
return entries
|
|
73
|
+
|
|
74
|
+
# app.py calls scan() in the lifespan; rescan() is the same work exposed for tests/callers that
|
|
75
|
+
# want the mapping back. Keep both so neither call site has to know about the other.
|
|
76
|
+
def scan(self) -> dict[str, list[str]]:
|
|
77
|
+
return self.rescan()
|
|
78
|
+
|
|
79
|
+
def list(self, category: str) -> list[str]:
|
|
80
|
+
"""The installed entries for a category (empty for an unknown or empty one)."""
|
|
81
|
+
return list(self._entries.get(category, []))
|
|
82
|
+
|
|
83
|
+
def fingerprint(self) -> str:
|
|
84
|
+
"""A short, stable digest of the cached scan; changes iff the installed set changes."""
|
|
85
|
+
payload = json.dumps(self._entries, sort_keys=True)
|
|
86
|
+
return hashlib.sha256(payload.encode()).hexdigest()[:16]
|
|
87
|
+
|
|
88
|
+
def _scan_category(self, directory: Path) -> list[str]:
|
|
89
|
+
if not directory.is_dir():
|
|
90
|
+
return []
|
|
91
|
+
names: list[str] = []
|
|
92
|
+
for entry in directory.iterdir():
|
|
93
|
+
if _is_weight(entry):
|
|
94
|
+
names.append(entry.name)
|
|
95
|
+
elif entry.is_dir() and _folder_has_weight(entry):
|
|
96
|
+
# A sharded model (config + shards) is one entry, named for its folder.
|
|
97
|
+
names.append(entry.name)
|
|
98
|
+
return sorted(names)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The Z-Image (Alibaba Tongyi) runtime: a diffusers-backed text-to-image / img2img runner.
|
|
2
|
+
|
|
3
|
+
Optional subpackage. `server.bootstrap` imports `register_zimage` best-effort, so a core install
|
|
4
|
+
without the ``zimage`` extra (torch + diffusers) still boots and serves the source nodes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .runner import register_zimage
|
|
10
|
+
|
|
11
|
+
__all__ = ["register_zimage"]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""What Z-Image needs on disk, and whether it's present — the data behind the node's model popup.
|
|
2
|
+
|
|
3
|
+
**No hidden downloads.** A component is "present" only if the user placed it under ``models/`` or
|
|
4
|
+
downloaded it through the popup (which also writes into ``models/``). Nothing here or in the runner
|
|
5
|
+
ever fetches a model as a side effect of loading — a missing component is reported, not silently
|
|
6
|
+
pulled from Hugging Face.
|
|
7
|
+
|
|
8
|
+
This module is deliberately **torch-free** (pure filesystem + config), so the requirements check and
|
|
9
|
+
the download planning work without the heavy ``zimage`` runtime loaded. The runner imports the
|
|
10
|
+
resolution helpers from here so the "what/where" logic lives in one place.
|
|
11
|
+
|
|
12
|
+
Z-Image is a single diffusers repo (``Tongyi-MAI/Z-Image-Turbo``) with ``transformer/``, ``vae/``,
|
|
13
|
+
``text_encoder/`` and ``tokenizer/`` subfolders. The user's common path is to drop a single
|
|
14
|
+
diffusion ``.safetensors`` in ``diffusion_models/`` and download the small VAE + text-encoder; the
|
|
15
|
+
popup can also fetch the whole pipeline as one diffusers folder.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from ...config import models_dir
|
|
25
|
+
|
|
26
|
+
#: The reference repo. Used only as an explicit download source (never as an implicit load source).
|
|
27
|
+
BASE_REPO = "Tongyi-MAI/Z-Image-Turbo"
|
|
28
|
+
|
|
29
|
+
_WEIGHT_SUFFIXES = (".safetensors", ".ckpt", ".pt", ".sft")
|
|
30
|
+
_LOCAL_NAMES = ("Z-Image-Turbo", "z-image-turbo", "Z-Image", "z-image")
|
|
31
|
+
#: Folder name new downloads land under, inside each category dir (so re-downloads are idempotent).
|
|
32
|
+
_LOCAL_DIR = "z-image-turbo"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --- filesystem resolution (shared with the runner) ---------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def diffusion_root() -> Path:
|
|
39
|
+
return models_dir() / "diffusion_models"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_weight_file(root: Path) -> Path | None:
|
|
43
|
+
"""The single diffusion weight file to load: prefer a z-image-named file, else the first one."""
|
|
44
|
+
if not root.is_dir():
|
|
45
|
+
return None
|
|
46
|
+
weights = sorted(
|
|
47
|
+
p for p in root.iterdir() if p.is_file() and p.suffix.lower() in _WEIGHT_SUFFIXES
|
|
48
|
+
)
|
|
49
|
+
named = [p for p in weights if "z" in p.name.lower() and "image" in p.name.lower()]
|
|
50
|
+
return (named or weights or [None])[0]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def pipeline_dir(root: Path) -> Path | None:
|
|
54
|
+
"""A local diffusers folder (has ``model_index.json``) that holds the whole Z-Image pipeline."""
|
|
55
|
+
if not root.is_dir():
|
|
56
|
+
return None
|
|
57
|
+
for name in _LOCAL_NAMES:
|
|
58
|
+
candidate = root / name
|
|
59
|
+
if (candidate / "model_index.json").is_file():
|
|
60
|
+
return candidate
|
|
61
|
+
# Any subfolder that looks like a diffusers pipeline also counts (e.g. a popup download).
|
|
62
|
+
for child in sorted(p for p in root.iterdir() if p.is_dir()):
|
|
63
|
+
if (child / "model_index.json").is_file():
|
|
64
|
+
return child
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def local_component(category: str, env_var: str) -> Path | None:
|
|
69
|
+
"""A local supporting-model file/dir under ``models/<category>/`` (or an env override), or None.
|
|
70
|
+
Prefers an explicit env path, then a single weight file, then a subdir (HF snapshot)."""
|
|
71
|
+
env = os.environ.get(env_var, "").strip()
|
|
72
|
+
if env:
|
|
73
|
+
path = Path(env)
|
|
74
|
+
return path if path.exists() else None
|
|
75
|
+
root = models_dir() / category
|
|
76
|
+
if not root.is_dir():
|
|
77
|
+
return None
|
|
78
|
+
files = sorted(
|
|
79
|
+
p for p in root.iterdir() if p.is_file() and p.suffix.lower() in _WEIGHT_SUFFIXES
|
|
80
|
+
)
|
|
81
|
+
if files:
|
|
82
|
+
return files[0]
|
|
83
|
+
dirs = sorted(p for p in root.iterdir() if p.is_dir())
|
|
84
|
+
return dirs[0] if dirs else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_diffusion(params: dict[str, object] | None = None) -> tuple[str, str] | None:
|
|
88
|
+
"""Pick the Z-Image diffusion source without ever inventing a remote one.
|
|
89
|
+
|
|
90
|
+
Returns ``(mode, path)`` where ``mode`` is ``"single_file"`` (a lone transformer file — VAE and
|
|
91
|
+
text-encoder come from local files) or ``"pipeline"`` (a whole diffusers folder). Returns
|
|
92
|
+
``None`` when nothing is present locally — reported as missing (no repo-id fallback). Priority:
|
|
93
|
+
node ``model`` param, ``INLINE_ZIMAGE_MODEL`` env, a single weight file, then a diffusers dir.
|
|
94
|
+
"""
|
|
95
|
+
root = diffusion_root()
|
|
96
|
+
|
|
97
|
+
chosen = str((params or {}).get("model") or "").strip()
|
|
98
|
+
if chosen:
|
|
99
|
+
path = root / chosen
|
|
100
|
+
if path.is_file():
|
|
101
|
+
return "single_file", str(path)
|
|
102
|
+
|
|
103
|
+
env = os.environ.get("INLINE_ZIMAGE_MODEL", "").strip()
|
|
104
|
+
if env:
|
|
105
|
+
# An explicit override is trusted: a file is a single-file source, anything else a pipeline.
|
|
106
|
+
return ("single_file", env) if Path(env).is_file() else ("pipeline", env)
|
|
107
|
+
|
|
108
|
+
single = find_weight_file(root)
|
|
109
|
+
if single is not None:
|
|
110
|
+
return "single_file", str(single)
|
|
111
|
+
|
|
112
|
+
pipe = pipeline_dir(root)
|
|
113
|
+
if pipe is not None:
|
|
114
|
+
return "pipeline", str(pipe)
|
|
115
|
+
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --- the requirements view (the popup's data) ---------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class ModelComponent:
|
|
124
|
+
"""One required model component, whether it's present, and how the popup would download it."""
|
|
125
|
+
|
|
126
|
+
id: str # "diffusion" | "vae" | "text_encoder"
|
|
127
|
+
label: str
|
|
128
|
+
category: str # models/ subfolder it belongs to
|
|
129
|
+
present: bool
|
|
130
|
+
local_path: str # where it's expected / would land (relative to the models root)
|
|
131
|
+
repo: str # HF repo the popup downloads from
|
|
132
|
+
subfolders: tuple[str, ...] # repo subfolders to fetch & flatten; () = whole repo, keep layout
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def zimage_requirements(params: dict[str, object] | None = None) -> list[ModelComponent]:
|
|
136
|
+
"""The three Z-Image components with live presence, for the node's model popup.
|
|
137
|
+
|
|
138
|
+
VAE and text-encoder count as present when the diffusion source is a whole-pipeline folder (it
|
|
139
|
+
already contains them) or when a local file/dir is provided under their category.
|
|
140
|
+
"""
|
|
141
|
+
diffusion = resolve_diffusion(params)
|
|
142
|
+
is_pipeline = diffusion is not None and diffusion[0] == "pipeline"
|
|
143
|
+
|
|
144
|
+
vae_local = local_component("vae", "INLINE_ZIMAGE_VAE")
|
|
145
|
+
te_local = local_component("text_encoders", "INLINE_ZIMAGE_TEXT_ENCODER")
|
|
146
|
+
|
|
147
|
+
return [
|
|
148
|
+
ModelComponent(
|
|
149
|
+
id="diffusion",
|
|
150
|
+
label="Diffusion model",
|
|
151
|
+
category="diffusion_models",
|
|
152
|
+
present=diffusion is not None,
|
|
153
|
+
local_path=f"diffusion_models/{_LOCAL_DIR}",
|
|
154
|
+
repo=BASE_REPO,
|
|
155
|
+
subfolders=(), # the whole diffusers pipeline as one folder
|
|
156
|
+
),
|
|
157
|
+
ModelComponent(
|
|
158
|
+
id="vae",
|
|
159
|
+
label="VAE",
|
|
160
|
+
category="vae",
|
|
161
|
+
present=is_pipeline or vae_local is not None,
|
|
162
|
+
local_path=f"vae/{_LOCAL_DIR}",
|
|
163
|
+
repo=BASE_REPO,
|
|
164
|
+
subfolders=("vae",),
|
|
165
|
+
),
|
|
166
|
+
ModelComponent(
|
|
167
|
+
id="text_encoder",
|
|
168
|
+
label="Text encoder",
|
|
169
|
+
category="text_encoders",
|
|
170
|
+
present=is_pipeline or (te_local is not None and te_local.is_dir()),
|
|
171
|
+
local_path=f"text_encoders/{_LOCAL_DIR}",
|
|
172
|
+
repo=BASE_REPO,
|
|
173
|
+
subfolders=("text_encoder", "tokenizer"),
|
|
174
|
+
),
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def download_target(component: ModelComponent) -> Path:
|
|
179
|
+
"""Absolute local dir a component downloads into (under the models root, never the HF cache)."""
|
|
180
|
+
return models_dir() / component.local_path
|