comfygit-studio 0.5.0__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.
@@ -0,0 +1,295 @@
1
+ """Embedded Studio runtime helpers for hosts that already run aiohttp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import aiohttp
12
+ from aiohttp import web
13
+ from comfygit_core import Environment
14
+
15
+ from .runtime import (
16
+ SERVE_STATE_KEY,
17
+ STUDIO_API_BASE_PATH_KEY,
18
+ STUDIO_STATIC_DIR_KEY,
19
+ ServeConfig,
20
+ ServeState,
21
+ cancel_run_handler,
22
+ contracts_handler,
23
+ favicon_handler,
24
+ gallery_delete_handler,
25
+ gallery_handler,
26
+ health_handler,
27
+ openapi_handler,
28
+ output_view_handler,
29
+ run_contract_handler,
30
+ runs_handler,
31
+ single_contract_handler,
32
+ single_run_handler,
33
+ studio_index_handler,
34
+ upload_prepare_handler,
35
+ upload_put_handler,
36
+ upload_status_handler,
37
+ worker_callback_handler,
38
+ )
39
+
40
+ _EMBEDDED_SESSION_KEY = web.AppKey("comfygit_studio_embedded_session", aiohttp.ClientSession)
41
+ _EMBEDDED_ENV_PATH_KEY = web.AppKey("comfygit_studio_embedded_env_path", str)
42
+ _EMBEDDED_URL_KEY = web.AppKey("comfygit_studio_embedded_url", str)
43
+ _EMBEDDED_COMFY_URL_KEY = web.AppKey("comfygit_studio_embedded_comfy_url", str)
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class EmbeddedStudioResult:
48
+ """Browser-facing metadata for an embedded Studio runtime."""
49
+
50
+ status: str
51
+ url: str | None
52
+ env_name: str
53
+ started: bool = False
54
+ reused: bool = False
55
+ comfy_url: str | None = None
56
+ mode: str = "embedded"
57
+
58
+ def to_dict(self) -> dict[str, Any]:
59
+ return {
60
+ "status": self.status,
61
+ "url": self.url,
62
+ "env_name": self.env_name,
63
+ "started": self.started,
64
+ "reused": self.reused,
65
+ "comfy_url": self.comfy_url,
66
+ "mode": self.mode,
67
+ }
68
+
69
+
70
+ def register_embedded_studio_routes(
71
+ app: web.Application,
72
+ *,
73
+ route_api_prefix: str,
74
+ route_ui_prefix: str,
75
+ public_api_base_path: str,
76
+ ) -> None:
77
+ """Register namespaced Studio UI and API routes on an existing app."""
78
+
79
+ configure_embedded_studio_app(app, public_api_base_path=public_api_base_path)
80
+ app.add_routes(
81
+ create_embedded_studio_routes(
82
+ route_api_prefix=route_api_prefix,
83
+ route_ui_prefix=route_ui_prefix,
84
+ )
85
+ )
86
+ app.on_cleanup.append(close_embedded_studio)
87
+
88
+
89
+ def configure_embedded_studio_app(
90
+ app: web.Application,
91
+ *,
92
+ public_api_base_path: str,
93
+ static_dir: Path | None = None,
94
+ ) -> None:
95
+ """Configure static assets and browser-facing API base path before startup."""
96
+
97
+ app[STUDIO_STATIC_DIR_KEY] = static_dir or _studio_static_dir()
98
+ app[STUDIO_API_BASE_PATH_KEY] = _normalize_public_prefix(public_api_base_path)
99
+
100
+
101
+ def create_embedded_studio_routes(
102
+ *,
103
+ route_api_prefix: str,
104
+ route_ui_prefix: str,
105
+ ) -> web.RouteTableDef:
106
+ """Create namespaced Studio routes for ComfyUI/Manager-style hosts."""
107
+
108
+ routes = web.RouteTableDef()
109
+ api_prefix = _normalize_route_prefix(route_api_prefix)
110
+ ui_prefix = _normalize_route_prefix(route_ui_prefix)
111
+
112
+ def configured(handler):
113
+ async def wrapped(request: web.Request) -> web.StreamResponse:
114
+ return await handler(request)
115
+
116
+ return wrapped
117
+
118
+ routes.get(_route_path(ui_prefix, "/"))(configured(studio_index_handler))
119
+ routes.get(ui_prefix)(configured(studio_index_handler))
120
+ routes.get(_route_path(ui_prefix, "/assets/{tail:.*}"))(configured(_studio_asset_handler))
121
+ routes.get(_route_path(ui_prefix, "/favicon.ico"))(configured(favicon_handler))
122
+ routes.get(_route_path(ui_prefix, "/{tail:.*}"))(configured(studio_index_handler))
123
+
124
+ api_routes = (
125
+ ("GET", "/openapi.json", openapi_handler),
126
+ ("GET", "/health", health_handler),
127
+ ("GET", "/contracts", contracts_handler),
128
+ ("GET", "/contracts/{workflow}/{contract}", single_contract_handler),
129
+ ("POST", "/uploads/prepare", upload_prepare_handler),
130
+ ("PUT", "/uploads/{upload_id}", upload_put_handler),
131
+ ("GET", "/uploads/{upload_id}/status", upload_status_handler),
132
+ ("GET", "/gallery", gallery_handler),
133
+ ("DELETE", "/gallery/{item_id}", gallery_delete_handler),
134
+ ("GET", "/runs", runs_handler),
135
+ ("GET", "/runs/{run_id}", single_run_handler),
136
+ ("POST", "/runs/{run_id}/cancel", cancel_run_handler),
137
+ ("POST", "/worker-callback/runs/{run_id}", worker_callback_handler),
138
+ ("POST", "/contracts/{workflow}/{contract}/run", run_contract_handler),
139
+ ("GET", "/outputs/view", output_view_handler),
140
+ )
141
+ for method, path, handler in api_routes:
142
+ routes.route(method, _route_path(api_prefix, path))(configured(handler))
143
+
144
+ return routes
145
+
146
+
147
+ async def open_embedded_studio(
148
+ app: web.Application,
149
+ env: Environment,
150
+ *,
151
+ public_scheme: str,
152
+ public_host: str,
153
+ public_ui_path: str,
154
+ comfy_url: str,
155
+ max_request_bytes: int = 256 * 1024 * 1024,
156
+ ) -> EmbeddedStudioResult:
157
+ """Create or reuse the embedded Studio runtime for an environment."""
158
+
159
+ env_path = str(Path(env.path).resolve())
160
+ url = _public_url(public_scheme, public_host, public_ui_path)
161
+ existing_env_path = app.get(_EMBEDDED_ENV_PATH_KEY)
162
+ existing_comfy_url = app.get(_EMBEDDED_COMFY_URL_KEY)
163
+ if existing_env_path == env_path and existing_comfy_url == comfy_url and SERVE_STATE_KEY in app:
164
+ app[_EMBEDDED_URL_KEY] = url
165
+ return EmbeddedStudioResult(
166
+ status="running",
167
+ url=url,
168
+ env_name=env.name,
169
+ reused=True,
170
+ comfy_url=comfy_url,
171
+ )
172
+
173
+ await _close_current_state(app)
174
+
175
+ session = aiohttp.ClientSession()
176
+ config = ServeConfig(
177
+ host="embedded",
178
+ port=0,
179
+ comfy_url=comfy_url,
180
+ max_request_bytes=max_request_bytes,
181
+ state="local",
182
+ gallery="private",
183
+ state_db=_embedded_state_db(env),
184
+ )
185
+ app[SERVE_STATE_KEY] = ServeState(env, config, session)
186
+ app[_EMBEDDED_SESSION_KEY] = session
187
+ app[_EMBEDDED_ENV_PATH_KEY] = env_path
188
+ app[_EMBEDDED_URL_KEY] = url
189
+ app[_EMBEDDED_COMFY_URL_KEY] = comfy_url
190
+
191
+ return EmbeddedStudioResult(
192
+ status="running",
193
+ url=url,
194
+ env_name=env.name,
195
+ started=True,
196
+ comfy_url=comfy_url,
197
+ )
198
+
199
+
200
+ def get_embedded_studio_status(
201
+ app: web.Application,
202
+ env: Environment,
203
+ *,
204
+ public_scheme: str,
205
+ public_host: str,
206
+ public_ui_path: str,
207
+ ) -> EmbeddedStudioResult:
208
+ env_path = str(Path(env.path).resolve())
209
+ if app.get(_EMBEDDED_ENV_PATH_KEY) == env_path and SERVE_STATE_KEY in app:
210
+ url = app.get(_EMBEDDED_URL_KEY) or _public_url(public_scheme, public_host, public_ui_path)
211
+ comfy_url = app.get(_EMBEDDED_COMFY_URL_KEY)
212
+ return EmbeddedStudioResult(
213
+ status="running",
214
+ url=url,
215
+ env_name=env.name,
216
+ reused=True,
217
+ comfy_url=comfy_url,
218
+ )
219
+ return EmbeddedStudioResult(status="stopped", url=None, env_name=env.name)
220
+
221
+
222
+ async def close_embedded_studio(app: web.Application) -> None:
223
+ await _close_current_state(app)
224
+
225
+
226
+ async def _close_current_state(app: web.Application) -> None:
227
+ state = app.get(SERVE_STATE_KEY)
228
+ tasks: list[asyncio.Task[Any]] = []
229
+ if isinstance(state, ServeState):
230
+ state.state_store.close()
231
+ tasks.extend(set(state.background_tasks))
232
+ tasks.extend(set(state.active_run_tasks.values()))
233
+ for task in tasks:
234
+ task.cancel()
235
+ session = app.get(_EMBEDDED_SESSION_KEY)
236
+ if isinstance(session, aiohttp.ClientSession):
237
+ await session.close()
238
+ if tasks:
239
+ await asyncio.gather(*tasks, return_exceptions=True)
240
+ app.pop(SERVE_STATE_KEY, None)
241
+ app.pop(_EMBEDDED_SESSION_KEY, None)
242
+ app.pop(_EMBEDDED_ENV_PATH_KEY, None)
243
+ app.pop(_EMBEDDED_URL_KEY, None)
244
+ app.pop(_EMBEDDED_COMFY_URL_KEY, None)
245
+
246
+
247
+ def _embedded_state_db(env: Environment) -> Path:
248
+ workspace_path = Path(env.workspace.path).resolve()
249
+ state_dir = workspace_path / ".metadata" / "studio"
250
+ return state_dir / f"{_safe_name(env.name)}.sqlite"
251
+
252
+
253
+ def _safe_name(value: str) -> str:
254
+ safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", value.strip()).strip("-")
255
+ return safe or "environment"
256
+
257
+
258
+ def _public_url(scheme: str, host: str, path: str) -> str:
259
+ normalized_path = "/" + str(path or "").strip("/")
260
+ return f"{scheme}://{host}{normalized_path}/"
261
+
262
+
263
+ async def _studio_asset_handler(request: web.Request) -> web.StreamResponse:
264
+ assets_root = (request.app[STUDIO_STATIC_DIR_KEY] / "assets").resolve()
265
+ requested = request.match_info.get("tail", "")
266
+ candidate = (assets_root / requested).resolve()
267
+ if not candidate.is_file() or assets_root not in candidate.parents:
268
+ return web.json_response({"error": "not_found"}, status=404)
269
+ return web.FileResponse(candidate)
270
+
271
+
272
+ def _studio_static_dir() -> Path:
273
+ from importlib import resources
274
+
275
+ return Path(str(resources.files("comfygit_studio").joinpath("static")))
276
+
277
+
278
+ def _normalize_route_prefix(prefix: str) -> str:
279
+ normalized = "/" + str(prefix or "").strip("/")
280
+ return "" if normalized == "/" else normalized
281
+
282
+
283
+ def _normalize_public_prefix(prefix: str) -> str:
284
+ normalized = "/" + str(prefix or "").strip("/")
285
+ return "" if normalized == "/" else normalized
286
+
287
+
288
+ def _route_path(prefix: str, path: str) -> str:
289
+ if not path.startswith("/"):
290
+ path = f"/{path}"
291
+ if not prefix:
292
+ return path
293
+ if path == "/":
294
+ return f"{prefix}/"
295
+ return f"{prefix}{path}"