diffusers-workflow 0.4.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.
- diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
- diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
- diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
- diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
- diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
- diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
- dw/__init__.py +440 -0
- dw/adapter_compatibility.py +226 -0
- dw/arguments.py +1231 -0
- dw/assessment_rules.py +159 -0
- dw/assets.py +130 -0
- dw/cache_blocks.json +16 -0
- dw/cache_blocks.py +146 -0
- dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
- dw/content_types.py +150 -0
- dw/dissolve_frame_errors.py +121 -0
- dw/docs/ACCELERATION.md +352 -0
- dw/docs/AGENT_LOOP.md +95 -0
- dw/docs/DEPENDENCIES.md +91 -0
- dw/docs/IP_ADAPTER.md +109 -0
- dw/docs/LORAS.md +131 -0
- dw/docs/MCP.md +517 -0
- dw/docs/PROMPT_WEIGHTING.md +78 -0
- dw/docs/QUANTIZATION.md +230 -0
- dw/docs/RECIPES_24GB.md +201 -0
- dw/docs/RELEASING.md +195 -0
- dw/docs/REMOTE.md +140 -0
- dw/docs/REPL_COMMANDS.md +121 -0
- dw/docs/REPL_WORKER_GUIDE.md +51 -0
- dw/docs/SECURITY.md +272 -0
- dw/docs/SECURITY_QUICKREF.md +112 -0
- dw/docs/SERVER.md +679 -0
- dw/docs/TASKS.md +1741 -0
- dw/docs/TESTING.md +71 -0
- dw/docs/WORKFLOW_GUIDE.md +2038 -0
- dw/docs/WORKSPACES.md +316 -0
- dw/download_watch.py +335 -0
- dw/elision.py +306 -0
- dw/events.py +275 -0
- dw/for_each.py +409 -0
- dw/host_memory.py +258 -0
- dw/host_memory_projection.py +230 -0
- dw/hub_cache.py +432 -0
- dw/introspection.py +1228 -0
- dw/kernel_availability.py +208 -0
- dw/locations.py +599 -0
- dw/log_setup.py +45 -0
- dw/loudness.py +82 -0
- dw/media_audio.py +217 -0
- dw/media_frames.py +367 -0
- dw/media_info.py +297 -0
- dw/pipeline_processors/chain.py +821 -0
- dw/pipeline_processors/config_objects.py +237 -0
- dw/pipeline_processors/pipeline.py +2297 -0
- dw/pipeline_processors/remote.py +46 -0
- dw/plan.py +920 -0
- dw/previous_results.py +411 -0
- dw/probe_paths.py +59 -0
- dw/prompt_schema.json +48 -0
- dw/prompt_weighting.py +378 -0
- dw/prompts.py +159 -0
- dw/realize.py +250 -0
- dw/reference_limits.py +215 -0
- dw/reference_names.py +125 -0
- dw/repl.py +338 -0
- dw/repl_commands.py +836 -0
- dw/repl_worker.py +159 -0
- dw/result.py +1720 -0
- dw/result_fps.py +82 -0
- dw/run.py +162 -0
- dw/runs.py +768 -0
- dw/scalar_result_validation.py +97 -0
- dw/schema.py +283 -0
- dw/security.py +1038 -0
- dw/select_validation.py +115 -0
- dw/serve.py +277 -0
- dw/server/__init__.py +2 -0
- dw/server/app.py +4586 -0
- dw/server/assess.py +132 -0
- dw/server/catalog_shape.py +487 -0
- dw/server/enhancers.py +129 -0
- dw/server/exports.py +480 -0
- dw/server/guides.py +257 -0
- dw/server/jobs.py +1561 -0
- dw/server/mcp_mount.py +95 -0
- dw/server/netinfo.py +124 -0
- dw/server/observed_cost.py +379 -0
- dw/server/sysinfo.py +71 -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-CPznxfY8.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-CpWcotrd.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-CXtRM8N4.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-N7x-6NMY.js +1 -0
- dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
- dw/server/ui/assets/html-PhsdjHSr.js +1 -0
- dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
- dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
- dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
- dw/server/ui/assets/index-DgrYhQd9.js +43 -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-BJqN9Qhv.js +1 -0
- dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
- dw/server/ui/assets/jsonMode-DbM4SWSv.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-BWr8lEc4.js +1 -0
- dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.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-DAdMi_0p.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-BGCeEqaw.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-Bcn70HdC.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-D1HmNnby.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-D6u0XmOW.js +11 -0
- dw/server/ui/assets/twig-De2hgUGE.js +1 -0
- dw/server/ui/assets/typescript-BU6v-LMV.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-Cn7cTUKr.js +1 -0
- dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
- dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
- dw/server/ui/index.html +17 -0
- dw/server/updater.py +192 -0
- dw/settings.py +98 -0
- dw/shot_span_preflight.py +116 -0
- dw/shots.py +359 -0
- dw/slice_preflight.py +148 -0
- dw/step.py +187 -0
- dw/step_cache.py +442 -0
- dw/subfolders.py +107 -0
- dw/task_domains.py +307 -0
- dw/tasks/assess.py +826 -0
- dw/tasks/audio_transcription.py +88 -0
- dw/tasks/audio_utils.py +1862 -0
- dw/tasks/background_remover.py +43 -0
- dw/tasks/borders.py +113 -0
- dw/tasks/compose_text.py +74 -0
- dw/tasks/concat_videos.py +300 -0
- dw/tasks/depth_estimator.py +54 -0
- dw/tasks/diffusion_upscale.py +109 -0
- dw/tasks/dissolve_videos.py +342 -0
- dw/tasks/format_messages.py +24 -0
- dw/tasks/gather.py +173 -0
- dw/tasks/grade.py +97 -0
- dw/tasks/image_to_text.py +43 -0
- dw/tasks/image_utils.py +764 -0
- dw/tasks/interpolate_frames.py +252 -0
- dw/tasks/judge.py +68 -0
- dw/tasks/model_cache.py +55 -0
- dw/tasks/pair_audio.py +268 -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/select.py +111 -0
- dw/tasks/speech_generation.py +228 -0
- dw/tasks/stabilize.py +129 -0
- dw/tasks/task.py +920 -0
- dw/tasks/tensor_image.py +57 -0
- dw/tasks/text_generation.py +169 -0
- dw/tasks/text_sections.py +80 -0
- dw/tasks/upscale.py +203 -0
- dw/tasks/video_utils.py +624 -0
- dw/tasks/zoe_depth.py +71 -0
- dw/teacache.py +381 -0
- dw/teacache_models.json +99 -0
- dw/test.py +29 -0
- dw/type_helpers.py +231 -0
- dw/validate.py +68 -0
- dw/variable_constraints.py +444 -0
- dw/variables.py +443 -0
- dw/video_extensions.py +141 -0
- dw/vram_estimate.py +116 -0
- dw/worker.py +764 -0
- dw/workflow.py +2007 -0
- dw/workflow_schema.json +1346 -0
- dw/workflow_sources.py +383 -0
- dw/workflows/h3_context_ir.json +57 -0
- dw/workflows/test.json +31 -0
- dw/workspace.py +730 -0
- dw_mcp/__init__.py +6 -0
- dw_mcp/__main__.py +133 -0
- dw_mcp/assets.py +336 -0
- dw_mcp/authoring.py +114 -0
- dw_mcp/catalog.py +360 -0
- dw_mcp/client.py +486 -0
- dw_mcp/diagnose.py +371 -0
- dw_mcp/exports.py +84 -0
- dw_mcp/guides.py +35 -0
- dw_mcp/media.py +638 -0
- dw_mcp/models.py +97 -0
- dw_mcp/prompts.py +104 -0
- dw_mcp/server.py +1343 -0
- dw_mcp/workspaces.py +212 -0
dw_mcp/server.py
ADDED
|
@@ -0,0 +1,1343 @@
|
|
|
1
|
+
"""Assemble the MCP tool surface over a DwClient.
|
|
2
|
+
|
|
3
|
+
The only module that imports the MCP SDK. Every tool body is a one-line
|
|
4
|
+
call into a handler, so the handlers stay testable without a session and
|
|
5
|
+
this file stays a description of the surface rather than logic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import functools
|
|
9
|
+
import inspect
|
|
10
|
+
from typing import Literal, Optional
|
|
11
|
+
|
|
12
|
+
from mcp.server.mcpserver import MCPServer
|
|
13
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
14
|
+
from mcp.types import AudioContent, ImageContent, TextContent, ToolAnnotations
|
|
15
|
+
|
|
16
|
+
from dw_mcp import (
|
|
17
|
+
assets,
|
|
18
|
+
authoring,
|
|
19
|
+
catalog,
|
|
20
|
+
diagnose,
|
|
21
|
+
exports,
|
|
22
|
+
guides,
|
|
23
|
+
media,
|
|
24
|
+
models,
|
|
25
|
+
prompts,
|
|
26
|
+
workspaces,
|
|
27
|
+
)
|
|
28
|
+
from dw_mcp.client import DwApiError
|
|
29
|
+
|
|
30
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)
|
|
31
|
+
WRITES = ToolAnnotations(read_only_hint=False, open_world_hint=False)
|
|
32
|
+
OVERWRITES = ToolAnnotations(
|
|
33
|
+
read_only_hint=False,
|
|
34
|
+
destructive_hint=True,
|
|
35
|
+
idempotent_hint=True,
|
|
36
|
+
open_world_hint=False,
|
|
37
|
+
)
|
|
38
|
+
DELETES = ToolAnnotations(
|
|
39
|
+
read_only_hint=False,
|
|
40
|
+
destructive_hint=True,
|
|
41
|
+
idempotent_hint=True,
|
|
42
|
+
open_world_hint=False,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _anticipated(fn):
|
|
47
|
+
"""Let a DwApiError's message reach the model.
|
|
48
|
+
|
|
49
|
+
A DwApiError is a failure the handlers saw coming and wrote a message
|
|
50
|
+
for. Anything but a ToolError escaping a tool is treated by the SDK as a
|
|
51
|
+
crash: the message is replaced with "Error executing tool <name>" and a
|
|
52
|
+
traceback is logged. Re-raising as ToolError keeps the text.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@functools.wraps(fn)
|
|
56
|
+
def wrapper(*args, **kwargs):
|
|
57
|
+
try:
|
|
58
|
+
return fn(*args, **kwargs)
|
|
59
|
+
except DwApiError as e:
|
|
60
|
+
raise ToolError(str(e)) from e
|
|
61
|
+
|
|
62
|
+
return wrapper
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_server(client):
|
|
66
|
+
"""An MCP server whose tools all run against `client`."""
|
|
67
|
+
server = MCPServer(
|
|
68
|
+
"diffusers-workflow",
|
|
69
|
+
instructions=(
|
|
70
|
+
"Generate images, video and audio on a real GPU: author, run "
|
|
71
|
+
"and diagnose diffusers-workflow jobs against a running "
|
|
72
|
+
"dw.serve. A workflow is a JSON document of named steps, each a "
|
|
73
|
+
"diffusers pipeline or a utility task; the engine runs one job "
|
|
74
|
+
"at a time.\n"
|
|
75
|
+
"\n"
|
|
76
|
+
"Start from `list_workflows(shape=...)` and run what the catalog "
|
|
77
|
+
"already holds, with `arguments` overriding its variables. "
|
|
78
|
+
"Shapes: image, image-set, image-edit, shot, sequence, audio, "
|
|
79
|
+
"text, utility. Traits: has-audio, chained, image-conditioned, "
|
|
80
|
+
"identity-referenced, needs-input-media, composes-workflows. An "
|
|
81
|
+
"open-ended request names a subject, not a shape - decide the "
|
|
82
|
+
"deliverable's shape first. `list_guides` indexes the docs by "
|
|
83
|
+
"section and `list_tasks` is what a shape is composed from; "
|
|
84
|
+
"author new JSON only when neither the catalog nor a "
|
|
85
|
+
"composition covers the request.\n"
|
|
86
|
+
"\n"
|
|
87
|
+
"`get_server_info` reports the accelerator, directories and this "
|
|
88
|
+
"session's workspace; a CUDA-only choice is unavailable on an "
|
|
89
|
+
"mps or cpu server.\n"
|
|
90
|
+
"\n"
|
|
91
|
+
'The loop: `get_guide("workflows", section="Authoring a '
|
|
92
|
+
'workflow from an agent")` before writing or repairing JSON -> '
|
|
93
|
+
"`validate_workflow` (free; repeat until clean) -> quote its "
|
|
94
|
+
"`plan.estimate` and get the user's go-ahead -> `run_workflow` "
|
|
95
|
+
"-> `wait_for_job` -> `get_job` -> `get_output_image`, "
|
|
96
|
+
"`get_output_frames`, `get_output_audio` to look at and listen "
|
|
97
|
+
"to the result and judge it against the request. Tools that "
|
|
98
|
+
"spend GPU time or disk, or delete for good, refuse until "
|
|
99
|
+
"`acknowledged_cost` is set. A workflow you wrote has no "
|
|
100
|
+
"measured cost: quote the `models/` entry that loads the same "
|
|
101
|
+
"pipeline (`list_workflows(include_models=true)`) times the "
|
|
102
|
+
"number of images.\n"
|
|
103
|
+
"\n"
|
|
104
|
+
"Arguments carry references rather than literals: `variable:`, "
|
|
105
|
+
"`previous_result:`, `prompt:` (the stored prompt library), "
|
|
106
|
+
"`asset:` (input media on the server - `upload_asset`, "
|
|
107
|
+
"`keep_output`) and `output:` (an earlier run's file). The "
|
|
108
|
+
"guide's References section defines each; prefer them to a "
|
|
109
|
+
"local path, which means nothing to the server. "
|
|
110
|
+
"`use_workspace` picks the workspace this session works in."
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def tool(fn, annotations):
|
|
115
|
+
# The SDK ships fn.__doc__ verbatim. Python 3.13+ strips a docstring's
|
|
116
|
+
# common indentation at compile time, 3.12 does not - so without this
|
|
117
|
+
# every continuation line reaches the agent with eight leading spaces,
|
|
118
|
+
# about 1_100 tokens of resident surface on the interpreter most
|
|
119
|
+
# servers run. cleandoc makes the description the same on both.
|
|
120
|
+
server.add_tool(
|
|
121
|
+
_anticipated(fn),
|
|
122
|
+
name=fn.__name__,
|
|
123
|
+
description=inspect.cleandoc(fn.__doc__ or ""),
|
|
124
|
+
annotations=annotations,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------- catalog
|
|
128
|
+
|
|
129
|
+
def list_workflows(
|
|
130
|
+
shape: Optional[str] = None,
|
|
131
|
+
traits: Optional[str] = None,
|
|
132
|
+
configures: Optional[str] = None,
|
|
133
|
+
include_models: bool = False,
|
|
134
|
+
) -> dict:
|
|
135
|
+
"""List the workflows stored on the server, compactly. Decide the
|
|
136
|
+
deliverable's shape first and pass it: one of image, image-set,
|
|
137
|
+
image-edit, shot, sequence, audio, text, utility. `traits` narrows
|
|
138
|
+
further (comma-separated, all must match): has-audio, chained,
|
|
139
|
+
image-conditioned, identity-referenced, needs-input-media,
|
|
140
|
+
composes-workflows. Each entry carries a one-line `summary`, its
|
|
141
|
+
`shape` and `traits` (what it needs supplied), `cost` (curated:
|
|
142
|
+
measured once by a maintainer on the devices named, never derived
|
|
143
|
+
from this server's history - null means nobody wrote one down, not
|
|
144
|
+
that the run is cheap. It is also the mark of an
|
|
145
|
+
entry that has been run through on a real device: one without a
|
|
146
|
+
`cost` has only been authored, and its first run is the one that
|
|
147
|
+
finds what the description could not verify. `observed_minutes` and
|
|
148
|
+
`observed_runs`, when present, are this box's *own* finished runs
|
|
149
|
+
of that workflow - the cold median, model load included, and how
|
|
150
|
+
many runs are behind it. Prefer it when quoting a price for this
|
|
151
|
+
machine, fall back to `cost`, and say "unknown" only when neither
|
|
152
|
+
is there; say which one you used - a maintainer's card and this
|
|
153
|
+
box's average are different claims), output kinds and variable names. `lists`, present for a
|
|
154
|
+
list-driven workflow, names per list variable the fields an entry
|
|
155
|
+
takes, the steps run over it and the default's length; there
|
|
156
|
+
`cost[].per_entry`, when present, is the measured cost of one
|
|
157
|
+
entry (`{variable, minutes, entries}`), so a run over a
|
|
158
|
+
different-length list can be priced from it. `constraints`, present
|
|
159
|
+
for a workflow that bounds a variable, is the rule each bounded one
|
|
160
|
+
has to satisfy, terse - pass an
|
|
161
|
+
`arguments` value outside it and `validate_workflow` refuses it for
|
|
162
|
+
free, instead of the run failing after the weights are loaded.
|
|
163
|
+
Templates only by default; `configures=<template>` lists the checkpoint configs
|
|
164
|
+
tuned for one, `include_models=true` lists them all. `get_workflow`
|
|
165
|
+
has the full description and definition."""
|
|
166
|
+
return catalog.list_workflows(
|
|
167
|
+
client,
|
|
168
|
+
shape=shape,
|
|
169
|
+
traits=[t.strip() for t in (traits or "").split(",") if t.strip()],
|
|
170
|
+
configures=configures,
|
|
171
|
+
include_models=include_models,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def get_workflow(name: str, variables_only: bool = False) -> dict:
|
|
175
|
+
"""Get one stored workflow's full JSON definition, by a name from
|
|
176
|
+
`list_workflows`. Read one before editing it, and to learn the
|
|
177
|
+
idioms this installation actually uses. Pass
|
|
178
|
+
`variables_only=true` when the question is only what a variable
|
|
179
|
+
defaults to - it answers with the variables and their values and
|
|
180
|
+
nothing else, which is a fraction of the definition; long defaults
|
|
181
|
+
come back cut to 200 characters with the cut ones named in
|
|
182
|
+
`truncated`, reaching into a list default too - a shot's prompt
|
|
183
|
+
is named `shots[0].prompt`. `observed`, when this box has run the
|
|
184
|
+
workflow, is the whole derived-cost block: `cold_minutes` /
|
|
185
|
+
`cold_runs` (model load included) and `warm_minutes` /
|
|
186
|
+
`warm_runs` (model already resident), the `drivers` the figure is
|
|
187
|
+
for, `since`, and `unclassified_runs` when a run's event tail was
|
|
188
|
+
trimmed too far to tell which it was. Step-cache-only runs are
|
|
189
|
+
excluded. A variable the workflow bounds is
|
|
190
|
+
reported under `constraints` beside its default - the range and the
|
|
191
|
+
step it has to land on - so a frame count is read rather than
|
|
192
|
+
guessed at."""
|
|
193
|
+
return catalog.get_workflow(client, name, variables_only=variables_only)
|
|
194
|
+
|
|
195
|
+
def get_schema(section: str | None = None) -> dict:
|
|
196
|
+
"""Get the JSON schema every workflow definition must satisfy - the
|
|
197
|
+
authority on a workflow's structure: steps, pipelines, tasks,
|
|
198
|
+
results, variables. Read it before authoring one from scratch, and
|
|
199
|
+
note that schema validation runs before variable substitution, so a
|
|
200
|
+
variable's default has to be the type its use expects (25, not
|
|
201
|
+
"25").
|
|
202
|
+
|
|
203
|
+
Ask for the part you need: `section` takes `steps`, `pipelines`,
|
|
204
|
+
`tasks`, `result`, `variables` or `configuration` and answers that
|
|
205
|
+
fragment - a tenth the size - with `elsewhere` naming the section
|
|
206
|
+
that holds each definition it still references. The whole schema is
|
|
207
|
+
the no-argument call."""
|
|
208
|
+
return catalog.get_schema(client, section=section)
|
|
209
|
+
|
|
210
|
+
def list_pipelines() -> dict:
|
|
211
|
+
"""List every diffusers pipeline class this installation provides.
|
|
212
|
+
These are the names a step's `component_type` can take - and the
|
|
213
|
+
list is this installation's, so a pipeline from a newer diffusers
|
|
214
|
+
will not be here until it is updated."""
|
|
215
|
+
return catalog.list_pipelines(client)
|
|
216
|
+
|
|
217
|
+
def get_pipeline_signature(name: str) -> dict:
|
|
218
|
+
"""Get a pipeline's real call arguments. Check this before proposing
|
|
219
|
+
pipeline arguments - a plausible-looking argument that the pipeline
|
|
220
|
+
does not accept is the most common workflow bug."""
|
|
221
|
+
return catalog.get_pipeline_signature(client, name)
|
|
222
|
+
|
|
223
|
+
def list_classes(
|
|
224
|
+
kind: Literal["pipelines", "models", "schedulers", "quantization"],
|
|
225
|
+
) -> dict:
|
|
226
|
+
"""List class names of one kind: pipelines, models, schedulers, or
|
|
227
|
+
quantization. These are the names a workflow's `component_type`,
|
|
228
|
+
`scheduler_type` or `config_type` can take."""
|
|
229
|
+
return catalog.list_classes(client, kind)
|
|
230
|
+
|
|
231
|
+
def get_class(name: str, target: Literal["init", "call", "load"] = "init") -> dict:
|
|
232
|
+
"""Get a class's argument schema, from whichever entry point a
|
|
233
|
+
workflow reaches it by: `init` reads the constructor (quantization
|
|
234
|
+
configs, schedulers, models built from named arguments), `call`
|
|
235
|
+
reads __call__ (a pipeline's `arguments`), `load` reads
|
|
236
|
+
from_pretrained plus the curated loading knobs, which is what a
|
|
237
|
+
component's `from_pretrained_arguments` can carry."""
|
|
238
|
+
return catalog.get_class(client, name, target=target)
|
|
239
|
+
|
|
240
|
+
def list_tasks() -> dict:
|
|
241
|
+
"""List every task command a workflow's task step can name - the
|
|
242
|
+
non-pipeline work: upscaling, face restoration, ControlNet
|
|
243
|
+
preprocessors, captioning, frame interpolation, video and audio
|
|
244
|
+
handling. Check here before assuming something needs a pipeline."""
|
|
245
|
+
return catalog.list_tasks(client)
|
|
246
|
+
|
|
247
|
+
def get_task(command: str) -> dict:
|
|
248
|
+
"""Get a task command's argument schema, read from its real
|
|
249
|
+
implementation signature. The counterpart of
|
|
250
|
+
`get_pipeline_signature` for a task step."""
|
|
251
|
+
return catalog.get_task(client, command)
|
|
252
|
+
|
|
253
|
+
def list_models() -> dict:
|
|
254
|
+
"""List what the Hugging Face model cache holds, largest first."""
|
|
255
|
+
return catalog.list_models(client)
|
|
256
|
+
|
|
257
|
+
def get_memory() -> dict:
|
|
258
|
+
"""Get the worker's VRAM and RAM statistics. Check this first when a
|
|
259
|
+
job fails with an out-of-memory error.
|
|
260
|
+
|
|
261
|
+
`gpu_*` is the card, `host_memory_*` the machine:
|
|
262
|
+
`host_memory_rss_mb` is what the worker process holds and
|
|
263
|
+
`host_memory_peak_rss_mb` the most it has ever held, beside the
|
|
264
|
+
machine's `host_memory_total_mb` / `host_memory_available_mb`. Read
|
|
265
|
+
both - a workflow that offloads (`offload: "sequential"`,
|
|
266
|
+
`group_offload`) keeps its weights in host memory by design, so the
|
|
267
|
+
card can sit near-empty through a generation and VRAM alone will not
|
|
268
|
+
show what a run is holding or failing to release. A host field is
|
|
269
|
+
absent, rather than null, on a platform that cannot measure it.
|
|
270
|
+
|
|
271
|
+
`host_pinned_reserved_mb` / `host_pinned_allocated_mb`, when
|
|
272
|
+
present, are torch's pinned-host cache and are part of
|
|
273
|
+
`host_memory_rss_mb` - see the `acceleration` guide, section
|
|
274
|
+
"Reading Memory While Offloading", for what that means for a worker
|
|
275
|
+
that has released every model and still holds gigabytes.
|
|
276
|
+
|
|
277
|
+
`live: true` means `info` was measured now and is the worker's own
|
|
278
|
+
memory - only these readings are comparable with each other.
|
|
279
|
+
`live: false` means it was not: `info: null` (with `stale: false`)
|
|
280
|
+
means nothing has been measured because nothing is resident, and a
|
|
281
|
+
populated `info` is a cached earlier reading - `reason` says why
|
|
282
|
+
(`job_running`, `worker_stopped`, `worker_busy`, `worker_unreachable`)
|
|
283
|
+
and `age_seconds` how old it is. A cached reading is not this
|
|
284
|
+
moment's: one taken while a job is loading a model understates what
|
|
285
|
+
is resident by however much has loaded since, so ask again when the
|
|
286
|
+
server is idle rather than comparing it against a live figure.
|
|
287
|
+
|
|
288
|
+
`info.step_cache` is the step cache's own accounting: `entries`,
|
|
289
|
+
`retained_bytes` against `max_retained_bytes`."""
|
|
290
|
+
return catalog.get_memory(client)
|
|
291
|
+
|
|
292
|
+
def clear_memory() -> dict:
|
|
293
|
+
"""Drop every loaded pipeline and the step cache, freeing VRAM/RAM
|
|
294
|
+
immediately instead of waiting for the next job to evict one model
|
|
295
|
+
for another. Also drops the step cache, so a seeded workflow that
|
|
296
|
+
would otherwise reuse cached results regenerates on its next run.
|
|
297
|
+
|
|
298
|
+
Refused with a 409 while a job is running or queued - the queue is
|
|
299
|
+
FIFO, so wait for it to finish and retry rather than expecting this
|
|
300
|
+
call to block until it does. On an idle server with no model process
|
|
301
|
+
resident there is nothing loaded to clear, so it succeeds with a null
|
|
302
|
+
`info` rather than failing."""
|
|
303
|
+
return catalog.clear_memory(client)
|
|
304
|
+
|
|
305
|
+
def get_health() -> dict:
|
|
306
|
+
"""Check that the server is alive, and see what answered: its
|
|
307
|
+
version and accelerator, whether a model process is currently
|
|
308
|
+
resident, the job running now and how many are queued.
|
|
309
|
+
|
|
310
|
+
`worker_alive: false` on an otherwise healthy server (`status: ok`)
|
|
311
|
+
is the normal idle state, not a fault - the worker is an on-demand
|
|
312
|
+
subprocess that has not started yet because no job has run since
|
|
313
|
+
the server started or the last memory clear, and it starts with the
|
|
314
|
+
next job."""
|
|
315
|
+
return catalog.get_health(client)
|
|
316
|
+
|
|
317
|
+
def get_server_info() -> dict:
|
|
318
|
+
"""Get what this installation can do and where it keeps things: the
|
|
319
|
+
accelerator a run will use (`device` - cuda, mps or cpu), the dw
|
|
320
|
+
version, and the workflow, output and prompt directories. Check the
|
|
321
|
+
device before authoring: a CUDA-only choice - bitsandbytes
|
|
322
|
+
quantization, torch.compile, flash attention - is not available on
|
|
323
|
+
an mps or cpu server, and `directories` is what a path passed to
|
|
324
|
+
run_workflow or download_output is relative to. If this session
|
|
325
|
+
works in a named workspace, `directories` are scoped to that
|
|
326
|
+
workspace. `trust_workflows` reports the posture a submitted
|
|
327
|
+
workflow is read under: false - the default - means the file is
|
|
328
|
+
untrusted input, so an out-of-ecosystem import, remote code, and a
|
|
329
|
+
media location outside the workspace's roots are all refused."""
|
|
330
|
+
return workspaces.server_info(client)
|
|
331
|
+
|
|
332
|
+
def list_jobs(
|
|
333
|
+
limit: int = 20, status: str | None = None, workspace: str | None = None
|
|
334
|
+
) -> dict:
|
|
335
|
+
"""List queued, running and recent jobs, newest first, with their
|
|
336
|
+
status and queue position. The ids here are what `get_job`,
|
|
337
|
+
`wait_for_job`, `get_job_events`, `cancel_job`, `rerun_job` and
|
|
338
|
+
`move_job` take - including jobs from before this session, so a run
|
|
339
|
+
someone started in the browser can be picked up here.
|
|
340
|
+
|
|
341
|
+
`limit` is the newest N (20 by default); `total` reports how many
|
|
342
|
+
matched, so a truncated answer says so rather than looking
|
|
343
|
+
complete. `status` narrows to one state or a comma-separated set of
|
|
344
|
+
them - queued, running, succeeded, failed, cancelled. `workspace`
|
|
345
|
+
lists one workspace's jobs; without it, a named workspace lists its
|
|
346
|
+
own and the default workspace lists every job the server holds,
|
|
347
|
+
whichever workspace ran it. Each job carries `acknowledged` - `none`,
|
|
348
|
+
`boolean` or `bound` - which form of cost acknowledgement queued it."""
|
|
349
|
+
return catalog.list_jobs(
|
|
350
|
+
client, limit=limit, status=status, workspace=workspace
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def list_gallery(
|
|
354
|
+
limit: int = 50,
|
|
355
|
+
subfolder: str | None = None,
|
|
356
|
+
only_orphans: bool = False,
|
|
357
|
+
workspace: str | None = None,
|
|
358
|
+
folder: str | None = None,
|
|
359
|
+
version: int | None = None,
|
|
360
|
+
media: bool = False,
|
|
361
|
+
) -> dict:
|
|
362
|
+
"""List generated output files, newest first. A name is
|
|
363
|
+
<workflow>/<run id>/<file>, where <file> may itself sit in a
|
|
364
|
+
subfolder the step chose (`final/episode.mp4`) - the form
|
|
365
|
+
`get_output_image`, `get_output_text`, `download_output`,
|
|
366
|
+
`keep_output` and `delete_output` all take, and the form an
|
|
367
|
+
"output:" reference in a later workflow is built from. Each entry
|
|
368
|
+
carries `folder` (the workflow) and `subfolder` (the part of the run:
|
|
369
|
+
by convention `final` is the deliverable and `intermediate` the
|
|
370
|
+
scratch work, '' when the step chose none); `subfolder=` filters on
|
|
371
|
+
the latter, so `subfolder="final"` is "what did these runs
|
|
372
|
+
deliver". Each entry's `url` is already scoped to its workspace;
|
|
373
|
+
use it as given rather than composing one from the name.
|
|
374
|
+
|
|
375
|
+
Entries also carry `run_id` and `version`, the run's stable ordinal
|
|
376
|
+
(the web UI shows `v5`) - quote the version to a person. `folder=`
|
|
377
|
+
plus `version=` lists that run; "output:<folder>/v5/<file>" names
|
|
378
|
+
it. Other tools take `name`.
|
|
379
|
+
|
|
380
|
+
`only_orphans=True` inverts the call: instead of files, it returns
|
|
381
|
+
run directories holding nothing but their own bookkeeping
|
|
382
|
+
(manifest.json, workflow.json, job.json) as `runs`, each
|
|
383
|
+
`{name, mtime}` - a run whose output was deleted before
|
|
384
|
+
`delete_output` could remove it by name, or one that failed before
|
|
385
|
+
writing anything. `subfolder` does not apply in this mode. `name` is
|
|
386
|
+
exactly what `delete_output` accepts, so clearing the backlog is
|
|
387
|
+
list, then delete each name. A run that wrote any file at
|
|
388
|
+
all - a text-shape prompt, a utility's side output - is not listed;
|
|
389
|
+
this call only lists, so deciding whether a listed entry is actually
|
|
390
|
+
junk before calling `delete_output` on it is still yours to make.
|
|
391
|
+
|
|
392
|
+
`workspace` names the workspace for this one call without
|
|
393
|
+
switching the session to it - the same pin `run_workflow`
|
|
394
|
+
takes, so a job run into another workspace is reachable from
|
|
395
|
+
here without leaving this one.
|
|
396
|
+
|
|
397
|
+
`media=True` adds `duration_seconds` to audio/video entries - two
|
|
398
|
+
takes sharing a basename are told apart by length, not size or
|
|
399
|
+
mtime."""
|
|
400
|
+
return catalog.list_gallery(
|
|
401
|
+
client,
|
|
402
|
+
limit=limit,
|
|
403
|
+
subfolder=subfolder,
|
|
404
|
+
only_orphans=only_orphans,
|
|
405
|
+
workspace=workspace,
|
|
406
|
+
folder=folder,
|
|
407
|
+
version=version,
|
|
408
|
+
media=media,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def get_gallery_metadata(
|
|
412
|
+
name: str, envelope: bool = False, workspace: str | None = None
|
|
413
|
+
) -> dict:
|
|
414
|
+
"""Get the metadata embedded in a generated file: the exact
|
|
415
|
+
workflow, arguments and seed that produced it - the definition,
|
|
416
|
+
not a summary, so a result can be reproduced or a failed run's
|
|
417
|
+
definition edited and re-run. Only an image embeds it; for audio
|
|
418
|
+
and video `metadata` is null and `next` names
|
|
419
|
+
`get_job_workflow(job_id)` when known, else a kept asset has no
|
|
420
|
+
provenance. `media` itself carries duration, sample rate,
|
|
421
|
+
channels, fps, size and level - the checks an agent that cannot
|
|
422
|
+
listen makes on a deliverable; `media.shots` places a joined
|
|
423
|
+
video's shots.
|
|
424
|
+
`envelope=true` adds that level second by second
|
|
425
|
+
(`media.envelope.rms_dbfs` / `peak_dbfs`), which says *where* in a
|
|
426
|
+
track something is: a shot's last frame, a seam's hole, where a
|
|
427
|
+
score goes quiet. Leave it off unless it's about position - a long
|
|
428
|
+
track is a long list.
|
|
429
|
+
|
|
430
|
+
`media.peak_dbfs` is what the job's `audio_no_headroom` (-0.5 dBFS,
|
|
431
|
+
pre-encode) and `audio_clipped` (0.0 dBFS, post-encode) warnings
|
|
432
|
+
read - see `normalize_audio` under "Video Processing" in the tasks
|
|
433
|
+
guide. A mux emits only the second.
|
|
434
|
+
|
|
435
|
+
`name` may be an "asset:" reference instead of a gallery name, and
|
|
436
|
+
then it describes that input asset - how many frames a shot is,
|
|
437
|
+
whether two shots share an fps, whether a score reaches the length
|
|
438
|
+
of the cut it will lie under. Check before running: frame counts
|
|
439
|
+
and rates are arguments the caller supplies, and a wrong one is a
|
|
440
|
+
failed job or, worse, silence padded onto the end of a track.
|
|
441
|
+
|
|
442
|
+
`workspace` pins this call to another workspace."""
|
|
443
|
+
return catalog.get_gallery_metadata(
|
|
444
|
+
client, name, envelope=envelope, workspace=workspace
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def list_guides() -> dict:
|
|
448
|
+
"""List the documentation the engine serves: each guide's
|
|
449
|
+
name, what it covers, and its section headings. Read this when a
|
|
450
|
+
request is open-ended enough that no catalog entry obviously
|
|
451
|
+
answers it - a request names a subject ("a lego movie trailer"),
|
|
452
|
+
while the catalog and these guides are written in shapes
|
|
453
|
+
(multi-shot video, cuts, a consistent cast, narration over
|
|
454
|
+
B-roll), and the section headings are where the two get matched
|
|
455
|
+
up. Cheaper than guessing: reading a section costs a fraction of
|
|
456
|
+
one wrong run."""
|
|
457
|
+
return guides.list_guides(client)
|
|
458
|
+
|
|
459
|
+
def get_guide(name: str, section: str | None = None) -> dict:
|
|
460
|
+
"""Get one guide from `list_guides`, or one section of it. Name the
|
|
461
|
+
section - a guide runs to thousands of lines, and the headings in
|
|
462
|
+
the listing are there so the right part can be asked for by name. A
|
|
463
|
+
section name is matched loosely, so a heading copied approximately
|
|
464
|
+
still resolves. Called without one, the answer is the guide's index
|
|
465
|
+
(its opening and first section, with `sections` and `withheld`
|
|
466
|
+
naming the rest), not the whole file."""
|
|
467
|
+
return guides.get_guide(client, name, section=section)
|
|
468
|
+
|
|
469
|
+
for fn in (
|
|
470
|
+
list_guides,
|
|
471
|
+
get_guide,
|
|
472
|
+
list_workflows,
|
|
473
|
+
get_workflow,
|
|
474
|
+
get_schema,
|
|
475
|
+
list_pipelines,
|
|
476
|
+
get_pipeline_signature,
|
|
477
|
+
list_classes,
|
|
478
|
+
get_class,
|
|
479
|
+
list_tasks,
|
|
480
|
+
get_task,
|
|
481
|
+
list_models,
|
|
482
|
+
get_memory,
|
|
483
|
+
get_health,
|
|
484
|
+
get_server_info,
|
|
485
|
+
list_jobs,
|
|
486
|
+
list_gallery,
|
|
487
|
+
get_gallery_metadata,
|
|
488
|
+
):
|
|
489
|
+
tool(fn, READ_ONLY)
|
|
490
|
+
tool(clear_memory, WRITES)
|
|
491
|
+
|
|
492
|
+
# --------------------------------------------------------------- media
|
|
493
|
+
|
|
494
|
+
def get_output_image(
|
|
495
|
+
name: str,
|
|
496
|
+
max_dimension: int = 768,
|
|
497
|
+
workspace: str | None = None,
|
|
498
|
+
crop: list[int] | None = None,
|
|
499
|
+
) -> list[ImageContent | TextContent]:
|
|
500
|
+
"""Look at a generated image, named as `list_gallery` or a job's
|
|
501
|
+
manifest reports it. Use this to judge output quality - a run that
|
|
502
|
+
succeeded can still have made the wrong picture. Downscaled to
|
|
503
|
+
`max_dimension` on its longest side; the second part reports the
|
|
504
|
+
before/after size, so a downscale is never silent.
|
|
505
|
+
`crop` is `[x, y, width, height]` in the original's pixels,
|
|
506
|
+
cut before the downscale.
|
|
507
|
+
|
|
508
|
+
`workspace` pins this call to another workspace."""
|
|
509
|
+
result = media.get_output_image(
|
|
510
|
+
client, name, max_dimension=max_dimension, workspace=workspace, crop=crop
|
|
511
|
+
)
|
|
512
|
+
image = ImageContent(
|
|
513
|
+
type="image", data=result["data"], mime_type=result["mime_type"]
|
|
514
|
+
)
|
|
515
|
+
telemetry = TextContent(
|
|
516
|
+
type="text",
|
|
517
|
+
text=(
|
|
518
|
+
f"name: {result['name']}\n"
|
|
519
|
+
f"original_size: {result['original_size']}\n"
|
|
520
|
+
+ (f"crop: {result['crop']}\n" if result["crop"] else "")
|
|
521
|
+
+ f"returned_size: {result['returned_size']}\n"
|
|
522
|
+
f"bytes: {result['bytes']}"
|
|
523
|
+
),
|
|
524
|
+
)
|
|
525
|
+
return [image, telemetry]
|
|
526
|
+
|
|
527
|
+
def get_output_audio(
|
|
528
|
+
name: str,
|
|
529
|
+
start: float | None = None,
|
|
530
|
+
duration: float | None = None,
|
|
531
|
+
workspace: str | None = None,
|
|
532
|
+
) -> list[AudioContent | TextContent]:
|
|
533
|
+
"""Listen to a generated soundtrack, named as `list_gallery` or a
|
|
534
|
+
job's manifest reports it - an audio output, or a video's muxed
|
|
535
|
+
track: own encoding when served whole, WAV when extracted or
|
|
536
|
+
excerpted. No downscale exists for audio - a whole clip too
|
|
537
|
+
large is refused; ask for a part with `start`/`duration` in
|
|
538
|
+
seconds, per `get_gallery_metadata`'s envelope. The text part
|
|
539
|
+
says what was cut. To *see* a video, `get_output_frames`. A
|
|
540
|
+
text-only client confirms the *words* an output speaks by
|
|
541
|
+
transcribing it instead: WORKFLOW_GUIDE's "The loop", step 6, in
|
|
542
|
+
`get_guide("workflows", section="Authoring a workflow from an
|
|
543
|
+
agent")`.
|
|
544
|
+
|
|
545
|
+
`workspace` pins this call to another workspace."""
|
|
546
|
+
result = media.get_output_audio(
|
|
547
|
+
client, name, start=start, duration=duration, workspace=workspace
|
|
548
|
+
)
|
|
549
|
+
audio = AudioContent(
|
|
550
|
+
type="audio", data=result["data"], mime_type=result["mime_type"]
|
|
551
|
+
)
|
|
552
|
+
lines = [f"name: {result['name']}", f"bytes: {result['bytes']}"]
|
|
553
|
+
if result["duration_seconds"] is not None:
|
|
554
|
+
lines.append(f"duration_seconds: {result['duration_seconds']}")
|
|
555
|
+
if result["excerpt"]:
|
|
556
|
+
e = result["excerpt"]
|
|
557
|
+
lines.append(f"excerpt: {e['duration']}s from {e['start']}s of {e['of']}s")
|
|
558
|
+
telemetry = TextContent(type="text", text="\n".join(lines))
|
|
559
|
+
return [audio, telemetry]
|
|
560
|
+
|
|
561
|
+
def get_output_frames(
|
|
562
|
+
name: str,
|
|
563
|
+
at: list[str | float] | None = None,
|
|
564
|
+
seams: bool | list[int] | None = None,
|
|
565
|
+
count: int | None = None,
|
|
566
|
+
boundaries: list[int] | None = None,
|
|
567
|
+
names: list[str] | None = None,
|
|
568
|
+
max_dimension: int = 512,
|
|
569
|
+
hear: float | None = None,
|
|
570
|
+
workspace: str | None = None,
|
|
571
|
+
crop: list[int] | None = None,
|
|
572
|
+
) -> list[ImageContent | AudioContent | TextContent]:
|
|
573
|
+
"""See a generated video as frames - no video content type exists
|
|
574
|
+
over MCP. One selector: `count` (contact sheet), `at` (seconds or
|
|
575
|
+
"frame:N"), or `seams` (true, or seam numbers from 1) for each
|
|
576
|
+
join's frame pair, at a joined output's `media.shots`; else
|
|
577
|
+
`boundaries` (each later shot's first frame) and `names`. Over budget, tiles shrink together.
|
|
578
|
+
`hear=N` adds N seconds of soundtrack around each `at`.
|
|
579
|
+
`crop` is `[x, y, width, height]` in the video's own source
|
|
580
|
+
pixels, cut from every frame before any downscale, like
|
|
581
|
+
`get_output_image`'s.
|
|
582
|
+
|
|
583
|
+
`workspace` pins this call to another workspace."""
|
|
584
|
+
result = media.get_output_frames(
|
|
585
|
+
client,
|
|
586
|
+
name,
|
|
587
|
+
at=at,
|
|
588
|
+
seams=seams,
|
|
589
|
+
count=count,
|
|
590
|
+
boundaries=boundaries,
|
|
591
|
+
names=names,
|
|
592
|
+
max_dimension=max_dimension,
|
|
593
|
+
hear=hear,
|
|
594
|
+
workspace=workspace,
|
|
595
|
+
crop=crop,
|
|
596
|
+
)
|
|
597
|
+
parts = []
|
|
598
|
+
for tile in result["tiles"]:
|
|
599
|
+
parts.append(
|
|
600
|
+
ImageContent(
|
|
601
|
+
type="image", data=tile["data"], mime_type=tile["mime_type"]
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
if "audio" in tile:
|
|
605
|
+
parts.append(
|
|
606
|
+
AudioContent(
|
|
607
|
+
type="audio",
|
|
608
|
+
data=tile["audio"]["data"],
|
|
609
|
+
mime_type=tile["audio"]["mime_type"],
|
|
610
|
+
)
|
|
611
|
+
)
|
|
612
|
+
lines = [
|
|
613
|
+
f"name: {result['name']}",
|
|
614
|
+
f"frame_count: {result['frame_count']} fps: {result['fps']}",
|
|
615
|
+
]
|
|
616
|
+
if result.get("crop"):
|
|
617
|
+
lines.append(f"crop: {result['crop']}")
|
|
618
|
+
fps = result["fps"]
|
|
619
|
+
for tile in result["tiles"]:
|
|
620
|
+
if tile.get("frames"):
|
|
621
|
+
# a contact sheet: every cell, so each one can be located
|
|
622
|
+
cells = ", ".join(
|
|
623
|
+
f"{frame} ({frame / fps:.2f}s)" if fps else str(frame)
|
|
624
|
+
for frame in tile["frames"]
|
|
625
|
+
)
|
|
626
|
+
where = f"frames: {cells}"
|
|
627
|
+
else:
|
|
628
|
+
where = f"frame {tile['frame']} @ {tile['seconds']:.2f}s"
|
|
629
|
+
if tile.get("difference") is not None:
|
|
630
|
+
where += f" difference: {tile['difference']}"
|
|
631
|
+
if tile.get("audio_error"):
|
|
632
|
+
where += f" hear: {tile['audio_error']}"
|
|
633
|
+
lines.append(
|
|
634
|
+
f"- {tile['label']} {where} [{tile['width']}x{tile['height']}]"
|
|
635
|
+
)
|
|
636
|
+
if result["downscaled_to"]:
|
|
637
|
+
lines.append(
|
|
638
|
+
f"downscaled_to: {result['downscaled_to']} (every tile, to fit the inline budget)"
|
|
639
|
+
)
|
|
640
|
+
if result.get("hear"):
|
|
641
|
+
lines.append(f"hear: {result['hear']}s around each moment")
|
|
642
|
+
if result.get("audio_truncated"):
|
|
643
|
+
lines.append(
|
|
644
|
+
"audio_truncated: some tiles' audio was skipped to stay within "
|
|
645
|
+
"the response size budget"
|
|
646
|
+
)
|
|
647
|
+
parts.append(TextContent(type="text", text="\n".join(lines)))
|
|
648
|
+
return parts
|
|
649
|
+
|
|
650
|
+
def get_output_text(
|
|
651
|
+
name: str, max_characters: int = 20000, workspace: str | None = None
|
|
652
|
+
) -> dict:
|
|
653
|
+
"""Read a text output - a prompt enhancement, or any step whose
|
|
654
|
+
result is text/plain or JSON. Truncated to `max_characters`, and
|
|
655
|
+
the reply says how long the file really was.
|
|
656
|
+
|
|
657
|
+
`workspace` names the workspace for this one call without
|
|
658
|
+
switching the session to it - the same pin `run_workflow`
|
|
659
|
+
takes, so a job run into another workspace is reachable from
|
|
660
|
+
here without leaving this one."""
|
|
661
|
+
return media.get_output_text(
|
|
662
|
+
client, name, max_characters=max_characters, workspace=workspace
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
def assess_output(
|
|
666
|
+
name: str,
|
|
667
|
+
probe: str | None = None,
|
|
668
|
+
detail: bool = False,
|
|
669
|
+
workspace: str | None = None,
|
|
670
|
+
) -> dict:
|
|
671
|
+
"""Measure a finished cut - seams, shot levels, sync - on the
|
|
672
|
+
server, without queueing. Findings are places to look, not
|
|
673
|
+
verdicts: check each with get_output_frames/get_output_audio.
|
|
674
|
+
`probe` (analyze_shots, analyze_seams, analyze_sync_drift) returns
|
|
675
|
+
one probe's full body; `detail` adds every probe's. Takes `asset:`."""
|
|
676
|
+
return media.assess_output(
|
|
677
|
+
client, name, probe=probe, detail=detail, workspace=workspace
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
def delete_output(
|
|
681
|
+
name: str | None = None,
|
|
682
|
+
workspace: str | None = None,
|
|
683
|
+
job_id: str | None = None,
|
|
684
|
+
) -> dict:
|
|
685
|
+
"""Permanently remove one generated file from the output directory.
|
|
686
|
+
Not recoverable (rerun the job to get it back), and any "output:"
|
|
687
|
+
reference to it stops resolving; prefer `keep_output` if it is
|
|
688
|
+
worth keeping. When it was the last media file of its run, the run
|
|
689
|
+
directory goes with it, sidecars included. `name` may also be a run
|
|
690
|
+
directory ("<workflow>/<run id>", the first two parts of a gallery
|
|
691
|
+
name), which removes the whole run - the only handle on a run that
|
|
692
|
+
failed before writing any media - or give `job_id` instead: the run
|
|
693
|
+
that job wrote is removed whole, and the reply adds `job_id` and
|
|
694
|
+
the resolved `run_dir`. Exactly one of the two; a job with no run
|
|
695
|
+
directory, or unknown, is an error.
|
|
696
|
+
|
|
697
|
+
`workspace` pins this call to another workspace without switching
|
|
698
|
+
the session; a `job_id` delete with no `workspace` goes to
|
|
699
|
+
the workspace the job ran in."""
|
|
700
|
+
return media.delete_output(client, name, workspace=workspace, job_id=job_id)
|
|
701
|
+
|
|
702
|
+
def download_output(
|
|
703
|
+
name: str,
|
|
704
|
+
destination: str | None = None,
|
|
705
|
+
overwrite: bool = False,
|
|
706
|
+
workspace: str | None = None,
|
|
707
|
+
) -> dict:
|
|
708
|
+
"""Save one output file to disk on the
|
|
709
|
+
machine running the MCP server - for the stdio `dw-mcp` that is
|
|
710
|
+
your own machine; for a
|
|
711
|
+
`dw.serve --mcp` endpoint it is the GPU box, and this tool is not
|
|
712
|
+
the way to get a file to where you are (use get_output_image /
|
|
713
|
+
get_output_text for inline content, or the `url` that
|
|
714
|
+
`list_gallery` reports for each entry, which already carries the
|
|
715
|
+
workspace selector - do not build an /outputs URL by hand). This is
|
|
716
|
+
also not how a generated file becomes an input for a later
|
|
717
|
+
workflow: use `keep_output`, which links it inside the workspace
|
|
718
|
+
under an "asset:" name, rather than writing into the server's asset
|
|
719
|
+
directory behind the API's back. Unlike the inline tools, this works
|
|
720
|
+
for any file type, streams the body straight to disk rather than
|
|
721
|
+
buffering it, and returns no content to the conversation - only
|
|
722
|
+
where it was saved. `destination` may be a
|
|
723
|
+
full path or a directory; a '..' path segment in it is refused. An
|
|
724
|
+
existing file at the resolved path is left alone unless
|
|
725
|
+
`overwrite=True`. On the stdio `dw-mcp`, omitting `destination`
|
|
726
|
+
saves into the current working directory under the output's own
|
|
727
|
+
name. On a `dw.serve --mcp` endpoint the save happens on the server, and destination is required there -
|
|
728
|
+
an omitted one is refused rather than dropped loose in the
|
|
729
|
+
workspace root, where nothing can find or delete it later; use
|
|
730
|
+
the `url` list_gallery reports, get_output_image/get_output_audio/
|
|
731
|
+
get_output_frames for inline content, or keep_output to make it a
|
|
732
|
+
named asset instead.
|
|
733
|
+
|
|
734
|
+
`workspace` names the workspace for this one call without
|
|
735
|
+
switching the session to it - the same pin `run_workflow`
|
|
736
|
+
takes, so a job run into another workspace is reachable from
|
|
737
|
+
here without leaving this one."""
|
|
738
|
+
return media.download_output(
|
|
739
|
+
client,
|
|
740
|
+
name,
|
|
741
|
+
destination=destination,
|
|
742
|
+
overwrite=overwrite,
|
|
743
|
+
workspace=workspace,
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
tool(get_output_image, READ_ONLY)
|
|
747
|
+
tool(get_output_audio, READ_ONLY)
|
|
748
|
+
tool(get_output_frames, READ_ONLY)
|
|
749
|
+
tool(get_output_text, READ_ONLY)
|
|
750
|
+
tool(assess_output, READ_ONLY)
|
|
751
|
+
tool(download_output, OVERWRITES)
|
|
752
|
+
tool(delete_output, DELETES)
|
|
753
|
+
|
|
754
|
+
# ---------------------------------------------------------------- assets
|
|
755
|
+
|
|
756
|
+
def list_assets(detail: bool = False) -> dict:
|
|
757
|
+
"""List the input media on the server, each with the "asset:"
|
|
758
|
+
reference a workflow argument carries. Look here before asking for
|
|
759
|
+
a file: what a workflow needs may already be there. Entries carry
|
|
760
|
+
name, reference, kind, size and origin only - for one asset's
|
|
761
|
+
duration, frame count, fps, sample rate or channels, pass its
|
|
762
|
+
reference to `get_gallery_metadata`, which reads inputs as well as
|
|
763
|
+
outputs. Pass detail=true for each entry's folder, mtime and url
|
|
764
|
+
too, needed before naming a shared library's writable/read-only
|
|
765
|
+
roots or opening the file's preview URL."""
|
|
766
|
+
return assets.list_assets(client, detail=detail)
|
|
767
|
+
|
|
768
|
+
def upload_asset(
|
|
769
|
+
file_path: str | None = None,
|
|
770
|
+
content: str | None = None,
|
|
771
|
+
asset_name: str | None = None,
|
|
772
|
+
shared: bool = False,
|
|
773
|
+
) -> dict:
|
|
774
|
+
"""Put an image, video or audio file into the server's asset
|
|
775
|
+
library and get back the "asset:" reference to use in a workflow.
|
|
776
|
+
Pass exactly one of `file_path` or `content`.
|
|
777
|
+
|
|
778
|
+
`file_path` is read from the machine this MCP server runs on and
|
|
779
|
+
pushed to the engine, so it is how an input reaches a dw.serve
|
|
780
|
+
running somewhere else. When this MCP surface is served by
|
|
781
|
+
dw.serve itself, "this machine" is the engine's own box, so
|
|
782
|
+
`file_path` is confined to the directories it works in - a file
|
|
783
|
+
that exists only on your own machine cannot be named this way.
|
|
784
|
+
|
|
785
|
+
`content` is for exactly that case: the file's bytes, base64-encoded,
|
|
786
|
+
sent inline in the call rather than read off any disk. Use it for a
|
|
787
|
+
voice sample or small image that lives only on the machine you are
|
|
788
|
+
running on, against a remote `dw.serve --mcp` endpoint with no
|
|
789
|
+
filesystem in common with you. Capped at 4MB, well under
|
|
790
|
+
`file_path`'s 200MB, because these bytes ride in the call itself.
|
|
791
|
+
`asset_name` is required with `content`, since there is no file to
|
|
792
|
+
take a name or extension from.
|
|
793
|
+
|
|
794
|
+
Accepts the usual image, video and audio extensions. Reference the
|
|
795
|
+
result rather than a path: a path on this machine means nothing to
|
|
796
|
+
the server. Pass `asset_name` to store it under a readable name
|
|
797
|
+
("cast/priya-voice.wav", folders allowed) - without one (when using
|
|
798
|
+
`file_path`) the stored name is random, and a set of related inputs
|
|
799
|
+
cannot be told apart in the workflows that carry them. Pass
|
|
800
|
+
`shared=true` to put it in the library every workspace shares
|
|
801
|
+
rather than this session's own - where a recurring cast belongs,
|
|
802
|
+
since a workspace's own assets are invisible from the next
|
|
803
|
+
workspace."""
|
|
804
|
+
return assets.upload_asset(
|
|
805
|
+
client,
|
|
806
|
+
file_path=file_path,
|
|
807
|
+
content=content,
|
|
808
|
+
asset_name=asset_name,
|
|
809
|
+
shared=shared,
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
def keep_output(
|
|
813
|
+
name: str,
|
|
814
|
+
asset_name: str | None = None,
|
|
815
|
+
overwrite: bool = False,
|
|
816
|
+
shared: bool = False,
|
|
817
|
+
workspace: str | None = None,
|
|
818
|
+
) -> dict:
|
|
819
|
+
"""Keep a generated file as an input asset under a stable "asset:"
|
|
820
|
+
name, so later workflows can rely on it - a run's own name moves
|
|
821
|
+
("latest") or breaks when outputs are pruned. This is the step
|
|
822
|
+
between a render you liked and the next stage that conditions on
|
|
823
|
+
it. `name` is a gallery name; `asset_name` defaults to the file's
|
|
824
|
+
own. The copy happens on the server, inside the workspace: nothing
|
|
825
|
+
is downloaded or re-uploaded. Pass `shared=true` to keep it in the
|
|
826
|
+
library every workspace shares instead - where something a later
|
|
827
|
+
piece in its own workspace has to reach belongs.
|
|
828
|
+
|
|
829
|
+
`workspace` names the workspace for this one call without
|
|
830
|
+
switching the session to it - the same pin `run_workflow`
|
|
831
|
+
takes, so a job run into another workspace is reachable from
|
|
832
|
+
here without leaving this one."""
|
|
833
|
+
return assets.keep_output(
|
|
834
|
+
client,
|
|
835
|
+
name,
|
|
836
|
+
asset_name=asset_name,
|
|
837
|
+
overwrite=overwrite,
|
|
838
|
+
shared=shared,
|
|
839
|
+
workspace=workspace,
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
def delete_asset(name: str) -> dict:
|
|
843
|
+
"""Permanently remove one file from the asset library, by the name
|
|
844
|
+
`list_assets` reports (without the "asset:" prefix). Not
|
|
845
|
+
recoverable, and any workflow still carrying that reference stops
|
|
846
|
+
loading. Deletes from whichever library holds it - this
|
|
847
|
+
workspace's own before the shared one, the order an "asset:"
|
|
848
|
+
reference resolves in; one from a read-only examples library is
|
|
849
|
+
refused."""
|
|
850
|
+
return assets.delete_asset(client, name)
|
|
851
|
+
|
|
852
|
+
tool(list_assets, READ_ONLY)
|
|
853
|
+
tool(upload_asset, WRITES)
|
|
854
|
+
tool(keep_output, WRITES)
|
|
855
|
+
tool(delete_asset, DELETES)
|
|
856
|
+
|
|
857
|
+
# ------------------------------------------------------------ workspaces
|
|
858
|
+
|
|
859
|
+
def list_workspaces(detail: bool = False) -> dict:
|
|
860
|
+
"""List the server's workspaces and say which one this session is
|
|
861
|
+
working in. Each has its own workflows, assets and outputs; the
|
|
862
|
+
stored prompt library is shared by all of them, and so is the
|
|
863
|
+
shared asset library that `upload_asset(shared=true)` and
|
|
864
|
+
`keep_output(shared=true)` write into - which is how a recurring
|
|
865
|
+
cast stays reachable from the workspace the next piece is made
|
|
866
|
+
in. Entries carry name, default and usage (files/bytes) only; pass
|
|
867
|
+
detail=true for each entry's full folder paths (workflows, assets,
|
|
868
|
+
outputs, prompts, common_assets)."""
|
|
869
|
+
return workspaces.list_workspaces(client, detail=detail)
|
|
870
|
+
|
|
871
|
+
def use_workspace(name: str) -> dict:
|
|
872
|
+
"""Work in a different workspace for the rest of this session - every
|
|
873
|
+
later call reads and writes there. Use this to keep your work out of
|
|
874
|
+
another agent's namespace, rather than sharing the default one."""
|
|
875
|
+
return workspaces.use_workspace(client, name)
|
|
876
|
+
|
|
877
|
+
def create_workspace(name: str, use: bool = False) -> dict:
|
|
878
|
+
"""Create a workspace on the server. It gets its own workflows,
|
|
879
|
+
assets and outputs and shares the one prompt library. The name is a
|
|
880
|
+
single path segment and cannot be one of the reserved folder names
|
|
881
|
+
(workflows, prompts, assets, outputs, exports, common). Pass use=true to switch this
|
|
882
|
+
session to it as well; otherwise the session stays where it was and
|
|
883
|
+
the result says so."""
|
|
884
|
+
return workspaces.create_workspace(client, name, use=use)
|
|
885
|
+
|
|
886
|
+
def delete_workspace(name: str, acknowledged_cost: bool = False) -> dict:
|
|
887
|
+
"""Permanently delete a workspace and every workflow, asset and
|
|
888
|
+
generated file in it. Refuses without acknowledged_cost=True, and
|
|
889
|
+
reports what it would remove instead."""
|
|
890
|
+
return workspaces.delete_workspace(
|
|
891
|
+
client, name, acknowledged_cost=acknowledged_cost
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
tool(list_workspaces, READ_ONLY)
|
|
895
|
+
tool(use_workspace, WRITES)
|
|
896
|
+
tool(create_workspace, WRITES)
|
|
897
|
+
tool(delete_workspace, DELETES)
|
|
898
|
+
|
|
899
|
+
# ----------------------------------------------------------- authoring
|
|
900
|
+
|
|
901
|
+
def validate_workflow(
|
|
902
|
+
workflow: dict | str | None = None,
|
|
903
|
+
name: str | None = None,
|
|
904
|
+
inline_workflow: dict | str | None = None,
|
|
905
|
+
workflow_path: str | None = None,
|
|
906
|
+
workspace: str | None = None,
|
|
907
|
+
arguments: dict | None = None,
|
|
908
|
+
) -> dict:
|
|
909
|
+
"""Check a workflow against the schema and against real pipeline
|
|
910
|
+
signatures. Free and instant - run it before run_workflow.
|
|
911
|
+
Give exactly one of `workflow` or `name` (a stored workflow as
|
|
912
|
+
`list_workflows` reports it); `run_workflow`'s `inline_workflow`
|
|
913
|
+
and `workflow_path` spellings are accepted here too. `workflow` may
|
|
914
|
+
be a JSON-encoded string. Every error comes back at once, each with
|
|
915
|
+
its JSON path. `workspace` pins this one call to another workspace
|
|
916
|
+
without switching the session.
|
|
917
|
+
|
|
918
|
+
A valid answer carries `plan`: what will execute for these
|
|
919
|
+
arguments - `estimate.minutes` and its `basis` (`observed`,
|
|
920
|
+
`per_entry`, `catalog`, `derived`, `other_device` or `unknown` -
|
|
921
|
+
how to quote each is WORKFLOW_GUIDE's "The loop", step 4), each
|
|
922
|
+
`downloads_required` entry as its own cost line, and
|
|
923
|
+
`steps`/`list_entries` for how many members the list produced.
|
|
924
|
+
`plan` is null when it could not be built; the verdict stands.
|
|
925
|
+
|
|
926
|
+
Pass the same `arguments` you will pass to `run_workflow` and they
|
|
927
|
+
are checked too: an undeclared name, a value that will not coerce,
|
|
928
|
+
and an `asset:`/`prompt:`/`output:` reference naming nothing this
|
|
929
|
+
workspace can reach, each at `arguments.<name>`.
|
|
930
|
+
`checked_arguments` says whether your values or only the stored
|
|
931
|
+
defaults were checked. A value outside a bound the workflow
|
|
932
|
+
declares is an error here rather than a failed run; one the workflow rounds up
|
|
933
|
+
comes back as a warning naming what it becomes.
|
|
934
|
+
|
|
935
|
+
Also checked: an unwritable `result.subfolder` or `file_base_name`,
|
|
936
|
+
after `for_each` expansion; and each sub-workflow step - an
|
|
937
|
+
unreachable `workflow.path`, the composed workflow in turn, a
|
|
938
|
+
composition cycle, and (as a warning) an argument passed down that
|
|
939
|
+
it declares no variable for."""
|
|
940
|
+
return authoring.validate_workflow(
|
|
941
|
+
client,
|
|
942
|
+
workflow=workflow,
|
|
943
|
+
name=name,
|
|
944
|
+
inline_workflow=inline_workflow,
|
|
945
|
+
workflow_path=workflow_path,
|
|
946
|
+
workspace=workspace,
|
|
947
|
+
arguments=arguments,
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
def save_workflow(
|
|
951
|
+
name: str,
|
|
952
|
+
workflow: dict | str | None = None,
|
|
953
|
+
patch: dict | str | None = None,
|
|
954
|
+
) -> dict:
|
|
955
|
+
"""Save a workflow to the server's writable workflow directory,
|
|
956
|
+
overwriting any existing workflow of that name there. Validate it
|
|
957
|
+
first. A name that currently resolves to a read-only source (an
|
|
958
|
+
examples directory) is not overwritten - the copy lands in the
|
|
959
|
+
writable directory and shadows it from then on, which is how an
|
|
960
|
+
example gets adapted without being damaged. `name` may include
|
|
961
|
+
folders.
|
|
962
|
+
|
|
963
|
+
Give exactly one of `workflow` (the full document) or `patch` for a
|
|
964
|
+
small, targeted edit: a JSON Merge Patch (RFC 7396) merged onto the
|
|
965
|
+
currently stored definition, so bumping one argument means sending
|
|
966
|
+
just that argument rather than the whole document -
|
|
967
|
+
`{"variables": {"num_images_per_prompt": 4}}` rather than the whole
|
|
968
|
+
workflow. A patch key set to `null` deletes that key from the
|
|
969
|
+
stored document. A list is replaced whole, never merged - a merge
|
|
970
|
+
patch has no notion of list position, so changing one `shots` entry
|
|
971
|
+
still means sending the whole `shots` list. Either may also be
|
|
972
|
+
given as a JSON-encoded string, which is parsed before saving; a
|
|
973
|
+
string that fails to parse is reported as invalid JSON rather than
|
|
974
|
+
as a type mismatch.
|
|
975
|
+
|
|
976
|
+
A workflow stored for reuse should mark each saving step's
|
|
977
|
+
`result.subfolder` - `final` for the step whose output the user will
|
|
978
|
+
be shown, `intermediate` for the rest - so a later consumer can tell
|
|
979
|
+
the deliverable from the scratch files without knowing the workflow."""
|
|
980
|
+
return authoring.save_workflow(client, name, workflow=workflow, patch=patch)
|
|
981
|
+
|
|
982
|
+
def delete_workflow(name: str) -> dict:
|
|
983
|
+
"""Permanently delete a stored workflow from this workspace. A
|
|
984
|
+
workflow from a read-only examples directory is refused rather than
|
|
985
|
+
deleted - `list_workflows` reports which those are as
|
|
986
|
+
`writable: false`."""
|
|
987
|
+
return authoring.delete_workflow(client, name)
|
|
988
|
+
|
|
989
|
+
tool(validate_workflow, READ_ONLY)
|
|
990
|
+
tool(save_workflow, OVERWRITES)
|
|
991
|
+
tool(delete_workflow, DELETES)
|
|
992
|
+
|
|
993
|
+
# ------------------------------------------------------------- prompts
|
|
994
|
+
|
|
995
|
+
def list_prompts(
|
|
996
|
+
tag: Optional[str] = None,
|
|
997
|
+
intended_model: Optional[str] = None,
|
|
998
|
+
include_text: bool = False,
|
|
999
|
+
) -> dict:
|
|
1000
|
+
"""List the stored prompts - the worked examples a workflow reaches
|
|
1001
|
+
by writing "prompt:name" or "prompt:folder/name". Each entry carries
|
|
1002
|
+
its `description`, `intended_model`, `tags` and the size of its text;
|
|
1003
|
+
`get_prompt` returns the text itself. This is where the caption a
|
|
1004
|
+
model was trained on is already written out, so read the exemplar
|
|
1005
|
+
for the family you are about to run rather than inventing the
|
|
1006
|
+
format: `intended_model` narrows to one family, as the listing
|
|
1007
|
+
reports it, and `tag` to one label. `include_text=true` returns every body, which for the whole
|
|
1008
|
+
library is more than a client will accept - filter first."""
|
|
1009
|
+
return prompts.list_prompts(
|
|
1010
|
+
client,
|
|
1011
|
+
tag=tag,
|
|
1012
|
+
intended_model=intended_model,
|
|
1013
|
+
include_text=include_text,
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
def get_prompt(name: str) -> dict:
|
|
1017
|
+
"""Get one stored prompt's full definition - its text, description,
|
|
1018
|
+
intended model and tags - by a name from `list_prompts`. The prompt
|
|
1019
|
+
library is shared by every workspace on the server."""
|
|
1020
|
+
return prompts.get_prompt(client, name)
|
|
1021
|
+
|
|
1022
|
+
def get_prompt_schema() -> dict:
|
|
1023
|
+
"""Get the JSON schema every stored prompt must satisfy. Check this
|
|
1024
|
+
before writing one, as you would get_schema before a workflow."""
|
|
1025
|
+
return prompts.get_prompt_schema(client)
|
|
1026
|
+
|
|
1027
|
+
def save_prompt(name: str, prompt: dict | str) -> dict:
|
|
1028
|
+
"""Save a prompt to the library, overwriting any prompt of that
|
|
1029
|
+
name. Its `text` may not itself begin with a reference prefix
|
|
1030
|
+
(variable:, previous_result:, constant:, asset:, output:, prompt:)
|
|
1031
|
+
- the server refuses that to prevent a reference resolving twice.
|
|
1032
|
+
The library is shared by every workspace on this server. `prompt`
|
|
1033
|
+
may also be a JSON-encoded string; a parse failure is reported as
|
|
1034
|
+
invalid JSON, not a type mismatch."""
|
|
1035
|
+
return prompts.save_prompt(client, name, prompt)
|
|
1036
|
+
|
|
1037
|
+
def delete_prompt(name: str) -> dict:
|
|
1038
|
+
"""Permanently delete a stored prompt. A workflow that still
|
|
1039
|
+
references it by "prompt:name" will fail to load."""
|
|
1040
|
+
return prompts.delete_prompt(client, name)
|
|
1041
|
+
|
|
1042
|
+
def list_enhancers() -> dict:
|
|
1043
|
+
"""List the enhancer presets `enhance_prompt` accepts - one per
|
|
1044
|
+
target model family. Call this before enhance_prompt rather than
|
|
1045
|
+
guessing a preset name."""
|
|
1046
|
+
return prompts.list_enhancers(client)
|
|
1047
|
+
|
|
1048
|
+
def enhance_prompt(
|
|
1049
|
+
idea: str,
|
|
1050
|
+
preset: str = "h3",
|
|
1051
|
+
model_name: str | None = None,
|
|
1052
|
+
device: str | None = None,
|
|
1053
|
+
acknowledged_cost: bool = False,
|
|
1054
|
+
) -> dict:
|
|
1055
|
+
"""Expand a short idea into a full prompt with a language model.
|
|
1056
|
+
This costs time on the engine: it queues a real job, and the engine
|
|
1057
|
+
runs one at a time, so a generation waiting behind it is delayed.
|
|
1058
|
+
Tell the user what will be enhanced and get their go-ahead, then
|
|
1059
|
+
pass acknowledged_cost=true. Returns as soon as the job is queued;
|
|
1060
|
+
the enhanced text is the text file in its finished manifest."""
|
|
1061
|
+
return prompts.enhance_prompt(
|
|
1062
|
+
client,
|
|
1063
|
+
idea,
|
|
1064
|
+
preset=preset,
|
|
1065
|
+
model_name=model_name,
|
|
1066
|
+
device=device,
|
|
1067
|
+
acknowledged_cost=acknowledged_cost,
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
for fn in (list_prompts, get_prompt, get_prompt_schema, list_enhancers):
|
|
1071
|
+
tool(fn, READ_ONLY)
|
|
1072
|
+
tool(save_prompt, OVERWRITES)
|
|
1073
|
+
tool(delete_prompt, DELETES)
|
|
1074
|
+
tool(enhance_prompt, WRITES)
|
|
1075
|
+
|
|
1076
|
+
# ------------------------------------------------------------ diagnose
|
|
1077
|
+
|
|
1078
|
+
def run_workflow(
|
|
1079
|
+
workflow_path: str | None = None,
|
|
1080
|
+
inline_workflow: dict | str | None = None,
|
|
1081
|
+
workflow: dict | str | None = None,
|
|
1082
|
+
name: str | None = None,
|
|
1083
|
+
arguments: dict | None = None,
|
|
1084
|
+
acknowledged_cost: bool | dict = False,
|
|
1085
|
+
workspace: str | None = None,
|
|
1086
|
+
wait_seconds: int = 0,
|
|
1087
|
+
) -> dict:
|
|
1088
|
+
"""Queue a workflow for generation. This costs GPU time: a run
|
|
1089
|
+
occupies the machine for minutes and the engine runs one job at a
|
|
1090
|
+
time. Tell the user what will run and get their go-ahead, then pass
|
|
1091
|
+
acknowledged_cost as below. Returns as soon as the job is queued;
|
|
1092
|
+
follow it with `wait_for_job`, then `get_job` for the manifest - or
|
|
1093
|
+
fold that first wait in with `wait_seconds` above 0, which waits on
|
|
1094
|
+
the job exactly as `wait_for_job(job_id,
|
|
1095
|
+
timeout_seconds=wait_seconds)` would ({cap}s cap per call) and adds
|
|
1096
|
+
its fields to the result (`still_running`, `waited_seconds`,
|
|
1097
|
+
`timeout_*`, the slim `job`). If the cap covers the job's
|
|
1098
|
+
runtime one call is enough; on `still_running: true` call
|
|
1099
|
+
`wait_for_job` again. Give exactly one of `workflow_path` - a
|
|
1100
|
+
catalog name from `list_workflows`, with or without .json, or a
|
|
1101
|
+
path on the server - or `inline_workflow`, a full definition
|
|
1102
|
+
nothing stored covers; `validate_workflow` calls these `name` and
|
|
1103
|
+
`workflow`, and both tools accept both spellings.
|
|
1104
|
+
`inline_workflow`/`workflow` may also be a JSON-encoded string; a
|
|
1105
|
+
parse failure is reported as invalid JSON, not a type mismatch.
|
|
1106
|
+
`arguments` overrides the workflow's variables by name. `workspace` pins this
|
|
1107
|
+
call to another workspace without switching the session (where its
|
|
1108
|
+
`output:`/`asset:` references live).
|
|
1109
|
+
|
|
1110
|
+
Bind the acknowledgement to what you quoted: pass
|
|
1111
|
+
{"fingerprint": plan.fingerprint, "minutes": plan.estimate.minutes,
|
|
1112
|
+
"downloads": [...the non-null repos in plan.downloads_required]} from
|
|
1113
|
+
the validate plan; the server refuses with 409, naming the new
|
|
1114
|
+
plan, if the run's shape changed since. Bare true is for a plan
|
|
1115
|
+
that was null."""
|
|
1116
|
+
return diagnose.run_workflow(
|
|
1117
|
+
client,
|
|
1118
|
+
workflow_path=workflow_path,
|
|
1119
|
+
inline_workflow=inline_workflow,
|
|
1120
|
+
workflow=workflow,
|
|
1121
|
+
name=name,
|
|
1122
|
+
arguments=arguments,
|
|
1123
|
+
acknowledged_cost=acknowledged_cost,
|
|
1124
|
+
workspace=workspace,
|
|
1125
|
+
wait_seconds=wait_seconds,
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
# The cap is a number a caller paces against, so the description
|
|
1129
|
+
# states it (as wait_for_job's does, below). replace rather than
|
|
1130
|
+
# format: the docstring spells out a literal {fingerprint, ...} dict.
|
|
1131
|
+
if run_workflow.__doc__: # absent under python -OO
|
|
1132
|
+
run_workflow.__doc__ = run_workflow.__doc__.replace(
|
|
1133
|
+
"{cap}", str(diagnose.MAX_WAIT_SECONDS)
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
def get_job(job_id: str) -> dict:
|
|
1137
|
+
"""Get a job's status, argument warnings, output manifest, error and
|
|
1138
|
+
traceback. The manifest names each step's files the way
|
|
1139
|
+
`get_output_image`, `download_output` and `keep_output` take them,
|
|
1140
|
+
and each entry's `subfolder` says what kind of output the step
|
|
1141
|
+
declared - by convention `final` is the deliverable, `intermediate`
|
|
1142
|
+
the scratch work, and '' a step that said nothing. A step served
|
|
1143
|
+
from the step cache is marked `reused` and reports the earlier run's
|
|
1144
|
+
files. When a job failed, the error and traceback here are what to
|
|
1145
|
+
read before changing anything. `acknowledged` says which form of
|
|
1146
|
+
cost acknowledgement queued the job (`none`, `boolean`, `bound`) and
|
|
1147
|
+
`acknowledged_cost` is the bound `{fingerprint, minutes, downloads}`
|
|
1148
|
+
when there was one."""
|
|
1149
|
+
return diagnose.get_job(client, job_id)
|
|
1150
|
+
|
|
1151
|
+
def get_job_workflow(job_id: str) -> dict:
|
|
1152
|
+
"""Get the workflow a job actually ran. When `realized` is true every
|
|
1153
|
+
mutable input is pinned - the caller's arguments folded into the
|
|
1154
|
+
variables, the seed the run used, stored prompt text inlined, and any
|
|
1155
|
+
`output:.../latest/...` rewritten to the run it resolved to - so the
|
|
1156
|
+
definition reproduces that run however the library changes. When it is
|
|
1157
|
+
false the job predates run tracking and this is the definition as
|
|
1158
|
+
submitted. After a long inline run worth keeping, this then
|
|
1159
|
+
`save_workflow` is how it gets a name."""
|
|
1160
|
+
return diagnose.get_job_workflow(client, job_id)
|
|
1161
|
+
|
|
1162
|
+
def get_job_events(
|
|
1163
|
+
job_id: str,
|
|
1164
|
+
after: int = -1,
|
|
1165
|
+
limit: int = 200,
|
|
1166
|
+
kinds: list[str] | None = None,
|
|
1167
|
+
) -> dict:
|
|
1168
|
+
"""Get a page of a job's progress events - phase transitions, denoise
|
|
1169
|
+
steps, memory readings and log lines. `after` is exclusive: pass back
|
|
1170
|
+
the previous call's `last_seq` to continue. Each event's `at` is
|
|
1171
|
+
seconds since the job started, so where a step's time went is the
|
|
1172
|
+
difference between two events. For 'is it still moving?' the
|
|
1173
|
+
`progress` block on get_job/wait_for_job is cheaper than a page of
|
|
1174
|
+
events. `kinds` (e.g. `["log", "warning"]`, or a warning's `kind`)
|
|
1175
|
+
filters the page - `memory` events otherwise dominate it.
|
|
1176
|
+
|
|
1177
|
+
A `kind: "phase_stall"` entry is a watchdog notice, not progress - it
|
|
1178
|
+
fires every ~30s a phase goes quiet, not evidence of a hang by
|
|
1179
|
+
itself. It carries `seconds_since_last_progress` and
|
|
1180
|
+
`seconds_since_phase_start`; some models are silent for minutes
|
|
1181
|
+
normally - check the model's guide before treating one as a fault."""
|
|
1182
|
+
return diagnose.get_job_events(
|
|
1183
|
+
client, job_id, after=after, limit=limit, kinds=kinds
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
def wait_for_job(job_id: str, timeout_seconds: int = 20) -> dict:
|
|
1187
|
+
"""Block until a job finishes, instead of polling get_job or
|
|
1188
|
+
get_job_events by hand: returns as soon as its status is succeeded,
|
|
1189
|
+
failed or cancelled, or with still_running: true when
|
|
1190
|
+
timeout_seconds elapses first, so you can call again. Queues
|
|
1191
|
+
nothing, so no acknowledged_cost.
|
|
1192
|
+
|
|
1193
|
+
One call blocks for at most {cap} seconds, whatever timeout_seconds
|
|
1194
|
+
asks for - this deployment's cap, set for the tool-call budget the
|
|
1195
|
+
client holds open; a larger value is clamped, not honoured, so
|
|
1196
|
+
budget one call per {cap}s of the job, and one call is enough when
|
|
1197
|
+
{cap} covers its runtime. Every reply says which happened:
|
|
1198
|
+
waited_seconds, timeout_requested_seconds, timeout_applied_seconds
|
|
1199
|
+
and timeout_capped.
|
|
1200
|
+
|
|
1201
|
+
Returns a slim job - status, warnings, error, the manifest once
|
|
1202
|
+
finished - without the arguments (get_job has those). A running job
|
|
1203
|
+
also carries `progress`: step, phase, and
|
|
1204
|
+
`denoise_step`/`denoise_total_steps`, null until the denoise loop
|
|
1205
|
+
starts. Tell a slow run from a stuck one by whether
|
|
1206
|
+
`denoise_step` has moved since a poll minutes ago, not by silence:
|
|
1207
|
+
a video reference's lead-in can run many minutes emitting nothing,
|
|
1208
|
+
and denoise gaps are uneven under a transformer block cache - both
|
|
1209
|
+
normal. If you're also reading get_job_events, a `phase_stall`
|
|
1210
|
+
entry there is the same silence being narrated, not a fault or a
|
|
1211
|
+
sign of progress - it repeats every ~30s the phase stays quiet, so
|
|
1212
|
+
neither seeing one nor watching its event_count climb tells you
|
|
1213
|
+
anything `denoise_step` doesn't already say better. Full diagnosis,
|
|
1214
|
+
and why `denoise_total_steps` can read one less than asked, in
|
|
1215
|
+
WORKFLOW_GUIDE's "The loop", step 5."""
|
|
1216
|
+
return diagnose.wait_for_job(client, job_id, timeout_seconds=timeout_seconds)
|
|
1217
|
+
|
|
1218
|
+
# The cap is a number a caller paces against, so the description states
|
|
1219
|
+
# it rather than saying "well under a generation's runtime".
|
|
1220
|
+
if wait_for_job.__doc__: # absent under python -OO
|
|
1221
|
+
wait_for_job.__doc__ = wait_for_job.__doc__.format(
|
|
1222
|
+
cap=diagnose.MAX_WAIT_SECONDS
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
def cancel_job(job_id: str) -> dict:
|
|
1226
|
+
"""Ask a queued or running job to stop. Cooperative: a running job
|
|
1227
|
+
stops at the next step or denoise-step boundary, not instantly.
|
|
1228
|
+
Deliberately not gated - it ends a cost rather than starting one."""
|
|
1229
|
+
return diagnose.cancel_job(client, job_id)
|
|
1230
|
+
|
|
1231
|
+
def rerun_job(
|
|
1232
|
+
job_id: str, acknowledged_cost: bool | dict = False, new_seed: bool = False
|
|
1233
|
+
) -> dict:
|
|
1234
|
+
"""Queue a fresh job from a previous job's stored specification. This
|
|
1235
|
+
costs GPU time: a rerun is a run - it occupies the machine for
|
|
1236
|
+
minutes and the engine runs one job at a time. Tell the user what
|
|
1237
|
+
will run and get their go-ahead, then pass acknowledged_cost.
|
|
1238
|
+
|
|
1239
|
+
Pass new_seed=true for a different image: a workflow that pins its
|
|
1240
|
+
seed reruns to the same pixels, and the step cache serves that whole
|
|
1241
|
+
run from the earlier one's files (marked `reused`) in a fraction of a
|
|
1242
|
+
second rather than generating anything.
|
|
1243
|
+
|
|
1244
|
+
`acknowledged_cost` takes the same bound form as run_workflow; a
|
|
1245
|
+
fresh seed never changes the fingerprint, so the original plan still
|
|
1246
|
+
binds a new_seed rerun."""
|
|
1247
|
+
return diagnose.rerun_job(
|
|
1248
|
+
client,
|
|
1249
|
+
job_id,
|
|
1250
|
+
acknowledged_cost=acknowledged_cost,
|
|
1251
|
+
new_seed=new_seed,
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
def move_job(
|
|
1255
|
+
job_id: str, direction: Literal["up", "down", "front", "back"]
|
|
1256
|
+
) -> dict:
|
|
1257
|
+
"""Reorder a queued job. Only a job still waiting can move; the one
|
|
1258
|
+
already running cannot."""
|
|
1259
|
+
return diagnose.move_job(client, job_id, direction)
|
|
1260
|
+
|
|
1261
|
+
def export_job(job_id: str, overwrite: bool = False) -> dict:
|
|
1262
|
+
"""Gather one finished job into a directory on the server: the
|
|
1263
|
+
realized workflow, the run's manifest, the job row, a README, and
|
|
1264
|
+
copies of every asset it used, every earlier run's file it read and
|
|
1265
|
+
every file it made. The export copies every output and input file
|
|
1266
|
+
rather than linking them, so a video job's export costs its size
|
|
1267
|
+
again on the server's disk; `total_bytes` in the result reports
|
|
1268
|
+
what was copied. Returns the directory, a zip URL, the file list
|
|
1269
|
+
with sizes and the total. The three JSON files are in the zip, not
|
|
1270
|
+
repeated here - get_job_workflow and get_job serve them individually.
|
|
1271
|
+
THE DIRECTORY IS ON THE MACHINE RUNNING THE SERVER, not on yours.
|
|
1272
|
+
|
|
1273
|
+
`auth_required` says whether opening the zip needs this server's
|
|
1274
|
+
bearer token, a token you cannot attach to someone else's browser
|
|
1275
|
+
or tooling. When it is false, fetch open_url yourself and unpack
|
|
1276
|
+
it into exports/ under the session's working directory - it is
|
|
1277
|
+
the user's deliverable, not a temp file; the archive already
|
|
1278
|
+
unpacks into one folder named after the job id, so do not create that folder first.
|
|
1279
|
+
When it is true, do NOT fetch it: hand open_url to the person and let them open it
|
|
1280
|
+
(`next` says whether it is already absolute or needs the server's
|
|
1281
|
+
address told to them). Individual results stay reachable inline
|
|
1282
|
+
via get_output_image/get_output_audio/get_output_frames either
|
|
1283
|
+
way. Refuses a job that is still running; refuses an existing
|
|
1284
|
+
export unless overwrite=true."""
|
|
1285
|
+
return exports.export_job(client, job_id, overwrite=overwrite)
|
|
1286
|
+
|
|
1287
|
+
tool(get_job, READ_ONLY)
|
|
1288
|
+
tool(get_job_workflow, READ_ONLY)
|
|
1289
|
+
tool(get_job_events, READ_ONLY)
|
|
1290
|
+
tool(wait_for_job, READ_ONLY)
|
|
1291
|
+
for fn in (run_workflow, cancel_job, rerun_job, move_job, export_job):
|
|
1292
|
+
tool(fn, WRITES)
|
|
1293
|
+
|
|
1294
|
+
# -------------------------------------------------------------- models
|
|
1295
|
+
|
|
1296
|
+
def download_model(repo_id: str, acknowledged_cost: bool = False) -> dict:
|
|
1297
|
+
"""Fetch a model repo into the Hugging Face cache. This costs disk
|
|
1298
|
+
and bandwidth: a model repo is commonly tens of gigabytes. Check
|
|
1299
|
+
list_models first - it may already be cached. Tell the user what you
|
|
1300
|
+
are about to fetch and get their go-ahead, then pass
|
|
1301
|
+
acknowledged_cost=true. Returns as soon as the download starts; poll
|
|
1302
|
+
list_downloads for progress."""
|
|
1303
|
+
return models.download_model(
|
|
1304
|
+
client, repo_id, acknowledged_cost=acknowledged_cost
|
|
1305
|
+
)
|
|
1306
|
+
|
|
1307
|
+
def list_downloads() -> dict:
|
|
1308
|
+
"""List model downloads the server is running or recently ran."""
|
|
1309
|
+
return models.list_downloads(client)
|
|
1310
|
+
|
|
1311
|
+
def cancel_download(download_id: str) -> dict:
|
|
1312
|
+
"""Ask a running model download to stop. Partial files stay in the
|
|
1313
|
+
cache and resume if it is retried."""
|
|
1314
|
+
return models.cancel_download(client, download_id)
|
|
1315
|
+
|
|
1316
|
+
def delete_model(repo: str, acknowledged_cost: bool = False) -> dict:
|
|
1317
|
+
"""Delete every cached revision of one model repo. This is not
|
|
1318
|
+
recoverable: getting the model back means downloading it again. Tell
|
|
1319
|
+
the user which repo and how much it frees, get their go-ahead, then
|
|
1320
|
+
pass acknowledged_cost=true. Refused while a job or download is
|
|
1321
|
+
active."""
|
|
1322
|
+
return models.delete_model(client, repo, acknowledged_cost=acknowledged_cost)
|
|
1323
|
+
|
|
1324
|
+
def get_diffusers_state() -> dict:
|
|
1325
|
+
"""Get the installed diffusers version and any update in flight."""
|
|
1326
|
+
return models.get_diffusers_state(client)
|
|
1327
|
+
|
|
1328
|
+
def update_diffusers(acknowledged_cost: bool = False) -> dict:
|
|
1329
|
+
"""Upgrade diffusers to GitHub HEAD. This can break the install: it
|
|
1330
|
+
installs an untagged development build that workflows running today
|
|
1331
|
+
may not survive, and this tool cannot undo it. Report the current
|
|
1332
|
+
version, explain why the update is worth it, get the user's
|
|
1333
|
+
go-ahead, then pass acknowledged_cost=true. Refused while a job is
|
|
1334
|
+
running or queued."""
|
|
1335
|
+
return models.update_diffusers(client, acknowledged_cost=acknowledged_cost)
|
|
1336
|
+
|
|
1337
|
+
tool(list_downloads, READ_ONLY)
|
|
1338
|
+
tool(get_diffusers_state, READ_ONLY)
|
|
1339
|
+
for fn in (download_model, cancel_download, update_diffusers):
|
|
1340
|
+
tool(fn, WRITES)
|
|
1341
|
+
tool(delete_model, DELETES)
|
|
1342
|
+
|
|
1343
|
+
return server
|