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.
- diffusers_workflow-0.4.0a3.dist-info/METADATA +310 -0
- diffusers_workflow-0.4.0a3.dist-info/RECORD +171 -0
- diffusers_workflow-0.4.0a3.dist-info/WHEEL +5 -0
- diffusers_workflow-0.4.0a3.dist-info/entry_points.txt +6 -0
- diffusers_workflow-0.4.0a3.dist-info/licenses/LICENSE +201 -0
- diffusers_workflow-0.4.0a3.dist-info/top_level.txt +1 -0
- dw/__init__.py +353 -0
- dw/arguments.py +906 -0
- dw/cache_blocks.json +16 -0
- dw/cache_blocks.py +145 -0
- dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
- dw/events.py +78 -0
- dw/hub_cache.py +289 -0
- dw/introspection.py +458 -0
- dw/log_setup.py +45 -0
- dw/pipeline_processors/chain.py +750 -0
- dw/pipeline_processors/config_objects.py +235 -0
- dw/pipeline_processors/pipeline.py +1687 -0
- dw/pipeline_processors/remote.py +18 -0
- dw/previous_results.py +259 -0
- dw/prompt_weighting.py +378 -0
- dw/repl.py +298 -0
- dw/repl_commands.py +808 -0
- dw/repl_worker.py +129 -0
- dw/result.py +850 -0
- dw/run.py +92 -0
- dw/schema.py +24 -0
- dw/security.py +379 -0
- dw/serve.py +70 -0
- dw/server/__init__.py +2 -0
- dw/server/app.py +588 -0
- dw/server/jobs.py +547 -0
- dw/server/ui/assets/abap-08VXUWAP.js +1 -0
- dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
- dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
- dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
- dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
- dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
- dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
- dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
- dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
- dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
- dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
- dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
- dw/server/ui/assets/css-DIMkf-bt.js +3 -0
- dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
- dw/server/ui/assets/cssMode-CEh6hWi2.js +1 -0
- dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
- dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
- dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
- dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
- dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
- dw/server/ui/assets/editor.api-CExg3_mM.js +847 -0
- dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
- dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
- dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
- dw/server/ui/assets/freemarker2-DH6orYh2.js +3 -0
- dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
- dw/server/ui/assets/go-C-y9NEjX.js +1 -0
- dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
- dw/server/ui/assets/handlebars-CbrMVW4Q.js +1 -0
- dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
- dw/server/ui/assets/html-YDNPZw2M.js +1 -0
- dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
- dw/server/ui/assets/htmlMode-B_zSGWO2.js +1 -0
- dw/server/ui/assets/index-B7-VcYS-.css +1 -0
- dw/server/ui/assets/index-D_EiPU3b.js +13 -0
- dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
- dw/server/ui/assets/java-BEtHBSE6.js +1 -0
- dw/server/ui/assets/javascript-dYuBvioq.js +1 -0
- dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
- dw/server/ui/assets/jsonMode-CUqLM39V.js +7 -0
- dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
- dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
- dw/server/ui/assets/less-B9JPFI3C.js +2 -0
- dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
- dw/server/ui/assets/liquid-D6vxBzMv.js +1 -0
- dw/server/ui/assets/lspLanguageFeatures-1WJ2palX.js +4 -0
- dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
- dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
- dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
- dw/server/ui/assets/mdx-SHQb6vmD.js +1 -0
- dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
- dw/server/ui/assets/monaco--ixms01u.css +1 -0
- dw/server/ui/assets/monaco-CP-s5rcP.js +56 -0
- dw/server/ui/assets/msdax-DauUninz.js +1 -0
- dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
- dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
- dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
- dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
- dw/server/ui/assets/perl-oz_6vUea.js +1 -0
- dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
- dw/server/ui/assets/php-nr791fC2.js +1 -0
- dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
- dw/server/ui/assets/postiats-43DmfD33.js +1 -0
- dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
- dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
- dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
- dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
- dw/server/ui/assets/python-x0_EGHq9.js +1 -0
- dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
- dw/server/ui/assets/r-BwWrilGY.js +1 -0
- dw/server/ui/assets/razor-BZC4LQDP.js +1 -0
- dw/server/ui/assets/redis-ClamHrr6.js +1 -0
- dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
- dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
- dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
- dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
- dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
- dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
- dw/server/ui/assets/scheme-BeGwcela.js +1 -0
- dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
- dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
- dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
- dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
- dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
- dw/server/ui/assets/sql-NEE52Syq.js +1 -0
- dw/server/ui/assets/st-DbInun42.js +1 -0
- dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
- dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
- dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
- dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
- dw/server/ui/assets/tsMode-BTfA6SbD.js +11 -0
- dw/server/ui/assets/twig-De2hgUGE.js +1 -0
- dw/server/ui/assets/typescript-CWA4MsNk.js +1 -0
- dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
- dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
- dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
- dw/server/ui/assets/workers-CWU0uvj5.js +1 -0
- dw/server/ui/assets/xml-KmfTm3rg.js +1 -0
- dw/server/ui/assets/yaml-nFO_dDS6.js +1 -0
- dw/server/ui/index.html +17 -0
- dw/settings.py +77 -0
- dw/step.py +132 -0
- dw/tasks/audio_utils.py +266 -0
- dw/tasks/background_remover.py +43 -0
- dw/tasks/borders.py +113 -0
- dw/tasks/concat_videos.py +80 -0
- dw/tasks/depth_estimator.py +54 -0
- dw/tasks/diffusion_upscale.py +109 -0
- dw/tasks/format_messages.py +24 -0
- dw/tasks/gather.py +139 -0
- dw/tasks/image_to_text.py +43 -0
- dw/tasks/image_utils.py +661 -0
- dw/tasks/interpolate_frames.py +227 -0
- dw/tasks/model_cache.py +39 -0
- dw/tasks/pair_audio.py +58 -0
- dw/tasks/qr_code.py +19 -0
- dw/tasks/restore_faces.py +175 -0
- dw/tasks/rife_model.py +192 -0
- dw/tasks/segment.py +121 -0
- dw/tasks/task.py +474 -0
- dw/tasks/tensor_image.py +57 -0
- dw/tasks/text_generation.py +168 -0
- dw/tasks/text_sections.py +80 -0
- dw/tasks/upscale.py +203 -0
- dw/tasks/video_utils.py +154 -0
- dw/tasks/zoe_depth.py +71 -0
- dw/teacache.py +376 -0
- dw/teacache_models.json +99 -0
- dw/test.py +29 -0
- dw/type_helpers.py +68 -0
- dw/validate.py +43 -0
- dw/variables.py +153 -0
- dw/worker.py +517 -0
- dw/workflow.py +553 -0
- dw/workflow_schema.json +1157 -0
- dw/workflows/augment_prompt.json +65 -0
- dw/workflows/describe_image.json +58 -0
- dw/workflows/h3_context_ir.json +57 -0
- 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
|