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,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,11 @@
1
+ """The media kinds a take or a model output can be."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class MediaKind(str, Enum):
9
+ IMAGE = "image"
10
+ VIDEO = "video"
11
+ AUDIO = "audio"
@@ -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