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,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