diffusers-workflow 0.4.0a3__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 (171) hide show
  1. diffusers_workflow-0.4.0a3.dist-info/METADATA +310 -0
  2. diffusers_workflow-0.4.0a3.dist-info/RECORD +171 -0
  3. diffusers_workflow-0.4.0a3.dist-info/WHEEL +5 -0
  4. diffusers_workflow-0.4.0a3.dist-info/entry_points.txt +6 -0
  5. diffusers_workflow-0.4.0a3.dist-info/licenses/LICENSE +201 -0
  6. diffusers_workflow-0.4.0a3.dist-info/top_level.txt +1 -0
  7. dw/__init__.py +353 -0
  8. dw/arguments.py +906 -0
  9. dw/cache_blocks.json +16 -0
  10. dw/cache_blocks.py +145 -0
  11. dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
  12. dw/events.py +78 -0
  13. dw/hub_cache.py +289 -0
  14. dw/introspection.py +458 -0
  15. dw/log_setup.py +45 -0
  16. dw/pipeline_processors/chain.py +750 -0
  17. dw/pipeline_processors/config_objects.py +235 -0
  18. dw/pipeline_processors/pipeline.py +1687 -0
  19. dw/pipeline_processors/remote.py +18 -0
  20. dw/previous_results.py +259 -0
  21. dw/prompt_weighting.py +378 -0
  22. dw/repl.py +298 -0
  23. dw/repl_commands.py +808 -0
  24. dw/repl_worker.py +129 -0
  25. dw/result.py +850 -0
  26. dw/run.py +92 -0
  27. dw/schema.py +24 -0
  28. dw/security.py +379 -0
  29. dw/serve.py +70 -0
  30. dw/server/__init__.py +2 -0
  31. dw/server/app.py +588 -0
  32. dw/server/jobs.py +547 -0
  33. dw/server/ui/assets/abap-08VXUWAP.js +1 -0
  34. dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
  35. dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
  36. dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
  37. dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
  38. dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
  39. dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
  40. dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
  41. dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
  42. dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
  43. dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
  44. dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
  45. dw/server/ui/assets/css-DIMkf-bt.js +3 -0
  46. dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
  47. dw/server/ui/assets/cssMode-CEh6hWi2.js +1 -0
  48. dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
  49. dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
  50. dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
  51. dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
  52. dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
  53. dw/server/ui/assets/editor.api-CExg3_mM.js +847 -0
  54. dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
  55. dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
  56. dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
  57. dw/server/ui/assets/freemarker2-DH6orYh2.js +3 -0
  58. dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
  59. dw/server/ui/assets/go-C-y9NEjX.js +1 -0
  60. dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
  61. dw/server/ui/assets/handlebars-CbrMVW4Q.js +1 -0
  62. dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
  63. dw/server/ui/assets/html-YDNPZw2M.js +1 -0
  64. dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
  65. dw/server/ui/assets/htmlMode-B_zSGWO2.js +1 -0
  66. dw/server/ui/assets/index-B7-VcYS-.css +1 -0
  67. dw/server/ui/assets/index-D_EiPU3b.js +13 -0
  68. dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
  69. dw/server/ui/assets/java-BEtHBSE6.js +1 -0
  70. dw/server/ui/assets/javascript-dYuBvioq.js +1 -0
  71. dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
  72. dw/server/ui/assets/jsonMode-CUqLM39V.js +7 -0
  73. dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
  74. dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
  75. dw/server/ui/assets/less-B9JPFI3C.js +2 -0
  76. dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
  77. dw/server/ui/assets/liquid-D6vxBzMv.js +1 -0
  78. dw/server/ui/assets/lspLanguageFeatures-1WJ2palX.js +4 -0
  79. dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
  80. dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
  81. dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
  82. dw/server/ui/assets/mdx-SHQb6vmD.js +1 -0
  83. dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
  84. dw/server/ui/assets/monaco--ixms01u.css +1 -0
  85. dw/server/ui/assets/monaco-CP-s5rcP.js +56 -0
  86. dw/server/ui/assets/msdax-DauUninz.js +1 -0
  87. dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
  88. dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
  89. dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
  90. dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
  91. dw/server/ui/assets/perl-oz_6vUea.js +1 -0
  92. dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
  93. dw/server/ui/assets/php-nr791fC2.js +1 -0
  94. dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
  95. dw/server/ui/assets/postiats-43DmfD33.js +1 -0
  96. dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
  97. dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
  98. dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
  99. dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
  100. dw/server/ui/assets/python-x0_EGHq9.js +1 -0
  101. dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
  102. dw/server/ui/assets/r-BwWrilGY.js +1 -0
  103. dw/server/ui/assets/razor-BZC4LQDP.js +1 -0
  104. dw/server/ui/assets/redis-ClamHrr6.js +1 -0
  105. dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
  106. dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
  107. dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
  108. dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
  109. dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
  110. dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
  111. dw/server/ui/assets/scheme-BeGwcela.js +1 -0
  112. dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
  113. dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
  114. dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
  115. dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
  116. dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
  117. dw/server/ui/assets/sql-NEE52Syq.js +1 -0
  118. dw/server/ui/assets/st-DbInun42.js +1 -0
  119. dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
  120. dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
  121. dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
  122. dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
  123. dw/server/ui/assets/tsMode-BTfA6SbD.js +11 -0
  124. dw/server/ui/assets/twig-De2hgUGE.js +1 -0
  125. dw/server/ui/assets/typescript-CWA4MsNk.js +1 -0
  126. dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
  127. dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
  128. dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
  129. dw/server/ui/assets/workers-CWU0uvj5.js +1 -0
  130. dw/server/ui/assets/xml-KmfTm3rg.js +1 -0
  131. dw/server/ui/assets/yaml-nFO_dDS6.js +1 -0
  132. dw/server/ui/index.html +17 -0
  133. dw/settings.py +77 -0
  134. dw/step.py +132 -0
  135. dw/tasks/audio_utils.py +266 -0
  136. dw/tasks/background_remover.py +43 -0
  137. dw/tasks/borders.py +113 -0
  138. dw/tasks/concat_videos.py +80 -0
  139. dw/tasks/depth_estimator.py +54 -0
  140. dw/tasks/diffusion_upscale.py +109 -0
  141. dw/tasks/format_messages.py +24 -0
  142. dw/tasks/gather.py +139 -0
  143. dw/tasks/image_to_text.py +43 -0
  144. dw/tasks/image_utils.py +661 -0
  145. dw/tasks/interpolate_frames.py +227 -0
  146. dw/tasks/model_cache.py +39 -0
  147. dw/tasks/pair_audio.py +58 -0
  148. dw/tasks/qr_code.py +19 -0
  149. dw/tasks/restore_faces.py +175 -0
  150. dw/tasks/rife_model.py +192 -0
  151. dw/tasks/segment.py +121 -0
  152. dw/tasks/task.py +474 -0
  153. dw/tasks/tensor_image.py +57 -0
  154. dw/tasks/text_generation.py +168 -0
  155. dw/tasks/text_sections.py +80 -0
  156. dw/tasks/upscale.py +203 -0
  157. dw/tasks/video_utils.py +154 -0
  158. dw/tasks/zoe_depth.py +71 -0
  159. dw/teacache.py +376 -0
  160. dw/teacache_models.json +99 -0
  161. dw/test.py +29 -0
  162. dw/type_helpers.py +68 -0
  163. dw/validate.py +43 -0
  164. dw/variables.py +153 -0
  165. dw/worker.py +517 -0
  166. dw/workflow.py +553 -0
  167. dw/workflow_schema.json +1157 -0
  168. dw/workflows/augment_prompt.json +65 -0
  169. dw/workflows/describe_image.json +58 -0
  170. dw/workflows/h3_context_ir.json +57 -0
  171. dw/workflows/test.json +31 -0
dw/server/app.py ADDED
@@ -0,0 +1,588 @@
1
+ """FastAPI application exposing the workflow engine.
2
+
3
+ All state lives in the JobManager; this module is routing, validation and
4
+ SSE framing. Everything path-shaped goes through dw.security validators.
5
+ Interactive API docs are served at /docs (OpenAPI at /openapi.json).
6
+ """
7
+
8
+ import os
9
+ import copy
10
+ import json
11
+ import asyncio
12
+ import logging
13
+ from contextlib import asynccontextmanager
14
+ from urllib.parse import urlparse
15
+ from typing import Any, Dict, Optional
16
+
17
+ from fastapi import FastAPI, HTTPException, Request
18
+ from fastapi.responses import StreamingResponse, JSONResponse
19
+ from fastapi.staticfiles import StaticFiles
20
+ from pydantic import BaseModel, Field
21
+
22
+ from ..security import validate_path, SecurityError
23
+ from ..introspection import (
24
+ describe_class,
25
+ list_classes,
26
+ list_pipelines,
27
+ describe_pipeline,
28
+ list_tasks,
29
+ describe_task,
30
+ workflow_argument_warnings,
31
+ )
32
+ from ..schema import load_schema
33
+ from ..workflow import Workflow, workflow_from_definition
34
+ from ..result import read_embedded_metadata
35
+ from ..hub_cache import scan_models, delete_model, DownloadManager
36
+ from .jobs import JobManager, TERMINAL_STATES
37
+
38
+ logger = logging.getLogger("dw")
39
+
40
+ # How long one SSE poll waits for a new event before checking liveness
41
+ SSE_POLL_SECONDS = 1.0
42
+
43
+
44
+ class JobRequest(BaseModel):
45
+ workflow_path: Optional[str] = Field(
46
+ default=None, description="Path to a workflow JSON file on the server"
47
+ )
48
+ workflow: Optional[Dict[str, Any]] = Field(
49
+ default=None, description="Inline workflow definition"
50
+ )
51
+ arguments: Dict[str, Any] = Field(
52
+ default_factory=dict, description="Workflow variable overrides"
53
+ )
54
+ base_dir: Optional[str] = Field(
55
+ default=None,
56
+ description="Directory relative paths in an inline workflow resolve against",
57
+ )
58
+
59
+
60
+ def workflow_names(workflow_dir):
61
+ """Workflow names under workflow_dir, as relative paths without .json."""
62
+ names = []
63
+ if not os.path.isdir(workflow_dir):
64
+ return names
65
+ for root, _dirs, files in os.walk(workflow_dir):
66
+ for file_name in files:
67
+ if file_name.endswith(".json"):
68
+ relative = os.path.relpath(os.path.join(root, file_name), workflow_dir)
69
+ names.append(relative[: -len(".json")])
70
+ return sorted(names)
71
+
72
+
73
+ # What each workflow produces and takes, for listing cards - cached by mtime
74
+ _workflow_detail_cache = {}
75
+
76
+
77
+ def workflow_details(workflow_dir, names):
78
+ """Per-workflow card metadata: output kinds and variable count."""
79
+ details = {}
80
+ for name in names:
81
+ path = os.path.join(workflow_dir, f"{name}.json")
82
+ try:
83
+ mtime = os.path.getmtime(path)
84
+ except OSError:
85
+ continue
86
+ cached = _workflow_detail_cache.get(path)
87
+ if cached and cached[0] == mtime:
88
+ details[name] = cached[1]
89
+ continue
90
+ try:
91
+ with open(path, "r") as file:
92
+ definition = json.load(file)
93
+ kinds = sorted(
94
+ {
95
+ step["result"]["content_type"].split("/")[0]
96
+ for step in definition.get("steps", [])
97
+ if isinstance(step.get("result"), dict)
98
+ and "content_type" in step["result"]
99
+ }
100
+ )
101
+ detail = {
102
+ "kinds": kinds,
103
+ "variables": len(definition.get("variables", {})),
104
+ "description": str(definition.get("description", "") or ""),
105
+ }
106
+ except Exception:
107
+ detail = {"kinds": [], "variables": 0, "description": ""}
108
+ _workflow_detail_cache[path] = (mtime, detail)
109
+ details[name] = detail
110
+ return details
111
+
112
+
113
+ def resolve_workflow_name(workflow_dir, name, allow_create=False):
114
+ """The on-disk path for a workflow name, confined to workflow_dir."""
115
+ if not name.endswith(".json"):
116
+ name = f"{name}.json"
117
+ try:
118
+ return validate_path(
119
+ os.path.join(workflow_dir, name), workflow_dir, allow_create=allow_create
120
+ )
121
+ except SecurityError as e:
122
+ raise HTTPException(status_code=404, detail=f"Unknown workflow: {e}")
123
+
124
+
125
+ def default_ui_dir():
126
+ """Where the built SPA lives: ui/dist in a checkout (the copy npm just
127
+ built), else the copy packaged into the wheel at dw/server/ui, else None."""
128
+ here = os.path.dirname(os.path.abspath(__file__))
129
+ candidates = [
130
+ os.path.join(os.path.dirname(os.path.dirname(here)), "ui", "dist"),
131
+ os.path.join(here, "ui"),
132
+ ]
133
+ for candidate in candidates:
134
+ if os.path.isfile(os.path.join(candidate, "index.html")):
135
+ return candidate
136
+ return None
137
+
138
+
139
+ def create_app(
140
+ workflow_dir="./examples",
141
+ output_dir="./outputs",
142
+ log_level="INFO",
143
+ job_manager=None,
144
+ ui_dir=None,
145
+ download_manager=None,
146
+ ):
147
+ """Build the application. A caller (tests) can inject a JobManager."""
148
+ manager = job_manager or JobManager(output_dir, log_level=log_level)
149
+
150
+ @asynccontextmanager
151
+ async def lifespan(app):
152
+ yield
153
+ manager.shutdown()
154
+
155
+ app = FastAPI(
156
+ title="diffusers-workflow",
157
+ description="Declarative diffusers workflows over HTTP: queue a job, "
158
+ "stream its progress, fetch what it saved.",
159
+ lifespan=lifespan,
160
+ )
161
+ app.state.job_manager = manager
162
+ app.state.workflow_dir = workflow_dir
163
+
164
+ @app.middleware("http")
165
+ async def reject_foreign_origins(request, call_next):
166
+ """Refuse browser cross-origin requests - a drive-by web page must
167
+ not be able to queue jobs on a localhost GPU server. Requests
168
+ without an Origin header (curl, scripts, same-origin GETs) pass."""
169
+ origin = request.headers.get("origin")
170
+ if origin:
171
+ host = urlparse(origin).hostname
172
+ if host not in ("localhost", "127.0.0.1", "::1"):
173
+ return JSONResponse(
174
+ status_code=403,
175
+ content={"detail": "Cross-origin requests are not allowed"},
176
+ )
177
+ return await call_next(request)
178
+
179
+ # ------------------------------------------------------------------ jobs
180
+
181
+ @app.post("/api/jobs", status_code=201)
182
+ def submit_job(request: JobRequest):
183
+ try:
184
+ job = manager.submit(
185
+ workflow_path=request.workflow_path,
186
+ workflow=request.workflow,
187
+ arguments=request.arguments,
188
+ base_dir=request.base_dir,
189
+ )
190
+ except Exception as e:
191
+ # workflow_from_file / validate / the security layer all raise for
192
+ # bad requests - every failure here is the client's fault
193
+ raise HTTPException(status_code=400, detail=str(e))
194
+ return job.detail()
195
+
196
+ @app.get("/api/jobs")
197
+ def list_jobs():
198
+ return {"jobs": manager.list()}
199
+
200
+ @app.get("/api/jobs/{job_id}")
201
+ def get_job(job_id: str):
202
+ job = manager.get(job_id)
203
+ if job is None:
204
+ raise HTTPException(status_code=404, detail="Unknown job")
205
+ # a historical job is already a detail dict; a live one renders itself
206
+ return job if isinstance(job, dict) else job.detail()
207
+
208
+ @app.post("/api/jobs/{job_id}/rerun", status_code=201)
209
+ def rerun_job(job_id: str):
210
+ """Queue a fresh job from a previous job's stored spec."""
211
+ try:
212
+ job = manager.rerun(job_id)
213
+ except Exception as e:
214
+ raise HTTPException(status_code=400, detail=str(e))
215
+ if job is None:
216
+ raise HTTPException(status_code=404, detail="Unknown job")
217
+ return job.detail()
218
+
219
+ class MoveRequest(BaseModel):
220
+ direction: str = Field(description="up, down, front, or back")
221
+
222
+ @app.post("/api/jobs/{job_id}/move")
223
+ def move_job(job_id: str, body: MoveRequest):
224
+ """Reorder a queued job. 409 once it is running or finished -
225
+ only the waiting portion of the queue can be rearranged."""
226
+ if manager.get(job_id) is None:
227
+ raise HTTPException(status_code=404, detail="Unknown job")
228
+ try:
229
+ order = manager.move(job_id, body.direction)
230
+ except ValueError as e:
231
+ raise HTTPException(status_code=400, detail=str(e))
232
+ if order is None:
233
+ raise HTTPException(
234
+ status_code=409, detail="Job is not queued - only queued jobs move"
235
+ )
236
+ return {"id": job_id, "queue": order}
237
+
238
+ @app.post("/api/jobs/{job_id}/cancel")
239
+ def cancel_job(job_id: str):
240
+ status = manager.cancel(job_id)
241
+ if status is None:
242
+ raise HTTPException(status_code=404, detail="Unknown job")
243
+ return {"id": job_id, "status": status}
244
+
245
+ @app.get("/api/jobs/{job_id}/events")
246
+ async def job_events(request: Request, job_id: str, after: int = -1):
247
+ """Server-sent events: every progress event from `after` (exclusive)
248
+ until the job reaches a terminal state. Reconnect with the last seen
249
+ seq (or let EventSource send Last-Event-ID) to resume without loss."""
250
+ job = manager.get(job_id)
251
+ if job is None:
252
+ raise HTTPException(status_code=404, detail="Unknown job")
253
+ if isinstance(job, dict):
254
+ # historical jobs carry no event log - an immediately-closed
255
+ # stream lets clients treat them uniformly
256
+ return StreamingResponse(iter(()), media_type="text/event-stream")
257
+
258
+ last_event_id = request.headers.get("last-event-id")
259
+ if last_event_id is not None:
260
+ try:
261
+ after = max(after, int(last_event_id))
262
+ except ValueError:
263
+ pass
264
+
265
+ async def stream():
266
+ last_seq = after
267
+ while True:
268
+ events = job.events_after(last_seq)
269
+ for event in events:
270
+ last_seq = event["seq"]
271
+ yield f"id: {event['seq']}\ndata: {json.dumps(event)}\n\n"
272
+ if job.status in TERMINAL_STATES and not job.events_after(last_seq):
273
+ return
274
+ await asyncio.to_thread(job.wait_for_event, last_seq, SSE_POLL_SECONDS)
275
+
276
+ return StreamingResponse(
277
+ stream(),
278
+ media_type="text/event-stream",
279
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
280
+ )
281
+
282
+ # ---------------------------------------------------------- introspection
283
+
284
+ @app.get("/api/pipelines")
285
+ def pipelines():
286
+ """Every pipeline class the installed diffusers exports."""
287
+ return {"pipelines": list_pipelines()}
288
+
289
+ @app.get("/api/pipelines/{name}")
290
+ def pipeline_description(name: str):
291
+ """A pipeline's __call__ argument schema, for form generation."""
292
+ try:
293
+ return describe_pipeline(name)
294
+ except ValueError as e:
295
+ raise HTTPException(status_code=404, detail=str(e))
296
+ except Exception as e:
297
+ # A pipeline whose import fails on this install (missing extra
298
+ # dependency) is absent, not a server error
299
+ raise HTTPException(status_code=404, detail=f"Could not load {name}: {e}")
300
+
301
+ @app.get("/api/tasks")
302
+ def tasks():
303
+ """Every task command a workflow's task step can name."""
304
+ return list_tasks()
305
+
306
+ @app.get("/api/tasks/{command}")
307
+ def get_task(command: str):
308
+ """A task command's argument schema - the registered implementation
309
+ function's real signature, in the same shape as a class description."""
310
+ try:
311
+ return describe_task(command)
312
+ except ValueError as e:
313
+ raise HTTPException(status_code=404, detail=str(e))
314
+
315
+ @app.get("/api/classes")
316
+ def classes(kind: str):
317
+ """Class names of one kind (pipelines, models, schedulers,
318
+ quantization) - the pickers' data source."""
319
+ try:
320
+ return {"kind": kind, "classes": list_classes(kind)}
321
+ except ValueError as e:
322
+ raise HTTPException(status_code=400, detail=str(e))
323
+
324
+ @app.get("/api/classes/{name:path}")
325
+ def class_description(name: str, target: str = "init"):
326
+ """A class's argument schema: target=call reads __call__, init reads
327
+ __init__, load reads from_pretrained plus the curated loading knobs."""
328
+ try:
329
+ return describe_class(name, target=target)
330
+ except ValueError as e:
331
+ raise HTTPException(status_code=404, detail=str(e))
332
+ except Exception as e:
333
+ raise HTTPException(status_code=404, detail=f"Could not load {name}: {e}")
334
+
335
+ @app.get("/api/schema")
336
+ def workflow_schema():
337
+ """The workflow JSON schema, for schema-aware JSON editing."""
338
+ return JSONResponse(load_schema("workflow"))
339
+
340
+ @app.post("/api/validate")
341
+ def validate_workflow(request: JobRequest):
342
+ """Schema-validate a workflow and check its pipeline arguments
343
+ against real signatures, without queuing anything."""
344
+ if request.workflow is None:
345
+ raise HTTPException(status_code=400, detail="Provide an inline workflow")
346
+ try:
347
+ candidate = workflow_from_definition(
348
+ copy.deepcopy(request.workflow), manager.output_dir, request.base_dir
349
+ )
350
+ except (SecurityError, Exception) as e:
351
+ raise HTTPException(status_code=400, detail=str(e))
352
+ try:
353
+ candidate.validate()
354
+ except Exception as e:
355
+ return {"valid": False, "error": str(e), "warnings": []}
356
+ return {
357
+ "valid": True,
358
+ "error": None,
359
+ "warnings": workflow_argument_warnings(request.workflow),
360
+ }
361
+
362
+ # ------------------------------------------------------------- workflows
363
+
364
+ @app.get("/api/workflows")
365
+ def list_workflows():
366
+ names = workflow_names(app.state.workflow_dir)
367
+ return {
368
+ "workflow_dir": app.state.workflow_dir,
369
+ "workflows": names,
370
+ "details": workflow_details(app.state.workflow_dir, names),
371
+ }
372
+
373
+ @app.put("/api/workflows/{name:path}")
374
+ def save_workflow(name: str, request: JobRequest):
375
+ """Write a workflow into the workflow directory. The definition must
376
+ be schema-valid - the editor validates before saving, and a save that
377
+ silently wrote a broken file would betray both."""
378
+ if request.workflow is None:
379
+ raise HTTPException(status_code=400, detail="Provide an inline workflow")
380
+ path = resolve_workflow_name(app.state.workflow_dir, name, allow_create=True)
381
+ candidate = Workflow(copy.deepcopy(request.workflow), manager.output_dir, path)
382
+ try:
383
+ candidate.validate()
384
+ except Exception as e:
385
+ raise HTTPException(status_code=400, detail=str(e))
386
+ os.makedirs(os.path.dirname(path), exist_ok=True)
387
+ with open(path, "w") as file:
388
+ json.dump(request.workflow, file, indent=2)
389
+ file.write("\n")
390
+ logger.info(f"Saved workflow {name} to {path}")
391
+ return {
392
+ "name": name,
393
+ "path": path,
394
+ "warnings": workflow_argument_warnings(request.workflow),
395
+ }
396
+
397
+ @app.delete("/api/workflows/{name:path}")
398
+ def delete_workflow(name: str):
399
+ """Remove a workflow file from the workflow directory."""
400
+ path = resolve_workflow_name(app.state.workflow_dir, name)
401
+ os.remove(path)
402
+ logger.info(f"Deleted workflow {name} ({path})")
403
+ return {"name": name, "deleted": True}
404
+
405
+ @app.get("/api/workflows/{name:path}")
406
+ def get_workflow(name: str):
407
+ path = resolve_workflow_name(app.state.workflow_dir, name)
408
+ try:
409
+ with open(path, "r") as file:
410
+ return JSONResponse(json.load(file))
411
+ except (OSError, json.JSONDecodeError) as e:
412
+ raise HTTPException(status_code=500, detail=f"Could not read workflow: {e}")
413
+
414
+ # --------------------------------------------------------------- gallery
415
+
416
+ # Built from the security layer's allowlists so a new format is added
417
+ # exactly once - the gallery had already drifted (.bmp, .mkv, .mov)
418
+ from ..security import (
419
+ ALLOWED_AUDIO_EXTENSIONS,
420
+ ALLOWED_IMAGE_EXTENSIONS,
421
+ ALLOWED_VIDEO_EXTENSIONS,
422
+ )
423
+
424
+ MEDIA_KINDS = {
425
+ **{ext: "image" for ext in ALLOWED_IMAGE_EXTENSIONS},
426
+ **{ext: "video" for ext in ALLOWED_VIDEO_EXTENSIONS},
427
+ **{ext: "audio" for ext in ALLOWED_AUDIO_EXTENSIONS},
428
+ }
429
+
430
+ def _output_file(name):
431
+ """A file inside the output directory, or a 404 - never outside it."""
432
+ try:
433
+ path = validate_path(
434
+ os.path.join(manager.output_dir, name),
435
+ manager.output_dir,
436
+ allow_create=False,
437
+ )
438
+ except SecurityError as e:
439
+ raise HTTPException(status_code=404, detail=f"Unknown file: {e}")
440
+ if not os.path.isfile(path):
441
+ raise HTTPException(status_code=404, detail="Unknown file")
442
+ return path
443
+
444
+ @app.get("/api/gallery")
445
+ def gallery(limit: int = 200):
446
+ """Media files in the output directory, newest first. Stateless by
447
+ design - the gallery survives server restarts because it reads the
448
+ directory, not job history."""
449
+ entries = []
450
+ try:
451
+ names = os.listdir(manager.output_dir)
452
+ except OSError:
453
+ names = []
454
+ for name in names:
455
+ extension = os.path.splitext(name)[1].lower()
456
+ kind = MEDIA_KINDS.get(extension)
457
+ if kind is None:
458
+ continue
459
+ path = os.path.join(manager.output_dir, name)
460
+ try:
461
+ stat = os.stat(path)
462
+ except OSError:
463
+ continue
464
+ entries.append(
465
+ {
466
+ "name": name,
467
+ "url": f"/outputs/{name}",
468
+ "kind": kind,
469
+ "size": stat.st_size,
470
+ "mtime": stat.st_mtime,
471
+ # File names look like '{workflow}-{step}.{i}-{j}.{k}.ext';
472
+ # the part before the first dot is a readable label and
473
+ # embedded metadata carries the precise identity
474
+ "label": name.split(".")[0],
475
+ }
476
+ )
477
+ entries.sort(key=lambda e: e["mtime"], reverse=True)
478
+ return {"files": entries[: max(0, limit)], "total": len(entries)}
479
+
480
+ @app.get("/api/gallery/{name}/metadata")
481
+ def gallery_metadata(name: str):
482
+ """Generation metadata embedded in a saved image ('workflow' inside
483
+ it is the full definition the editor can reopen), plus the job that
484
+ produced the file when history remembers one."""
485
+ path = _output_file(name)
486
+ metadata = read_embedded_metadata(path)
487
+ try:
488
+ job = manager.history.job_for_file(name)
489
+ except Exception:
490
+ job = None
491
+ return {"name": name, "metadata": metadata, "job": job}
492
+
493
+ @app.delete("/api/gallery/{name}")
494
+ def delete_output(name: str):
495
+ """Remove one file from the output directory."""
496
+ path = _output_file(name)
497
+ os.remove(path)
498
+ logger.info(f"Deleted output file {name}")
499
+ return {"name": name, "deleted": True}
500
+
501
+ # ----------------------------------------------------------------- models
502
+
503
+ @app.get("/api/models")
504
+ def get_models():
505
+ """What the Hugging Face hub cache holds, largest repo first."""
506
+ return scan_models()
507
+
508
+ downloads = download_manager or DownloadManager()
509
+
510
+ class DownloadRequest(BaseModel):
511
+ repo_id: str = Field(description="Hub repo to download, e.g. org/model")
512
+
513
+ @app.post("/api/models/download", status_code=202)
514
+ def start_download(body: DownloadRequest):
515
+ """Start a background snapshot download into the hub cache."""
516
+ try:
517
+ return downloads.start(body.repo_id)
518
+ except ValueError as e:
519
+ raise HTTPException(status_code=400, detail=str(e))
520
+
521
+ @app.get("/api/models/downloads")
522
+ def list_downloads():
523
+ return {"downloads": downloads.status_list()}
524
+
525
+ @app.post("/api/models/downloads/{download_id}/cancel")
526
+ def cancel_download(download_id: str):
527
+ """Request cancellation; takes effect at the next progress tick.
528
+ Partial files stay in the cache and resume on a retry."""
529
+ status = downloads.cancel(download_id)
530
+ if status is None:
531
+ raise HTTPException(status_code=404, detail="Unknown download")
532
+ return status
533
+
534
+ @app.delete("/api/models")
535
+ def delete_cached_model(repo: str):
536
+ """Delete every cached revision of one repo from the hub cache.
537
+
538
+ Refused while a job is running or queued: the worker may be reading
539
+ exactly the files a delete would remove out from under it."""
540
+ if manager.is_busy():
541
+ raise HTTPException(
542
+ status_code=409,
543
+ detail="A job is running or queued - deleting model files "
544
+ "out from under it would corrupt the run",
545
+ )
546
+ try:
547
+ freed = delete_model(repo)
548
+ except ValueError as e:
549
+ raise HTTPException(status_code=404, detail=str(e))
550
+ logger.info(f"Deleted {repo} from the hub cache ({freed} bytes)")
551
+ return {"repo_id": repo, "deleted": True, "freed": freed}
552
+
553
+ # --------------------------------------------------------- memory/health
554
+
555
+ @app.get("/api/memory")
556
+ def memory():
557
+ try:
558
+ return manager.memory_status()
559
+ except Exception as e:
560
+ raise HTTPException(status_code=503, detail=f"Worker unavailable: {e}")
561
+
562
+ @app.get("/api/health")
563
+ def health():
564
+ worker = manager.worker_manager
565
+ return {
566
+ "status": "ok",
567
+ "worker_alive": bool(
568
+ worker.worker_active
569
+ and worker.worker_process is not None
570
+ and worker.worker_process.is_alive()
571
+ ),
572
+ "current_job": manager._current_job_id,
573
+ "queued": sum(1 for j in manager.list() if j["status"] == "queued"),
574
+ }
575
+
576
+ # ---------------------------------------------------------------- outputs
577
+
578
+ app.mount("/outputs", StaticFiles(directory=manager.output_dir), name="outputs")
579
+
580
+ # ---------------------------------------------------------------- the UI
581
+
582
+ resolved_ui = ui_dir or default_ui_dir()
583
+ if resolved_ui:
584
+ # Mounted last so /api and /outputs keep precedence; html=True serves
585
+ # index.html at /, and the SPA routes by hash so no fallback is needed
586
+ app.mount("/", StaticFiles(directory=resolved_ui, html=True), name="ui")
587
+
588
+ return app