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,327 @@
1
+ """The FastAPI app: the /v1 routes from docs/contract.md over the run manager and registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import re
10
+ import tempfile
11
+ from contextlib import asynccontextmanager
12
+ from os.path import basename
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import unquote
16
+
17
+ from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
18
+ from fastapi.responses import FileResponse, JSONResponse
19
+ from fastapi.staticfiles import StaticFiles
20
+
21
+ from ..config import models_dir
22
+ from ..device.memory import MemoryPolicy
23
+ from ..device.policy import DevicePolicy
24
+ from ..errors import GraphValidationError, UnknownNodeType
25
+ from ..graph.cache import InMemoryCache, NodeCache
26
+ from ..graph.registry import Registry, build_default_registry
27
+ from ..graph.schema import SCHEMA_VERSION, parse_graph
28
+ from ..models.catalog import ModelCatalog
29
+ from .assets import AssetStore
30
+ from .manager import RunConflict, RunManager
31
+ from .rpc import EventBroadcaster, RpcRouter
32
+ from .run_store import RunStore
33
+ from .serialize import descriptor_json, event_json, run_json, take_json
34
+
35
+ # GET /v1/runs/<id> (the client's run-status poll) — but not /events or nested paths.
36
+ _RUN_POLL_PATH = re.compile(r"^/v1/runs/[^/]+$")
37
+
38
+
39
+ class _SuppressRunPolling(logging.Filter):
40
+ """Drop the flood of run-status poll requests from the uvicorn access log.
41
+
42
+ Studio polls ``GET /v1/runs/<id>`` sub-second while a run is in flight, which buries the
43
+ generation progress bar under identical 200 lines. We hide only those successful polls;
44
+ submits, cancels, errors, and every other request still log normally.
45
+ """
46
+
47
+ def filter(self, record: logging.LogRecord) -> bool:
48
+ args = record.args
49
+ if not isinstance(args, tuple) or len(args) < 5:
50
+ return True
51
+ method, path, status = args[1], args[2], args[4]
52
+ if method == "GET" and status == 200 and _RUN_POLL_PATH.match(str(path)):
53
+ return False
54
+ return True
55
+
56
+
57
+ def _quiet_run_polling() -> None:
58
+ """Install the poll-suppressing access-log filter once (idempotent across create_app calls)."""
59
+ access = logging.getLogger("uvicorn.access")
60
+ if not any(isinstance(f, _SuppressRunPolling) for f in access.filters):
61
+ access.addFilter(_SuppressRunPolling())
62
+
63
+
64
+ def _within(root: Path, path: Path) -> bool:
65
+ try:
66
+ path.resolve().relative_to(root.resolve())
67
+ return True
68
+ except ValueError:
69
+ return False
70
+
71
+
72
+ def _error(code: str, message: str, status: int, node_id: str | None = None) -> JSONResponse:
73
+ error: dict[str, Any] = {"code": code, "message": message}
74
+ if node_id is not None:
75
+ error["nodeId"] = node_id
76
+ return JSONResponse({"error": error}, status_code=status)
77
+
78
+
79
+ def _version(registry: Registry, catalog: ModelCatalog) -> str:
80
+ """Registry version = node types + the scanned model files, so dropping a file bumps it."""
81
+ payload = json.dumps(
82
+ {"types": sorted(d.type for d in registry.descriptors()), "models": catalog.fingerprint()}
83
+ )
84
+ return f"r_{hashlib.sha256(payload.encode()).hexdigest()[:8]}"
85
+
86
+
87
+ def create_app(
88
+ registry: Registry | None = None,
89
+ cache: NodeCache | None = None,
90
+ policy: DevicePolicy | None = None,
91
+ asset_dir: str | None = None,
92
+ models_root: str | None = None,
93
+ run_store: RunStore | None = None,
94
+ takes_dir: str | None = None,
95
+ frontend_root: str | None = None,
96
+ rpc: RpcRouter | None = None,
97
+ events: EventBroadcaster | None = None,
98
+ studio_store: Any = None,
99
+ ) -> FastAPI:
100
+ _quiet_run_polling()
101
+ registry = registry or build_default_registry()
102
+ cache = cache or InMemoryCache()
103
+ policy = policy or MemoryPolicy()
104
+ assets = AssetStore(Path(asset_dir or "./.inline-assets"))
105
+ catalog = ModelCatalog(Path(models_root) if models_root else models_dir())
106
+ takes_root = Path(takes_dir or "./.inline-takes")
107
+ manager = RunManager(registry, cache, policy, store=run_store)
108
+ rpc = rpc or RpcRouter()
109
+ events = events or EventBroadcaster()
110
+
111
+ @asynccontextmanager
112
+ async def lifespan(app: FastAPI): # noqa: ANN202
113
+ manager.bind_loop(asyncio.get_running_loop())
114
+ catalog.ensure_dirs()
115
+ catalog.scan()
116
+ yield
117
+ manager.shutdown()
118
+
119
+ app = FastAPI(title="Inline Core", version="0.0.0", lifespan=lifespan)
120
+
121
+ @app.get("/v1/health")
122
+ async def health() -> dict[str, Any]:
123
+ placement = policy.placement("denoiser")
124
+ return {
125
+ "ok": True,
126
+ "apiVersion": "v1",
127
+ "schemaVersions": {"min": SCHEMA_VERSION, "max": SCHEMA_VERSION},
128
+ "registryVersion": _version(registry, catalog),
129
+ "device": {
130
+ "kind": placement.device.kind.value,
131
+ "profile": policy.profile.value,
132
+ "vramBudgetMb": None,
133
+ },
134
+ }
135
+
136
+ @app.get("/v1/models")
137
+ async def list_models(request: Request) -> Response:
138
+ version = _version(registry, catalog)
139
+ etag = f'"{version}"'
140
+ if request.headers.get("if-none-match") == etag:
141
+ return Response(status_code=304)
142
+ body = {
143
+ "registryVersion": version,
144
+ "models": [descriptor_json(d, catalog) for d in registry.descriptors()],
145
+ }
146
+ return JSONResponse(body, headers={"ETag": etag})
147
+
148
+ @app.get("/v1/models/{model_type:path}")
149
+ async def get_model(model_type: str) -> Response:
150
+ try:
151
+ return JSONResponse(descriptor_json(registry.get(model_type), catalog))
152
+ except UnknownNodeType as error:
153
+ return _error("not_found", str(error), 404)
154
+
155
+ @app.post("/v1/runs")
156
+ async def submit_run(request: Request) -> Response:
157
+ body = await request.json()
158
+ target = body.get("target")
159
+ if not isinstance(target, str):
160
+ return _error("invalid_request", "'target' is required.", 422)
161
+ try:
162
+ graph = parse_graph(body.get("graph"))
163
+ record, created = manager.submit(graph, target, body.get("clientRunId"))
164
+ except GraphValidationError as error:
165
+ return _error("invalid_graph", str(error), 422, node_id=error.node_id)
166
+ except RunConflict as error:
167
+ return _error("conflict", str(error), 409)
168
+ return JSONResponse(
169
+ {"runId": record.state.run_id, "status": record.state.status.value},
170
+ status_code=201 if created else 200,
171
+ )
172
+
173
+ @app.get("/v1/runs/{run_id}")
174
+ async def get_run(run_id: str) -> Response:
175
+ record = manager.get(run_id)
176
+ if record is None:
177
+ return _error("not_found", f"No run {run_id!r}.", 404)
178
+ return JSONResponse(run_json(record.state))
179
+
180
+ @app.delete("/v1/runs/{run_id}")
181
+ async def cancel_run(run_id: str) -> Response:
182
+ if not manager.cancel(run_id):
183
+ return _error("not_found", f"No run {run_id!r}.", 404)
184
+ return JSONResponse({"runId": run_id, "status": "cancelled"})
185
+
186
+ @app.post("/v1/assets")
187
+ async def upload_asset(request: Request) -> Response:
188
+ data = await request.body()
189
+ stored = assets.put(data, request.headers.get("content-type"))
190
+ return JSONResponse({"id": stored.id, "kind": stored.kind.value, "bytes": stored.size})
191
+
192
+ @app.get("/v1/takes/{take_id}")
193
+ async def get_take(take_id: str) -> Response:
194
+ take = manager.find_take(take_id)
195
+ if take is None:
196
+ return _error("not_found", f"No take {take_id!r}.", 404)
197
+ return JSONResponse(take_json(take))
198
+
199
+ @app.get("/v1/takes/{take_id}/bytes")
200
+ async def get_take_bytes(take_id: str) -> Response:
201
+ take = manager.find_take(take_id)
202
+ if take is None:
203
+ return _error("not_found", f"No take {take_id!r}.", 404)
204
+ path = Path(take.uri)
205
+ if not _within(takes_root, path) or not path.is_file():
206
+ return _error("not_found", "Take bytes are not available.", 404)
207
+ return FileResponse(path)
208
+
209
+ @app.websocket("/v1/runs/{run_id}/events")
210
+ async def run_events(websocket: WebSocket, run_id: str) -> None:
211
+ record = manager.get(run_id)
212
+ if record is None:
213
+ await websocket.close(code=4404)
214
+ return
215
+ await websocket.accept()
216
+ queue: asyncio.Queue[Any] = asyncio.Queue()
217
+ record.subscribers.add(queue)
218
+ try:
219
+ await websocket.send_json(
220
+ {"type": "snapshot", "runId": run_id, "state": run_json(record.state)}
221
+ )
222
+ if record.done:
223
+ return
224
+ while True:
225
+ event = await queue.get()
226
+ if event is None:
227
+ break
228
+ await websocket.send_json(event_json(event))
229
+ finally:
230
+ record.subscribers.discard(queue)
231
+
232
+ # The Studio app-backend bridge (strangler-fig): the SPA posts InlineStudioApi calls here.
233
+ # Native handlers answer ported channels; the rest proxy to the legacy Node backend (rpc.py).
234
+ @app.post("/rpc")
235
+ async def rpc_dispatch(request: Request) -> Response:
236
+ body = await request.json()
237
+ channel = body.get("channel")
238
+ args = body.get("args") or []
239
+ if not isinstance(channel, str):
240
+ return JSONResponse({"ok": False, "error": "Missing 'channel'."})
241
+ if not isinstance(args, list):
242
+ return JSONResponse({"ok": False, "error": "'args' must be a list."})
243
+ return JSONResponse(await rpc.dispatch(channel, args))
244
+
245
+ @app.websocket("/events")
246
+ async def studio_events(websocket: WebSocket) -> None:
247
+ await websocket.accept()
248
+ queue = events.add()
249
+ try:
250
+ while True:
251
+ await websocket.send_json(await queue.get())
252
+ except WebSocketDisconnect:
253
+ pass
254
+ finally:
255
+ events.remove(queue)
256
+
257
+ # The native Studio app-backend: register the InlineStudioApi channels on the RpcRouter +
258
+ # project media/uploads (the B1 flip — Core becomes the sole backend, no Node proxy).
259
+ if studio_store is not None:
260
+ from ..studio.fal import FalGeneration
261
+ from ..studio.generation import CoreGeneration
262
+ from ..studio.handlers import register_studio_handlers
263
+ from ..studio.models import ModelDownloads
264
+ from ..studio.timeline.render import Timeline
265
+
266
+ def core_models() -> dict[str, Any]:
267
+ return {
268
+ "registryVersion": _version(registry, catalog),
269
+ "models": [descriptor_json(d, catalog) for d in registry.descriptors()],
270
+ }
271
+
272
+ def core_status() -> dict[str, Any]:
273
+ return {"running": True, "url": ""}
274
+
275
+ register_studio_handlers(
276
+ rpc,
277
+ studio_store,
278
+ core_models=core_models,
279
+ core_status=core_status,
280
+ generation=CoreGeneration(studio_store, manager, events),
281
+ fal_generation=FalGeneration(studio_store, events),
282
+ timeline=Timeline(studio_store, events),
283
+ # Explicit model downloads write into models/; rescan so new files bump the registry.
284
+ model_downloads=ModelDownloads(events, on_change=catalog.rescan),
285
+ )
286
+
287
+ @app.get("/media/{media_path:path}")
288
+ async def media(media_path: str, request: Request) -> Response:
289
+ try:
290
+ root = studio_store.folder().resolve()
291
+ except RuntimeError:
292
+ return Response("No project open", status_code=404)
293
+ rel = unquote(media_path).lstrip("/")
294
+ target = (root / rel).resolve()
295
+ if target != root and root not in target.parents:
296
+ return Response("Forbidden", status_code=403)
297
+ if not target.is_file():
298
+ return Response("Not found", status_code=404)
299
+ return FileResponse(target) # Range-aware; Content-Type guessed from the extension
300
+
301
+ @app.post("/upload")
302
+ async def upload(request: Request) -> Response:
303
+ from ..studio import assets as ax
304
+
305
+ name = basename(request.query_params.get("name") or "upload") or "upload"
306
+ folder_id = request.query_params.get("folderId") or None
307
+ body = await request.body()
308
+ try:
309
+ with tempfile.TemporaryDirectory() as tmp:
310
+ path = Path(tmp) / name
311
+ path.write_bytes(body)
312
+ asset = ax.import_file(
313
+ studio_store.conn(), studio_store.folder(), str(path), folder_id
314
+ )
315
+ if asset is not None:
316
+ events.broadcast("events:libraryChanged", None)
317
+ return JSONResponse({"ok": True, "value": asset})
318
+ except Exception as error: # noqa: BLE001
319
+ return JSONResponse({"ok": False, "error": str(error)})
320
+
321
+ # Serve the Inline Studio SPA on this same port when a frontend is available. Mounted LAST so
322
+ # every /v1 and /rpc route above still wins; StaticFiles(html=True) serves index.html at "/" and
323
+ # the hashed assets, giving the one-port experience (mirrors ComfyUI's frontend package).
324
+ if frontend_root and (Path(frontend_root) / "index.html").is_file():
325
+ app.mount("/", StaticFiles(directory=frontend_root, html=True), name="frontend")
326
+
327
+ return app
@@ -0,0 +1,47 @@
1
+ """Content-addressed input store: id = sha256 of the bytes, so re-uploading a file dedupes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ..media import MediaKind
10
+
11
+ _KIND_BY_PREFIX = {
12
+ "image/": MediaKind.IMAGE,
13
+ "video/": MediaKind.VIDEO,
14
+ "audio/": MediaKind.AUDIO,
15
+ }
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class StoredAsset:
20
+ id: str
21
+ kind: MediaKind
22
+ size: int
23
+
24
+
25
+ class AssetStore:
26
+ def __init__(self, root: Path) -> None:
27
+ self._root = root
28
+
29
+ def put(self, data: bytes, content_type: str | None) -> StoredAsset:
30
+ self._root.mkdir(parents=True, exist_ok=True)
31
+ asset_id = f"sha256-{hashlib.sha256(data).hexdigest()}"
32
+ path = self._root / asset_id
33
+ if not path.exists():
34
+ path.write_bytes(data)
35
+ return StoredAsset(id=asset_id, kind=_kind_for(content_type), size=len(data))
36
+
37
+ def path(self, asset_id: str) -> Path | None:
38
+ candidate = self._root / asset_id
39
+ return candidate if candidate.exists() else None
40
+
41
+
42
+ def _kind_for(content_type: str | None) -> MediaKind:
43
+ if content_type:
44
+ for prefix, kind in _KIND_BY_PREFIX.items():
45
+ if content_type.startswith(prefix):
46
+ return kind
47
+ return MediaKind.IMAGE
@@ -0,0 +1,21 @@
1
+ """Best-effort model registration. A model whose deps are absent is skipped; the engine still serves
2
+ source nodes and the rest of the API, so a torch-less install boots cleanly.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from ..device.policy import DevicePolicy
8
+ from ..graph.registry import Registry
9
+ from ..runtime.store import TakeStore
10
+
11
+
12
+ def register_models(registry: Registry, store: TakeStore, policy: DevicePolicy) -> list[str]:
13
+ registered: list[str] = []
14
+ try:
15
+ from ..models.zimage.runner import register_zimage
16
+
17
+ register_zimage(registry, store, policy)
18
+ registered.append("alibaba/z-image-turbo")
19
+ except ImportError:
20
+ pass
21
+ return registered
@@ -0,0 +1,37 @@
1
+ """Locate the Inline Studio frontend Core serves on its own port (mirrors ComfyUI's frontend pkg).
2
+
3
+ Resolution order, most specific first:
4
+ 1. ``INLINE_FRONTEND_ROOT`` — a local SPA build dir (set directly or via ``main.py
5
+ --front-end-root``); the dev loop — rebuild the UI locally without republishing the package.
6
+ 2. the installed ``inline_studio_frontend`` package's ``static/`` dir — the default for end users
7
+ (``pip install`` pulls the built frontend; no Node needed).
8
+ 3. ``None`` — Core runs API-only (no UI mounted).
9
+
10
+ A dir only counts when it actually holds an ``index.html``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from pathlib import Path
17
+
18
+
19
+ def _has_index(path: Path) -> bool:
20
+ return (path / "index.html").is_file()
21
+
22
+
23
+ def resolve_frontend_root() -> str | None:
24
+ env = os.environ.get("INLINE_FRONTEND_ROOT", "").strip()
25
+ if env:
26
+ root = Path(env)
27
+ return str(root) if _has_index(root) else None
28
+
29
+ try:
30
+ import inline_studio_frontend # type: ignore[import-not-found]
31
+ except ModuleNotFoundError:
32
+ return None
33
+ pkg_file = getattr(inline_studio_frontend, "__file__", None)
34
+ if not pkg_file:
35
+ return None
36
+ static = Path(pkg_file).parent / "static"
37
+ return str(static) if _has_index(static) else None
@@ -0,0 +1,195 @@
1
+ """The run manager: validate, queue, execute on a worker thread, fan out events to subscribers.
2
+
3
+ The durable RunState is authoritative (GET /v1/runs). The websocket stream is a fan-out on top; the
4
+ state is updated on the worker thread (via the executor's StateTrackingEmitter) before each publish.
5
+ A RunStore persists runs so they survive a restart; progress events are coalesced to the stream.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import hashlib
12
+ import json
13
+ import threading
14
+ import time
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from uuid import uuid4
17
+
18
+ from ..device.memory import MemoryPolicy
19
+ from ..device.policy import DevicePolicy
20
+ from ..errors import InlineCoreError
21
+ from ..graph.cache import NodeCache
22
+ from ..graph.executor import Executor
23
+ from ..graph.registry import Registry
24
+ from ..graph.schema import Graph
25
+ from ..graph.topo import topo_sort, upstream_closure
26
+ from ..graph.validate import validate
27
+ from ..runtime.context import CancelToken, ExecutionContext
28
+ from ..runtime.progress import ProgressEmitter, ProgressEvent, RunEvent
29
+ from ..runtime.run import NodeRuntimeState, RunState
30
+ from ..takes import Take
31
+ from .run_store import RunStore
32
+
33
+ # ~10 progress events per second per run to the stream (contract section 6). The snapshot stays
34
+ # authoritative, so dropping intermediate ticks only affects stream chattiness.
35
+ _MIN_PROGRESS_INTERVAL = 0.1
36
+
37
+
38
+ class RunConflict(InlineCoreError):
39
+ """A clientRunId was reused with a different graph."""
40
+
41
+
42
+ class RunRecord:
43
+ def __init__(self, state: RunState, cancel: CancelToken) -> None:
44
+ self.state = state
45
+ self.cancel = cancel
46
+ self.subscribers: set[asyncio.Queue[RunEvent | None]] = set()
47
+ self.done = False
48
+ self.last_progress = 0.0
49
+
50
+
51
+ class _BroadcastEmitter(ProgressEmitter):
52
+ def __init__(self, manager: RunManager, record: RunRecord) -> None:
53
+ self._manager = manager
54
+ self._record = record
55
+
56
+ def emit(self, event: RunEvent) -> None:
57
+ self._manager.publish(self._record, event)
58
+
59
+
60
+ class RunManager:
61
+ def __init__(
62
+ self,
63
+ registry: Registry,
64
+ cache: NodeCache,
65
+ policy: DevicePolicy | None = None,
66
+ workers: int = 1,
67
+ store: RunStore | None = None,
68
+ ) -> None:
69
+ self._registry = registry
70
+ self._cache = cache
71
+ self._policy = policy or MemoryPolicy()
72
+ self._pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="run")
73
+ self._runs: dict[str, RunRecord] = {}
74
+ self._by_client: dict[str, str] = {}
75
+ self._graph_hash: dict[str, str] = {}
76
+ self._lock = threading.Lock()
77
+ self._loop: asyncio.AbstractEventLoop | None = None
78
+ self._store = store
79
+ if store is not None:
80
+ store.interrupt_stale()
81
+
82
+ def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
83
+ self._loop = loop
84
+
85
+ def shutdown(self) -> None:
86
+ for record in list(self._runs.values()):
87
+ record.cancel.cancel()
88
+ self._pool.shutdown(wait=False)
89
+
90
+ def submit(
91
+ self, graph: Graph, target: str, client_run_id: str | None = None
92
+ ) -> tuple[RunRecord, bool]:
93
+ validate(graph, target, self._registry)
94
+ graph_hash = _graph_hash(graph, target)
95
+ if client_run_id is not None and client_run_id in self._by_client:
96
+ existing = self._runs[self._by_client[client_run_id]]
97
+ if self._graph_hash.get(existing.state.run_id) == graph_hash:
98
+ return existing, False
99
+ raise RunConflict(f"clientRunId {client_run_id!r} was reused with a different graph.")
100
+ run_id = f"run_{uuid4().hex[:12]}"
101
+ state = RunState(run_id=run_id, target=target)
102
+ for node_id in topo_sort(
103
+ list(upstream_closure(target, graph.input_sources)), graph.input_sources
104
+ ):
105
+ state.nodes[node_id] = NodeRuntimeState()
106
+ record = RunRecord(state, CancelToken())
107
+ with self._lock:
108
+ self._runs[run_id] = record
109
+ if client_run_id is not None:
110
+ self._by_client[client_run_id] = run_id
111
+ self._graph_hash[run_id] = graph_hash
112
+ if self._store is not None:
113
+ self._store.create(state, client_run_id)
114
+ self._pool.submit(self._execute, graph, target, record)
115
+ return record, True
116
+
117
+ def get(self, run_id: str) -> RunRecord | None:
118
+ record = self._runs.get(run_id)
119
+ if record is not None:
120
+ return record
121
+ if self._store is not None:
122
+ state = self._store.load(run_id)
123
+ if state is not None:
124
+ return _historical(state)
125
+ return None
126
+
127
+ def cancel(self, run_id: str) -> bool:
128
+ record = self._runs.get(run_id)
129
+ if record is None:
130
+ return False
131
+ record.cancel.cancel()
132
+ return True
133
+
134
+ def find_take(self, take_id: str) -> Take | None:
135
+ for record in self._runs.values():
136
+ for take in record.state.takes:
137
+ if take.id == take_id:
138
+ return take
139
+ if self._store is not None:
140
+ return self._store.find_take(take_id)
141
+ return None
142
+
143
+ def publish(self, record: RunRecord, event: RunEvent | None) -> None:
144
+ loop = self._loop
145
+ if loop is None:
146
+ return
147
+ if not self._should_stream(record, event, time.monotonic()):
148
+ return
149
+ for queue in list(record.subscribers):
150
+ loop.call_soon_threadsafe(queue.put_nowait, event)
151
+
152
+ def _should_stream(self, record: RunRecord, event: RunEvent | None, now: float) -> bool:
153
+ """Coalesce progress ticks; node_done, terminal events, and the finish sentinel pass."""
154
+ if isinstance(event, ProgressEvent):
155
+ if now - record.last_progress < _MIN_PROGRESS_INTERVAL:
156
+ return False
157
+ record.last_progress = now
158
+ return True
159
+
160
+ def _execute(self, graph: Graph, target: str, record: RunRecord) -> None:
161
+ ctx = ExecutionContext(
162
+ run_id=record.state.run_id,
163
+ policy=self._policy,
164
+ emitter=_BroadcastEmitter(self, record),
165
+ cancel=record.cancel,
166
+ )
167
+ Executor(self._registry, self._cache).run(graph, target, ctx, record.state)
168
+ record.done = True
169
+ if self._store is not None:
170
+ self._store.update(record.state)
171
+ self.publish(record, None)
172
+
173
+
174
+ def _historical(state: RunState) -> RunRecord:
175
+ """A finished run reloaded from the store: snapshot only, no live subscribers."""
176
+ record = RunRecord(state, CancelToken())
177
+ record.done = True
178
+ return record
179
+
180
+
181
+ def _graph_hash(graph: Graph, target: str) -> str:
182
+ nodes = [
183
+ {
184
+ "id": node.id,
185
+ "type": node.type,
186
+ "params": node.params,
187
+ "inputs": {
188
+ port: [[e.from_node, e.output] for e in edges]
189
+ for port, edges in sorted(node.inputs.items())
190
+ },
191
+ }
192
+ for node in graph.nodes
193
+ ]
194
+ payload = json.dumps({"target": target, "nodes": nodes}, sort_keys=True, default=str)
195
+ return hashlib.sha256(payload.encode()).hexdigest()
@@ -0,0 +1,60 @@
1
+ """The Studio app-backend on Core: the browser SPA speaks the InlineStudioApi wire protocol —
2
+ ``POST /rpc`` with ``{channel, args}`` plus an ``/events`` WebSocket, returning the same ``Result``
3
+ envelope (``{"ok": true, "value": ...}`` / ``{"ok": false, "error": ...}``). Core is the sole
4
+ backend; every channel is handled natively (the former proxy to the legacy Node backend is gone).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from collections.abc import Awaitable, Callable
11
+ from typing import Any
12
+
13
+ #: A native channel handler: receives the positional args list, returns the value to wrap in Ok.
14
+ Handler = Callable[[list[Any]], Awaitable[Any]]
15
+
16
+
17
+ class RpcRouter:
18
+ """Routes an RPC channel to its native handler."""
19
+
20
+ def __init__(self) -> None:
21
+ self._handlers: dict[str, Handler] = {}
22
+
23
+ def register(self, channel: str, handler: Handler) -> None:
24
+ self._handlers[channel] = handler
25
+
26
+ def has(self, channel: str) -> bool:
27
+ return channel in self._handlers
28
+
29
+ async def dispatch(self, channel: str, args: list[Any]) -> dict[str, Any]:
30
+ handler = self._handlers.get(channel)
31
+ if handler is None:
32
+ return {"ok": False, "error": f"No handler registered for channel {channel!r}."}
33
+ try:
34
+ return {"ok": True, "value": await handler(args)}
35
+ except Exception as error: # noqa: BLE001 — Result envelope: errors never cross raw
36
+ return {"ok": False, "error": str(error)}
37
+
38
+
39
+ class EventBroadcaster:
40
+ """Fans server→client event frames out to every connected ``/events`` WebSocket subscriber.
41
+
42
+ Native handlers push ``{"channel": ..., "payload": ...}`` frames here (matching the Node
43
+ broadcaster's shape); each open socket drains its own queue. Bridging the legacy Node event
44
+ stream is wired in with the fal domain (Part B4)."""
45
+
46
+ def __init__(self) -> None:
47
+ self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
48
+
49
+ def add(self) -> asyncio.Queue[dict[str, Any]]:
50
+ queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
51
+ self._subscribers.add(queue)
52
+ return queue
53
+
54
+ def remove(self, queue: asyncio.Queue[dict[str, Any]]) -> None:
55
+ self._subscribers.discard(queue)
56
+
57
+ def broadcast(self, channel: str, payload: Any) -> None:
58
+ frame = {"channel": channel, "payload": payload}
59
+ for queue in self._subscribers:
60
+ queue.put_nowait(frame)