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