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,81 @@
|
|
|
1
|
+
"""Node descriptors: the data half of a node, served at GET /v1/models. See docs/contract.md.
|
|
2
|
+
|
|
3
|
+
The behavior half (build request, run components) lives in a NodeRunner, kept out of this data.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..media import MediaKind
|
|
13
|
+
from .schema import PortKind
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Widget(str, Enum):
|
|
17
|
+
TEXT = "text"
|
|
18
|
+
TEXTAREA = "textarea"
|
|
19
|
+
NUMBER = "number"
|
|
20
|
+
BOOLEAN = "boolean"
|
|
21
|
+
SELECT = "select"
|
|
22
|
+
SEED = "seed"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Option:
|
|
27
|
+
value: str
|
|
28
|
+
label: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ParamField:
|
|
33
|
+
key: str
|
|
34
|
+
label: str
|
|
35
|
+
widget: Widget
|
|
36
|
+
default: Any
|
|
37
|
+
min: float | None = None
|
|
38
|
+
max: float | None = None
|
|
39
|
+
step: float | None = None
|
|
40
|
+
options: tuple[Option, ...] = ()
|
|
41
|
+
# A dynamic catalog Core fills from what is installed (checkpoints, loras, vae, ...).
|
|
42
|
+
options_from: str | None = None
|
|
43
|
+
advanced: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Port:
|
|
48
|
+
id: str
|
|
49
|
+
label: str
|
|
50
|
+
kind: PortKind
|
|
51
|
+
required: bool = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class NodeDescriptor:
|
|
56
|
+
type: str
|
|
57
|
+
title: str
|
|
58
|
+
category: str
|
|
59
|
+
inputs: tuple[Port, ...] = ()
|
|
60
|
+
outputs: tuple[Port, ...] = ()
|
|
61
|
+
params: tuple[ParamField, ...] = ()
|
|
62
|
+
# Only generation nodes back a Frame; source/utility nodes leave this None.
|
|
63
|
+
output_kind: MediaKind | None = None
|
|
64
|
+
icon: str = ""
|
|
65
|
+
source: str = "builtin"
|
|
66
|
+
# Internal building blocks (loaders, samplers, VAE) — served for validation/execution but never
|
|
67
|
+
# offered in the UI's add-node menu. Keeps generation one-click: the user sees only high-level
|
|
68
|
+
# model nodes (e.g. Z-Image Turbo); loading a diffusion model / VAE / encoder happens behind it.
|
|
69
|
+
hidden: bool = False
|
|
70
|
+
|
|
71
|
+
def output(self, port_id: str) -> Port | None:
|
|
72
|
+
return next((p for p in self.outputs if p.id == port_id), None)
|
|
73
|
+
|
|
74
|
+
def input(self, port_id: str) -> Port | None:
|
|
75
|
+
return next((p for p in self.inputs if p.id == port_id), None)
|
|
76
|
+
|
|
77
|
+
def seed_keys(self) -> tuple[str, ...]:
|
|
78
|
+
return tuple(p.key for p in self.params if p.widget is Widget.SEED)
|
|
79
|
+
|
|
80
|
+
def defaults(self) -> dict[str, Any]:
|
|
81
|
+
return {p.key: p.default for p in self.params}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The graph executor: lazily run a target's closure, reuse cached nodes, stream run events.
|
|
2
|
+
|
|
3
|
+
It orchestrates cheap work inline. A model node's runner submits the denoise to the batched sampler
|
|
4
|
+
(see sampling/batch.py); the executor never runs the loop itself.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..errors import CancelledError, GraphValidationError, InlineCoreError
|
|
13
|
+
from ..runtime.context import ExecutionContext
|
|
14
|
+
from ..runtime.progress import CancelledEvent, ErrorEvent, NodeDoneEvent, RunDoneEvent
|
|
15
|
+
from ..runtime.run import NodeRuntimeState, RunState, RunStatus, StateTrackingEmitter
|
|
16
|
+
from .cache import NodeCache, is_cache_eligible, node_cache_key
|
|
17
|
+
from .registry import Registry
|
|
18
|
+
from .schema import Graph, Node
|
|
19
|
+
from .topo import topo_sort, upstream_closure
|
|
20
|
+
from .validate import validate
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Executor:
|
|
24
|
+
def __init__(self, registry: Registry, cache: NodeCache) -> None:
|
|
25
|
+
self._registry = registry
|
|
26
|
+
self._cache = cache
|
|
27
|
+
|
|
28
|
+
def run(self, graph: Graph, target: str, ctx: ExecutionContext, state: RunState) -> None:
|
|
29
|
+
"""Run the target's closure, updating `state` and forwarding events to ctx.emitter."""
|
|
30
|
+
emitter = StateTrackingEmitter(ctx.emitter, state)
|
|
31
|
+
run_ctx = replace(ctx, emitter=emitter)
|
|
32
|
+
try:
|
|
33
|
+
order = self._plan(graph, target, state)
|
|
34
|
+
state.status = RunStatus.RUNNING
|
|
35
|
+
outputs: dict[str, dict[str, Any]] = {}
|
|
36
|
+
for node_id in order:
|
|
37
|
+
if ctx.cancel.cancelled:
|
|
38
|
+
raise CancelledError("Run cancelled.")
|
|
39
|
+
self._run_node(graph, node_id, outputs, run_ctx)
|
|
40
|
+
emitter.emit(RunDoneEvent(run_id=ctx.run_id))
|
|
41
|
+
except CancelledError:
|
|
42
|
+
emitter.emit(CancelledEvent(run_id=ctx.run_id))
|
|
43
|
+
except GraphValidationError as error:
|
|
44
|
+
emitter.emit(ErrorEvent(run_id=ctx.run_id, message=str(error), node_id=error.node_id))
|
|
45
|
+
except InlineCoreError as error:
|
|
46
|
+
emitter.emit(ErrorEvent(run_id=ctx.run_id, message=str(error)))
|
|
47
|
+
|
|
48
|
+
def _plan(self, graph: Graph, target: str, state: RunState) -> list[str]:
|
|
49
|
+
validate(graph, target, self._registry)
|
|
50
|
+
closure = list(upstream_closure(target, graph.input_sources))
|
|
51
|
+
order = topo_sort(closure, graph.input_sources)
|
|
52
|
+
for node_id in order:
|
|
53
|
+
state.nodes.setdefault(node_id, NodeRuntimeState())
|
|
54
|
+
return order
|
|
55
|
+
|
|
56
|
+
def _run_node(
|
|
57
|
+
self,
|
|
58
|
+
graph: Graph,
|
|
59
|
+
node_id: str,
|
|
60
|
+
outputs: dict[str, dict[str, Any]],
|
|
61
|
+
ctx: ExecutionContext,
|
|
62
|
+
) -> None:
|
|
63
|
+
node = graph.node(node_id)
|
|
64
|
+
runner = self._registry.runner(node.type)
|
|
65
|
+
inputs = self._resolve_inputs(node, outputs)
|
|
66
|
+
|
|
67
|
+
key: str | None = None
|
|
68
|
+
if runner.produces_takes and is_cache_eligible(node, self._registry):
|
|
69
|
+
# TODO(phase1): pass real asset content hashes so identity is content-addressed.
|
|
70
|
+
key = node_cache_key(graph, node_id, self._registry, asset_hashes={})
|
|
71
|
+
cached = self._cache.get(key)
|
|
72
|
+
if cached is not None:
|
|
73
|
+
ctx.emitter.emit(
|
|
74
|
+
NodeDoneEvent(run_id=ctx.run_id, node_id=node_id, cached=True, takes=cached)
|
|
75
|
+
)
|
|
76
|
+
outputs[node_id] = self._take_outputs(node, cached[0] if cached else None)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
result = runner.run(node, inputs, ctx)
|
|
80
|
+
outputs[node_id] = result.outputs
|
|
81
|
+
if result.takes:
|
|
82
|
+
if key is not None:
|
|
83
|
+
self._cache.put(key, result.takes)
|
|
84
|
+
ctx.emitter.emit(
|
|
85
|
+
NodeDoneEvent(run_id=ctx.run_id, node_id=node_id, cached=False, takes=result.takes)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _resolve_inputs(
|
|
89
|
+
self, node: Node, outputs: dict[str, dict[str, Any]]
|
|
90
|
+
) -> dict[str, list[Any]]:
|
|
91
|
+
resolved: dict[str, list[Any]] = {}
|
|
92
|
+
for port_id, edges in node.inputs.items():
|
|
93
|
+
values: list[Any] = []
|
|
94
|
+
for edge in edges:
|
|
95
|
+
upstream = outputs.get(edge.from_node, {})
|
|
96
|
+
if edge.output in upstream:
|
|
97
|
+
values.append(upstream[edge.output])
|
|
98
|
+
resolved[port_id] = values
|
|
99
|
+
return resolved
|
|
100
|
+
|
|
101
|
+
def _take_outputs(self, node: Node, take: Any) -> dict[str, Any]:
|
|
102
|
+
return {port.id: take for port in self._registry.get(node.type).outputs}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""The low-level primitive node vocabulary (our own clean design, not Comfy's names).
|
|
2
|
+
|
|
3
|
+
Descriptors only here; the diffusers-backed runners land in C2. Registering them descriptor-first
|
|
4
|
+
lets /v1/models serve the palette and the validator type-check engine-typed edges (model, vae,
|
|
5
|
+
conditioning, latent) now. Only media-output nodes (vae/decode) become Frames with take history.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from ..media import MediaKind
|
|
14
|
+
from .descriptor import NodeDescriptor, Option, ParamField, Port, Widget
|
|
15
|
+
from .schema import PortKind
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .registry import Registry
|
|
19
|
+
|
|
20
|
+
_SAMPLERS = (Option("euler", "Euler"), Option("dpmpp_2m", "DPM++ 2M"), Option("heun", "Heun"))
|
|
21
|
+
_SCHEDULERS = (Option("simple", "Simple"), Option("karras", "Karras"))
|
|
22
|
+
|
|
23
|
+
LOAD_DIFFUSION_MODEL = NodeDescriptor(
|
|
24
|
+
type="load/diffusion-model",
|
|
25
|
+
title="Load Diffusion Model",
|
|
26
|
+
category="Loaders",
|
|
27
|
+
icon="box",
|
|
28
|
+
params=(ParamField("file", "Model", Widget.SELECT, "", options_from="diffusion_models"),),
|
|
29
|
+
outputs=(Port("model", "Model", PortKind.MODEL),),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
LOAD_VAE = NodeDescriptor(
|
|
33
|
+
type="load/vae",
|
|
34
|
+
title="Load VAE",
|
|
35
|
+
category="Loaders",
|
|
36
|
+
icon="box",
|
|
37
|
+
params=(ParamField("file", "VAE", Widget.SELECT, "", options_from="vae"),),
|
|
38
|
+
outputs=(Port("vae", "VAE", PortKind.VAE),),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
LOAD_TEXT_ENCODER = NodeDescriptor(
|
|
42
|
+
type="load/text-encoder",
|
|
43
|
+
title="Load Text Encoder",
|
|
44
|
+
category="Loaders",
|
|
45
|
+
icon="box",
|
|
46
|
+
params=(ParamField("file", "Text encoder", Widget.SELECT, "", options_from="text_encoders"),),
|
|
47
|
+
outputs=(Port("text_encoder", "Text encoder", PortKind.TEXT_ENCODER),),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
ENCODE_TEXT = NodeDescriptor(
|
|
51
|
+
type="encode/text",
|
|
52
|
+
title="Encode Text",
|
|
53
|
+
category="Conditioning",
|
|
54
|
+
icon="type",
|
|
55
|
+
inputs=(
|
|
56
|
+
Port("text_encoder", "Text encoder", PortKind.TEXT_ENCODER, required=True),
|
|
57
|
+
Port("prompt", "Prompt", PortKind.TEXT, required=True),
|
|
58
|
+
),
|
|
59
|
+
outputs=(Port("conditioning", "Conditioning", PortKind.CONDITIONING),),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
EMPTY_LATENT = NodeDescriptor(
|
|
63
|
+
type="latent/empty",
|
|
64
|
+
title="Empty Latent",
|
|
65
|
+
category="Latent",
|
|
66
|
+
icon="square",
|
|
67
|
+
params=(
|
|
68
|
+
ParamField("width", "Width", Widget.NUMBER, 1024, min=64, max=4096, step=8),
|
|
69
|
+
ParamField("height", "Height", Widget.NUMBER, 1024, min=64, max=4096, step=8),
|
|
70
|
+
ParamField("batch", "Batch", Widget.NUMBER, 1, min=1, max=16, step=1),
|
|
71
|
+
),
|
|
72
|
+
outputs=(Port("latent", "Latent", PortKind.LATENT),),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
SAMPLE = NodeDescriptor(
|
|
76
|
+
type="sample",
|
|
77
|
+
title="Sample",
|
|
78
|
+
category="Sampling",
|
|
79
|
+
icon="wand",
|
|
80
|
+
inputs=(
|
|
81
|
+
Port("model", "Model", PortKind.MODEL, required=True),
|
|
82
|
+
Port("positive", "Positive", PortKind.CONDITIONING, required=True),
|
|
83
|
+
Port("negative", "Negative", PortKind.CONDITIONING, required=False),
|
|
84
|
+
Port("latent", "Latent", PortKind.LATENT, required=True),
|
|
85
|
+
),
|
|
86
|
+
params=(
|
|
87
|
+
ParamField("steps", "Steps", Widget.NUMBER, 20, min=1, max=200, step=1),
|
|
88
|
+
ParamField("cfg", "CFG", Widget.NUMBER, 5.0, min=0.0, max=30.0, step=0.1),
|
|
89
|
+
ParamField("sampler", "Sampler", Widget.SELECT, "euler", options=_SAMPLERS),
|
|
90
|
+
ParamField("scheduler", "Scheduler", Widget.SELECT, "simple", options=_SCHEDULERS),
|
|
91
|
+
ParamField("seed", "Seed (-1 = random)", Widget.SEED, -1),
|
|
92
|
+
),
|
|
93
|
+
outputs=(Port("latent", "Latent", PortKind.LATENT),),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
VAE_DECODE = NodeDescriptor(
|
|
97
|
+
type="vae/decode",
|
|
98
|
+
title="VAE Decode",
|
|
99
|
+
category="VAE",
|
|
100
|
+
icon="image",
|
|
101
|
+
output_kind=MediaKind.IMAGE,
|
|
102
|
+
inputs=(
|
|
103
|
+
Port("vae", "VAE", PortKind.VAE, required=True),
|
|
104
|
+
Port("latent", "Latent", PortKind.LATENT, required=True),
|
|
105
|
+
),
|
|
106
|
+
outputs=(Port("image", "Image", PortKind.IMAGE),),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
VAE_ENCODE = NodeDescriptor(
|
|
110
|
+
type="vae/encode",
|
|
111
|
+
title="VAE Encode",
|
|
112
|
+
category="VAE",
|
|
113
|
+
icon="square",
|
|
114
|
+
inputs=(
|
|
115
|
+
Port("vae", "VAE", PortKind.VAE, required=True),
|
|
116
|
+
Port("image", "Image", PortKind.IMAGE, required=True),
|
|
117
|
+
),
|
|
118
|
+
outputs=(Port("latent", "Latent", PortKind.LATENT),),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
PRIMITIVES: tuple[NodeDescriptor, ...] = (
|
|
122
|
+
LOAD_DIFFUSION_MODEL,
|
|
123
|
+
LOAD_VAE,
|
|
124
|
+
LOAD_TEXT_ENCODER,
|
|
125
|
+
ENCODE_TEXT,
|
|
126
|
+
EMPTY_LATENT,
|
|
127
|
+
SAMPLE,
|
|
128
|
+
VAE_DECODE,
|
|
129
|
+
VAE_ENCODE,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def register_primitives(registry: Registry) -> None:
|
|
134
|
+
"""Register the primitive descriptors, marked hidden so they never surface in the add-node menu
|
|
135
|
+
(they stay available for validation/execution). Their runners land in C2."""
|
|
136
|
+
for descriptor in PRIMITIVES:
|
|
137
|
+
registry.register(replace(descriptor, hidden=True))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The node registry: descriptors served at /v1/models plus the runner behind each type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
|
|
9
|
+
from ..errors import UnknownNodeType
|
|
10
|
+
from .descriptor import NodeDescriptor
|
|
11
|
+
from .primitives import register_primitives
|
|
12
|
+
from .runners import IMAGE_INPUT, TEXT_INPUT, ImageInputRunner, NodeRunner, TextInputRunner
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Registry:
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._descriptors: dict[str, NodeDescriptor] = {}
|
|
18
|
+
self._runners: dict[str, NodeRunner] = {}
|
|
19
|
+
|
|
20
|
+
def register(self, descriptor: NodeDescriptor, runner: NodeRunner | None = None) -> None:
|
|
21
|
+
"""Register a node. A descriptor with no runner is served + validated but cannot run yet."""
|
|
22
|
+
self._descriptors[descriptor.type] = descriptor
|
|
23
|
+
if runner is not None:
|
|
24
|
+
self._runners[descriptor.type] = runner
|
|
25
|
+
|
|
26
|
+
def get(self, node_type: str) -> NodeDescriptor:
|
|
27
|
+
descriptor = self._descriptors.get(node_type)
|
|
28
|
+
if descriptor is None:
|
|
29
|
+
raise UnknownNodeType(f"Unknown node type {node_type!r}.")
|
|
30
|
+
return descriptor
|
|
31
|
+
|
|
32
|
+
def has(self, node_type: str) -> bool:
|
|
33
|
+
return node_type in self._descriptors
|
|
34
|
+
|
|
35
|
+
def runner(self, node_type: str) -> NodeRunner:
|
|
36
|
+
runner = self._runners.get(node_type)
|
|
37
|
+
if runner is None:
|
|
38
|
+
raise UnknownNodeType(f"No runner registered for {node_type!r}.")
|
|
39
|
+
return runner
|
|
40
|
+
|
|
41
|
+
def descriptors(self) -> list[NodeDescriptor]:
|
|
42
|
+
return list(self._descriptors.values())
|
|
43
|
+
|
|
44
|
+
def version(self) -> str:
|
|
45
|
+
# TODO(phase1): fold resolved dynamic options into this so installing a model bumps it.
|
|
46
|
+
payload = json.dumps(sorted(self._descriptors), separators=(",", ":"))
|
|
47
|
+
return f"r_{hashlib.sha256(payload.encode()).hexdigest()[:8]}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_default_registry() -> Registry:
|
|
51
|
+
"""A registry with the built-in source nodes and the low-level primitive descriptors.
|
|
52
|
+
|
|
53
|
+
Source nodes have runners; the primitives are descriptor-only until their runners land (C2).
|
|
54
|
+
Both are marked hidden: the Studio drives text/image inputs through its own Prompt/library
|
|
55
|
+
nodes, so these plumbing types stay runnable but never appear in the add-node menu.
|
|
56
|
+
"""
|
|
57
|
+
registry = Registry()
|
|
58
|
+
registry.register(replace(TEXT_INPUT, hidden=True), TextInputRunner())
|
|
59
|
+
registry.register(replace(IMAGE_INPUT, hidden=True), ImageInputRunner())
|
|
60
|
+
register_primitives(registry)
|
|
61
|
+
return registry
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Node runners: the behavior half of a node. Source runners are pure; model runners lower to
|
|
2
|
+
components (see models/runner.py). Every runner returns its output values and any takes it produced.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, ClassVar
|
|
10
|
+
|
|
11
|
+
from ..errors import ComponentError
|
|
12
|
+
from ..runtime.context import ExecutionContext
|
|
13
|
+
from ..takes import AssetRef, Take
|
|
14
|
+
from .descriptor import NodeDescriptor, ParamField, Port, Widget
|
|
15
|
+
from .schema import Node, PortKind
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class NodeResult:
|
|
20
|
+
outputs: dict[str, Any]
|
|
21
|
+
takes: list[Take] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NodeRunner(ABC):
|
|
25
|
+
produces_takes: ClassVar[bool] = False
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def run(
|
|
29
|
+
self, node: Node, inputs: dict[str, list[Any]], ctx: ExecutionContext
|
|
30
|
+
) -> NodeResult: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TextInputRunner(NodeRunner):
|
|
34
|
+
produces_takes = False
|
|
35
|
+
|
|
36
|
+
def run(self, node: Node, inputs: dict[str, list[Any]], ctx: ExecutionContext) -> NodeResult:
|
|
37
|
+
return NodeResult(outputs={"text": str(node.params.get("text", ""))})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ImageInputRunner(NodeRunner):
|
|
41
|
+
produces_takes = False
|
|
42
|
+
|
|
43
|
+
def run(self, node: Node, inputs: dict[str, list[Any]], ctx: ExecutionContext) -> NodeResult:
|
|
44
|
+
return NodeResult(outputs={"image": _asset_ref(node.params.get("asset"))})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _asset_ref(raw: Any) -> AssetRef:
|
|
48
|
+
if isinstance(raw, dict):
|
|
49
|
+
ref = raw.get("ref")
|
|
50
|
+
if ref == "asset":
|
|
51
|
+
return AssetRef(ref="asset", id=str(raw.get("id", "")))
|
|
52
|
+
if ref == "path":
|
|
53
|
+
return AssetRef(ref="path", path=str(raw.get("path", "")))
|
|
54
|
+
raise ComponentError("An image input node needs a valid asset reference.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
TEXT_INPUT = NodeDescriptor(
|
|
58
|
+
type="input/text",
|
|
59
|
+
title="Prompt",
|
|
60
|
+
category="Input",
|
|
61
|
+
outputs=(Port("text", "Text", PortKind.TEXT),),
|
|
62
|
+
params=(ParamField("text", "Text", Widget.TEXTAREA, ""),),
|
|
63
|
+
icon="type",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
IMAGE_INPUT = NodeDescriptor(
|
|
67
|
+
type="input/image",
|
|
68
|
+
title="Image",
|
|
69
|
+
category="Input",
|
|
70
|
+
outputs=(Port("image", "Image", PortKind.IMAGE),),
|
|
71
|
+
icon="image",
|
|
72
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""The typed graph: PortKind, Node, Edge, Graph, and the JSON parser. Graph schemaVersion 1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..errors import GraphValidationError
|
|
10
|
+
|
|
11
|
+
SCHEMA_VERSION = 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PortKind(str, Enum):
|
|
15
|
+
# media (cross the wire as takes / assets)
|
|
16
|
+
IMAGE = "image"
|
|
17
|
+
IMAGE_LIST = "image[]"
|
|
18
|
+
VIDEO = "video"
|
|
19
|
+
AUDIO = "audio"
|
|
20
|
+
TEXT = "text"
|
|
21
|
+
MASK = "mask"
|
|
22
|
+
# engine handles (opaque, passed between low-level nodes; never a take)
|
|
23
|
+
MODEL = "model"
|
|
24
|
+
VAE = "vae"
|
|
25
|
+
TEXT_ENCODER = "text-encoder"
|
|
26
|
+
CONDITIONING = "conditioning"
|
|
27
|
+
LATENT = "latent"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def port_satisfies(source: PortKind, target: PortKind) -> bool:
|
|
31
|
+
"""Whether an output of `source` kind may feed an input of `target` kind."""
|
|
32
|
+
if source == target:
|
|
33
|
+
return True
|
|
34
|
+
# a single image satisfies a list input (a one-element list)
|
|
35
|
+
return source is PortKind.IMAGE and target is PortKind.IMAGE_LIST
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Edge:
|
|
40
|
+
from_node: str
|
|
41
|
+
output: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Node:
|
|
46
|
+
id: str
|
|
47
|
+
type: str
|
|
48
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
inputs: dict[str, list[Edge]] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Graph:
|
|
54
|
+
schema_version: int
|
|
55
|
+
nodes: list[Node]
|
|
56
|
+
_by_id: dict[str, Node] = field(default_factory=dict, init=False, repr=False)
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
self._by_id = {n.id: n for n in self.nodes}
|
|
60
|
+
|
|
61
|
+
def node(self, node_id: str) -> Node:
|
|
62
|
+
node = self._by_id.get(node_id)
|
|
63
|
+
if node is None:
|
|
64
|
+
raise GraphValidationError(f"No node with id {node_id!r}.", node_id=node_id)
|
|
65
|
+
return node
|
|
66
|
+
|
|
67
|
+
def ids(self) -> list[str]:
|
|
68
|
+
return [n.id for n in self.nodes]
|
|
69
|
+
|
|
70
|
+
def input_sources(self, node_id: str) -> list[str]:
|
|
71
|
+
"""Distinct upstream node ids feeding this node (the edges for topo sort)."""
|
|
72
|
+
seen: list[str] = []
|
|
73
|
+
for edges in self.node(node_id).inputs.values():
|
|
74
|
+
for e in edges:
|
|
75
|
+
if e.from_node not in seen:
|
|
76
|
+
seen.append(e.from_node)
|
|
77
|
+
return seen
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_edge(raw: Any) -> Edge:
|
|
81
|
+
if not isinstance(raw, dict):
|
|
82
|
+
raise GraphValidationError("An input edge must be an object with 'from' and 'output'.")
|
|
83
|
+
frm = raw.get("from")
|
|
84
|
+
out = raw.get("output")
|
|
85
|
+
if not isinstance(frm, str) or not isinstance(out, str):
|
|
86
|
+
raise GraphValidationError("An input edge needs string 'from' and 'output'.")
|
|
87
|
+
return Edge(from_node=frm, output=out)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_inputs(raw: Any, node_id: str) -> dict[str, list[Edge]]:
|
|
91
|
+
if raw is None:
|
|
92
|
+
return {}
|
|
93
|
+
if not isinstance(raw, dict):
|
|
94
|
+
raise GraphValidationError("Node 'inputs' must be an object.", node_id=node_id)
|
|
95
|
+
inputs: dict[str, list[Edge]] = {}
|
|
96
|
+
for port, val in raw.items():
|
|
97
|
+
inputs[str(port)] = [_parse_edge(e) for e in val] if isinstance(val, list) else [
|
|
98
|
+
_parse_edge(val)
|
|
99
|
+
]
|
|
100
|
+
return inputs
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _parse_node(raw: Any) -> Node:
|
|
104
|
+
if not isinstance(raw, dict):
|
|
105
|
+
raise GraphValidationError("Each node must be an object.")
|
|
106
|
+
node_id = raw.get("id")
|
|
107
|
+
node_type = raw.get("type")
|
|
108
|
+
if not isinstance(node_id, str) or not isinstance(node_type, str):
|
|
109
|
+
raise GraphValidationError("Each node needs a string 'id' and 'type'.")
|
|
110
|
+
params = raw.get("params") or {}
|
|
111
|
+
if not isinstance(params, dict):
|
|
112
|
+
raise GraphValidationError("Node 'params' must be an object.", node_id=node_id)
|
|
113
|
+
return Node(
|
|
114
|
+
id=node_id,
|
|
115
|
+
type=node_type,
|
|
116
|
+
params={str(k): v for k, v in params.items()},
|
|
117
|
+
inputs=_parse_inputs(raw.get("inputs"), node_id),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_graph(data: Any) -> Graph:
|
|
122
|
+
"""Parse the contract's graph JSON into a typed Graph. Raises GraphValidationError on shape."""
|
|
123
|
+
if not isinstance(data, dict):
|
|
124
|
+
raise GraphValidationError("Graph must be an object.")
|
|
125
|
+
version = data.get("schemaVersion")
|
|
126
|
+
if version != SCHEMA_VERSION:
|
|
127
|
+
raise GraphValidationError(f"Unsupported graph schemaVersion: {version!r}.")
|
|
128
|
+
raw_nodes = data.get("nodes")
|
|
129
|
+
if not isinstance(raw_nodes, list):
|
|
130
|
+
raise GraphValidationError("Graph 'nodes' must be a list.")
|
|
131
|
+
nodes = [_parse_node(n) for n in raw_nodes]
|
|
132
|
+
ids = [n.id for n in nodes]
|
|
133
|
+
dupes = sorted({i for i in ids if ids.count(i) > 1})
|
|
134
|
+
if dupes:
|
|
135
|
+
raise GraphValidationError(f"Duplicate node ids: {dupes}.")
|
|
136
|
+
return Graph(schema_version=version, nodes=nodes)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Pure graph traversal: the upstream closure and a stable topological sort."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from ..errors import CycleError
|
|
8
|
+
|
|
9
|
+
Edges = Callable[[str], list[str]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def upstream_closure(target: str, edges: Edges) -> set[str]:
|
|
13
|
+
"""Every node reachable upstream of `target`, inclusive."""
|
|
14
|
+
seen: set[str] = set()
|
|
15
|
+
stack = [target]
|
|
16
|
+
while stack:
|
|
17
|
+
node_id = stack.pop()
|
|
18
|
+
if node_id in seen:
|
|
19
|
+
continue
|
|
20
|
+
seen.add(node_id)
|
|
21
|
+
for up in edges(node_id):
|
|
22
|
+
if up not in seen:
|
|
23
|
+
stack.append(up)
|
|
24
|
+
return seen
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def topo_sort(ids: list[str], edges: Edges) -> list[str]:
|
|
28
|
+
"""Kahn sort over `ids` (dependencies first). Raises CycleError on a residual cycle."""
|
|
29
|
+
id_set = set(ids)
|
|
30
|
+
indegree = dict.fromkeys(ids, 0)
|
|
31
|
+
dependents: dict[str, list[str]] = {i: [] for i in ids}
|
|
32
|
+
for i in ids:
|
|
33
|
+
for up in edges(i):
|
|
34
|
+
if up not in id_set:
|
|
35
|
+
continue
|
|
36
|
+
indegree[i] += 1
|
|
37
|
+
dependents[up].append(i)
|
|
38
|
+
queue = [i for i in ids if indegree[i] == 0]
|
|
39
|
+
order: list[str] = []
|
|
40
|
+
while queue:
|
|
41
|
+
node_id = queue.pop(0)
|
|
42
|
+
order.append(node_id)
|
|
43
|
+
for dep in dependents[node_id]:
|
|
44
|
+
indegree[dep] -= 1
|
|
45
|
+
if indegree[dep] == 0:
|
|
46
|
+
queue.append(dep)
|
|
47
|
+
if len(order) != len(ids):
|
|
48
|
+
raise CycleError("This graph has a cycle.")
|
|
49
|
+
return order
|