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/media.py
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
"""The output directory: hand a generated file back to the agent, or remove
|
|
2
|
+
one.
|
|
3
|
+
|
|
4
|
+
Most output media is served from the /outputs static mount rather than an
|
|
5
|
+
/api route, so these are the tools that reach outside /api - audio is the
|
|
6
|
+
exception, served from the gallery's own `/audio` route so it can answer an
|
|
7
|
+
excerpt (#193). Everything returned
|
|
8
|
+
is downscaled or truncated first, and says so: a full-resolution render or
|
|
9
|
+
an unbounded text file would cost more context than the answer it is meant
|
|
10
|
+
to support.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import io
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import pathlib
|
|
18
|
+
|
|
19
|
+
from PIL import Image
|
|
20
|
+
|
|
21
|
+
from dw_mcp.client import DwApiError, api_path
|
|
22
|
+
|
|
23
|
+
# Roughly 4MB. The cap is on the returned payload's base64 size - the bytes
|
|
24
|
+
# actually sent over MCP - not the raw encoded image, which is smaller by a
|
|
25
|
+
# factor of 3/4. Past this the payload crowds out the conversation it is
|
|
26
|
+
# supposed to inform.
|
|
27
|
+
MAX_RETURNED_BYTES = 4 * 1024 * 1024
|
|
28
|
+
MIN_DIMENSION = 64
|
|
29
|
+
|
|
30
|
+
# Text is cheap next to an image, but an unbounded output file is not:
|
|
31
|
+
# a job that logged its way to a megabyte would otherwise arrive whole.
|
|
32
|
+
MAX_RETURNED_CHARACTERS = 20000
|
|
33
|
+
|
|
34
|
+
# The most pixels an image is decoded at, checked before the decode - a PNG
|
|
35
|
+
# header can claim any size. dw.security.MAX_DECODE_PIXELS's value, kept here
|
|
36
|
+
# because dw_mcp cannot import dw; a test pins the two equal.
|
|
37
|
+
MAX_DECODE_PIXELS = 50_000_000
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_output_image(client, name, max_dimension=768, workspace=None, crop=None):
|
|
41
|
+
"""One image from the output directory, downscaled, as base64 plus the
|
|
42
|
+
sizes it went in and came out at.
|
|
43
|
+
|
|
44
|
+
`crop` is `[x, y, width, height]` in the original's pixels, cut before
|
|
45
|
+
the downscale, so a region of a 2K still comes back at 100% where the
|
|
46
|
+
whole would be shrunk past what a seam or a small element can be read
|
|
47
|
+
at. Clamped to the image; the box actually cut is reported."""
|
|
48
|
+
|
|
49
|
+
def is_image(content_type):
|
|
50
|
+
return not content_type or content_type.startswith("image/")
|
|
51
|
+
|
|
52
|
+
body, content_type = client.get_bytes_if(
|
|
53
|
+
api_path("outputs", name), is_image, workspace=workspace
|
|
54
|
+
)
|
|
55
|
+
if body is None:
|
|
56
|
+
raise DwApiError(
|
|
57
|
+
f"{name} is {content_type}, not an image - this tool returns "
|
|
58
|
+
"images only. Use get_gallery_metadata to inspect other media."
|
|
59
|
+
)
|
|
60
|
+
try:
|
|
61
|
+
image = Image.open(io.BytesIO(body))
|
|
62
|
+
except Exception:
|
|
63
|
+
raise DwApiError(f"{name} could not be decoded as an image.")
|
|
64
|
+
if image.width * image.height > MAX_DECODE_PIXELS:
|
|
65
|
+
raise DwApiError(
|
|
66
|
+
f"{name} is {image.width}x{image.height}, more than the "
|
|
67
|
+
f"{MAX_DECODE_PIXELS:,} pixels this tool decodes."
|
|
68
|
+
)
|
|
69
|
+
try:
|
|
70
|
+
image.load()
|
|
71
|
+
except Exception:
|
|
72
|
+
raise DwApiError(f"{name} could not be decoded as an image.")
|
|
73
|
+
|
|
74
|
+
original_size = [image.width, image.height]
|
|
75
|
+
if crop is not None:
|
|
76
|
+
box = _crop_box(crop, image.width, image.height)
|
|
77
|
+
crop = [box[0], box[1], box[2] - box[0], box[3] - box[1]]
|
|
78
|
+
image = image.crop(box)
|
|
79
|
+
fmt = "JPEG" if (image.format or "").upper() == "JPEG" else "PNG"
|
|
80
|
+
if image.mode not in ("RGB", "L") and fmt == "JPEG":
|
|
81
|
+
image = image.convert("RGB")
|
|
82
|
+
|
|
83
|
+
limit = max(MIN_DIMENSION, int(max_dimension))
|
|
84
|
+
encoded, sized = _encode_within_budget(image, limit, fmt)
|
|
85
|
+
return {
|
|
86
|
+
"name": name,
|
|
87
|
+
"data": base64.b64encode(encoded).decode("ascii"),
|
|
88
|
+
"mime_type": "image/jpeg" if fmt == "JPEG" else "image/png",
|
|
89
|
+
"original_size": original_size,
|
|
90
|
+
"crop": crop,
|
|
91
|
+
"returned_size": [sized.width, sized.height],
|
|
92
|
+
"bytes": len(encoded),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _crop_box(crop, width, height):
|
|
97
|
+
"""`[x, y, w, h]` as Pillow's `(left, upper, right, lower)`, clamped to
|
|
98
|
+
the image. Refused when it is not four non-negative integers, starts
|
|
99
|
+
outside the image, or has nothing in it."""
|
|
100
|
+
try:
|
|
101
|
+
x, y, w, h = (int(v) for v in crop)
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
raise DwApiError(f"crop must be [x, y, width, height] in pixels, got {crop!r}.")
|
|
104
|
+
if x < 0 or y < 0 or w <= 0 or h <= 0:
|
|
105
|
+
raise DwApiError(
|
|
106
|
+
f"crop must have a non-negative origin and a positive size, got {crop!r}."
|
|
107
|
+
)
|
|
108
|
+
if x >= width or y >= height:
|
|
109
|
+
raise DwApiError(
|
|
110
|
+
f"crop origin ({x}, {y}) lies outside the {width}x{height} image."
|
|
111
|
+
)
|
|
112
|
+
return (x, y, min(x + w, width), min(y + h, height))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _encode_within_budget(image, limit, fmt):
|
|
116
|
+
"""Shrink until the base64-encoded bytes fit the ceiling. Two loops
|
|
117
|
+
rather than one calculation because compressed size does not follow
|
|
118
|
+
from pixel count - noise and flat colour differ by an order of
|
|
119
|
+
magnitude. After the first pass, each resize starts from the previous
|
|
120
|
+
pass's already-shrunk result rather than the full-resolution original -
|
|
121
|
+
LANCZOS-from-LANCZOS at half size is fine, and it is never an upscale
|
|
122
|
+
since the limit only ever shrinks."""
|
|
123
|
+
source = image
|
|
124
|
+
while True:
|
|
125
|
+
sized = _fit(source, limit)
|
|
126
|
+
buffer = io.BytesIO()
|
|
127
|
+
sized.save(buffer, format=fmt)
|
|
128
|
+
encoded = buffer.getvalue()
|
|
129
|
+
base64_size = 4 * math.ceil(len(encoded) / 3)
|
|
130
|
+
if base64_size <= MAX_RETURNED_BYTES or limit <= MIN_DIMENSION:
|
|
131
|
+
return encoded, sized
|
|
132
|
+
limit = max(MIN_DIMENSION, limit // 2)
|
|
133
|
+
source = sized
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _fit(image, limit):
|
|
137
|
+
"""A copy no larger than `limit` on its longest side, aspect preserved.
|
|
138
|
+
An image already inside the limit is returned as-is - upscaling would
|
|
139
|
+
invent detail the model would then reason about."""
|
|
140
|
+
longest = max(image.width, image.height)
|
|
141
|
+
if longest <= limit:
|
|
142
|
+
return image
|
|
143
|
+
scale = limit / longest
|
|
144
|
+
return image.resize(
|
|
145
|
+
(max(1, round(image.width * scale)), max(1, round(image.height * scale))),
|
|
146
|
+
Image.LANCZOS,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_output_audio(client, name, start=None, duration=None, workspace=None):
|
|
151
|
+
"""One soundtrack from the gallery as base64 - an audio output, or the
|
|
152
|
+
track muxed into a video (#193) - for a clip short enough to fit
|
|
153
|
+
MAX_RETURNED_BYTES whole, or an excerpt of one that is not. In its own
|
|
154
|
+
encoding when an audio file is served whole, WAV when extracted from a
|
|
155
|
+
video or excerpted; `mime_type` says which.
|
|
156
|
+
|
|
157
|
+
Audio is not resized the way an image is - there is no downscale of a
|
|
158
|
+
waveform that keeps it meaningful to listen to - so a whole clip over
|
|
159
|
+
budget is refused rather than truncated (#204). The way to hear part of
|
|
160
|
+
a long track is to *ask* for the part: `start` and `duration` in
|
|
161
|
+
seconds, and the answer names what it cut in `excerpt`, so a slice is
|
|
162
|
+
never mistaken for the whole."""
|
|
163
|
+
|
|
164
|
+
def is_audio(content_type):
|
|
165
|
+
return bool(content_type) and content_type.startswith("audio/")
|
|
166
|
+
|
|
167
|
+
params = {}
|
|
168
|
+
if start is not None:
|
|
169
|
+
params["start"] = start
|
|
170
|
+
if duration is not None:
|
|
171
|
+
params["duration"] = duration
|
|
172
|
+
body, content_type, headers = client.get_media_if(
|
|
173
|
+
api_path("api", "gallery", name, "audio"),
|
|
174
|
+
is_audio,
|
|
175
|
+
workspace=workspace,
|
|
176
|
+
params=params,
|
|
177
|
+
max_bytes=MAX_RETURNED_BYTES,
|
|
178
|
+
)
|
|
179
|
+
if body is None and not is_audio(content_type):
|
|
180
|
+
raise DwApiError(
|
|
181
|
+
f"{name} answered {content_type or 'no declared type'}, not audio - "
|
|
182
|
+
"this tool returns a soundtrack only. Use get_output_image for "
|
|
183
|
+
"an image, or get_gallery_metadata for other media."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Sized from the declared content-length when the client refused to
|
|
187
|
+
# read the body on it, else from the body it read (an answer that
|
|
188
|
+
# declared no length). The server refuses a whole track it can size
|
|
189
|
+
# from the file's headers with a 413 before either, and the client
|
|
190
|
+
# surfaces that detail as is; this is the same advice for the rest.
|
|
191
|
+
raw_size = len(body) if body is not None else int(headers["content-length"])
|
|
192
|
+
base64_size = 4 * math.ceil(raw_size / 3)
|
|
193
|
+
if base64_size > MAX_RETURNED_BYTES:
|
|
194
|
+
raise DwApiError(
|
|
195
|
+
f"{name} is {raw_size} bytes, which would be {base64_size} "
|
|
196
|
+
f"bytes base64-encoded - over the {MAX_RETURNED_BYTES} byte "
|
|
197
|
+
"limit for an inline clip. Ask for an excerpt with `start` and "
|
|
198
|
+
"`duration` (seconds) - get_gallery_metadata's envelope says "
|
|
199
|
+
"where to look - or use download_output for the whole file."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
excerpt = None
|
|
203
|
+
if "x-dw-excerpt-start" in headers:
|
|
204
|
+
excerpt = {
|
|
205
|
+
"start": float(headers["x-dw-excerpt-start"]),
|
|
206
|
+
"duration": float(headers["x-dw-excerpt-duration"]),
|
|
207
|
+
"of": _float_header(headers, "x-dw-duration"),
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
"name": name,
|
|
211
|
+
"data": base64.b64encode(body).decode("ascii"),
|
|
212
|
+
"mime_type": content_type,
|
|
213
|
+
"bytes": len(body),
|
|
214
|
+
"duration_seconds": _float_header(headers, "x-dw-duration"),
|
|
215
|
+
"excerpt": excerpt,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _float_header(headers, key):
|
|
220
|
+
value = headers.get(key)
|
|
221
|
+
try:
|
|
222
|
+
return float(value) if value not in (None, "") else None
|
|
223
|
+
except ValueError:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def get_output_frames(
|
|
228
|
+
client,
|
|
229
|
+
name,
|
|
230
|
+
at=None,
|
|
231
|
+
seams=None,
|
|
232
|
+
count=None,
|
|
233
|
+
boundaries=None,
|
|
234
|
+
names=None,
|
|
235
|
+
max_dimension=512,
|
|
236
|
+
hear=None,
|
|
237
|
+
workspace=None,
|
|
238
|
+
crop=None,
|
|
239
|
+
):
|
|
240
|
+
"""Frames of a generated video as images - the way to *see* a clip when
|
|
241
|
+
there is no video content type to return it as (#193, #210). One
|
|
242
|
+
selector per call: `at` (moments: seconds, or "frame:N"), `count` (an
|
|
243
|
+
evenly spaced contact sheet) or `seams` (True, or seam numbers from 1:
|
|
244
|
+
the last frame before and the first frame after each boundary, side by
|
|
245
|
+
side). `boundaries` is the list of frame indexes each shot after the
|
|
246
|
+
first starts at - the running sum of the shots' `frame_count` from
|
|
247
|
+
`get_gallery_metadata` on their own files - `names` the shots' names.
|
|
248
|
+
Without `boundaries`, an output joined from shots uses the boundaries
|
|
249
|
+
its run recorded (`get_gallery_metadata`'s `media.shots`).
|
|
250
|
+
|
|
251
|
+
`crop` is `[x, y, width, height]` in the video's own source pixels -
|
|
252
|
+
the same convention `get_output_image` uses - resolved once against
|
|
253
|
+
the clip's actual dimensions and cut from every sampled frame before
|
|
254
|
+
any stamping, fitting or composing, so it names the same region
|
|
255
|
+
whatever `max_dimension` (or a contact sheet's own tiling) does to the
|
|
256
|
+
result.
|
|
257
|
+
|
|
258
|
+
Every tile is fitted to `max_dimension`; when the whole answer would
|
|
259
|
+
still exceed MAX_RETURNED_BYTES the tiles are shrunk *together* - the
|
|
260
|
+
same dimension for all, halved until they fit - rather than any being
|
|
261
|
+
dropped, and `downscaled_to` says what they were shrunk to. A seam
|
|
262
|
+
pair at half size is still a seam pair; a seam pair missing is a
|
|
263
|
+
different answer.
|
|
264
|
+
|
|
265
|
+
`hear`'s excerpts are capped the same way in aggregate: each one is
|
|
266
|
+
already under `get_output_audio`'s own per-clip budget, but with up to
|
|
267
|
+
MAX_FRAME_MOMENTS tiles the excerpts summed could still dwarf
|
|
268
|
+
MAX_RETURNED_BYTES, so fetching stops once the running total would push
|
|
269
|
+
past it - the remaining tiles keep their frame but carry an
|
|
270
|
+
`audio_error` saying so, and `audio_truncated` is true."""
|
|
271
|
+
chosen = [
|
|
272
|
+
key for key, value in (("at", at), ("count", count), ("seams", seams)) if value
|
|
273
|
+
]
|
|
274
|
+
if len(chosen) != 1:
|
|
275
|
+
raise DwApiError(
|
|
276
|
+
"Pass exactly one of `at`, `count` or `seams`"
|
|
277
|
+
+ (f" - got {', '.join(chosen)}" if chosen else "")
|
|
278
|
+
)
|
|
279
|
+
if hear is not None:
|
|
280
|
+
if not at:
|
|
281
|
+
raise DwApiError(
|
|
282
|
+
"`hear` takes seconds of soundtrack around each `at` moment - pass `at`"
|
|
283
|
+
)
|
|
284
|
+
if float(hear) <= 0:
|
|
285
|
+
raise DwApiError("`hear` is a positive number of seconds")
|
|
286
|
+
params = [("max_dimension", str(max(MIN_DIMENSION, int(max_dimension))))]
|
|
287
|
+
# a list of pairs, turned into a dict by the client - so no key repeats
|
|
288
|
+
if at:
|
|
289
|
+
params.append(("at", ",".join(str(moment) for moment in at)))
|
|
290
|
+
elif count:
|
|
291
|
+
params.append(("count", str(int(count))))
|
|
292
|
+
else:
|
|
293
|
+
params.append(
|
|
294
|
+
("seams", "true" if seams is True else ",".join(str(s) for s in seams))
|
|
295
|
+
)
|
|
296
|
+
if boundaries:
|
|
297
|
+
params.append(("boundaries", ",".join(str(int(b)) for b in boundaries)))
|
|
298
|
+
if names:
|
|
299
|
+
params.append(("names", ",".join(names)))
|
|
300
|
+
if crop is not None:
|
|
301
|
+
params.append(("crop", ",".join(str(v) for v in crop)))
|
|
302
|
+
|
|
303
|
+
body = client.get_json(
|
|
304
|
+
api_path("api", "gallery", name, "frames"), params=params, workspace=workspace
|
|
305
|
+
)
|
|
306
|
+
tiles = body.get("tiles", [])
|
|
307
|
+
tiles, downscaled_to = _fit_tiles_within_budget(tiles)
|
|
308
|
+
audio_truncated = False
|
|
309
|
+
if hear is not None:
|
|
310
|
+
span = float(hear)
|
|
311
|
+
audio_bytes_so_far = 0
|
|
312
|
+
budget_exceeded = False
|
|
313
|
+
for tile in tiles:
|
|
314
|
+
if budget_exceeded:
|
|
315
|
+
tile["audio_error"] = "skipped - would exceed the response size budget"
|
|
316
|
+
audio_truncated = True
|
|
317
|
+
continue
|
|
318
|
+
start = max(0.0, float(tile["seconds"]) - span / 2)
|
|
319
|
+
try:
|
|
320
|
+
audio = get_output_audio(
|
|
321
|
+
client, name, start=start, duration=span, workspace=workspace
|
|
322
|
+
)
|
|
323
|
+
except DwApiError as e:
|
|
324
|
+
tile["audio_error"] = str(e)
|
|
325
|
+
continue
|
|
326
|
+
# A per-tile cap (get_output_audio's own MAX_RETURNED_BYTES check)
|
|
327
|
+
# bounds one excerpt; nothing summed the excerpts against the
|
|
328
|
+
# overall response budget, so up to MAX_FRAME_MOMENTS tiles times
|
|
329
|
+
# `hear` seconds each could dwarf it. This is that aggregate cap,
|
|
330
|
+
# on top of - not instead of - the per-tile one.
|
|
331
|
+
if audio_bytes_so_far + len(audio["data"]) > MAX_RETURNED_BYTES:
|
|
332
|
+
tile["audio_error"] = "skipped - would exceed the response size budget"
|
|
333
|
+
audio_truncated = True
|
|
334
|
+
budget_exceeded = True
|
|
335
|
+
continue
|
|
336
|
+
audio_bytes_so_far += len(audio["data"])
|
|
337
|
+
tile["audio"] = {
|
|
338
|
+
"data": audio["data"],
|
|
339
|
+
"mime_type": audio["mime_type"],
|
|
340
|
+
"excerpt": audio["excerpt"],
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
"name": name,
|
|
344
|
+
"frame_count": body.get("frame_count"),
|
|
345
|
+
"fps": body.get("fps"),
|
|
346
|
+
"tiles": tiles,
|
|
347
|
+
"downscaled_to": downscaled_to,
|
|
348
|
+
"hear": hear,
|
|
349
|
+
"audio_truncated": audio_truncated,
|
|
350
|
+
"crop": body.get("crop"),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _fit_tiles_within_budget(tiles):
|
|
355
|
+
"""Shrink every tile by the same factor until their base64 sizes sum
|
|
356
|
+
to MAX_RETURNED_BYTES or less. Returns (tiles, downscaled_to) with
|
|
357
|
+
downscaled_to None when nothing had to shrink."""
|
|
358
|
+
total = sum(len(tile["data"]) for tile in tiles)
|
|
359
|
+
if total <= MAX_RETURNED_BYTES or not tiles:
|
|
360
|
+
return tiles, None
|
|
361
|
+
images = [Image.open(io.BytesIO(base64.b64decode(tile["data"]))) for tile in tiles]
|
|
362
|
+
for image in images:
|
|
363
|
+
image.load()
|
|
364
|
+
limit = max(max(image.width, image.height) for image in images)
|
|
365
|
+
while True:
|
|
366
|
+
limit = max(MIN_DIMENSION, limit // 2)
|
|
367
|
+
shrunk = []
|
|
368
|
+
for tile, image in zip(tiles, images):
|
|
369
|
+
sized = _fit(image, limit)
|
|
370
|
+
buffer = io.BytesIO()
|
|
371
|
+
sized.save(buffer, format="PNG")
|
|
372
|
+
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
373
|
+
shrunk.append(
|
|
374
|
+
{**tile, "data": encoded, "width": sized.width, "height": sized.height}
|
|
375
|
+
)
|
|
376
|
+
if (
|
|
377
|
+
sum(len(t["data"]) for t in shrunk) <= MAX_RETURNED_BYTES
|
|
378
|
+
or limit <= MIN_DIMENSION
|
|
379
|
+
):
|
|
380
|
+
return shrunk, limit
|
|
381
|
+
images = [Image.open(io.BytesIO(base64.b64decode(t["data"]))) for t in shrunk]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def get_output_text(
|
|
385
|
+
client, name, max_characters=MAX_RETURNED_CHARACTERS, workspace=None
|
|
386
|
+
):
|
|
387
|
+
"""One text output from the output directory - the form a prompt
|
|
388
|
+
enhancement and any `text/plain` result arrive in."""
|
|
389
|
+
|
|
390
|
+
def is_text(content_type):
|
|
391
|
+
kind = content_type.split(";")[0].strip().lower()
|
|
392
|
+
return kind.startswith("text/") or kind == "application/json"
|
|
393
|
+
|
|
394
|
+
body, content_type = client.get_bytes_if(
|
|
395
|
+
api_path("outputs", name), is_text, workspace=workspace
|
|
396
|
+
)
|
|
397
|
+
if body is None:
|
|
398
|
+
raise DwApiError(
|
|
399
|
+
f"{name} is {content_type or 'of no declared type'}, not text - "
|
|
400
|
+
"this tool returns text only. Use get_output_image for an image, "
|
|
401
|
+
"or get_gallery_metadata for other media."
|
|
402
|
+
)
|
|
403
|
+
# A file the server labels text but that is not valid UTF-8 is damaged
|
|
404
|
+
# output, and reading it that way is more use than a decoding traceback
|
|
405
|
+
text = body.decode("utf-8", errors="replace")
|
|
406
|
+
limit = max(1, int(max_characters))
|
|
407
|
+
return {
|
|
408
|
+
"name": name,
|
|
409
|
+
"text": text[:limit],
|
|
410
|
+
"content_type": content_type,
|
|
411
|
+
"characters": len(text),
|
|
412
|
+
"truncated": len(text) > limit,
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
ASSESSMENT_PROBES = ("analyze_shots", "analyze_seams", "analyze_sync_drift")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def assess_output(client, name, probe=None, detail=False, workspace=None):
|
|
420
|
+
"""Measure a finished output or asset and say where to look (#388).
|
|
421
|
+
|
|
422
|
+
The server runs the assessment probes on one decode, beside any GPU
|
|
423
|
+
job rather than behind it. `probe` is checked against the whitelist
|
|
424
|
+
before anything else is read."""
|
|
425
|
+
if probe is not None and probe not in ASSESSMENT_PROBES:
|
|
426
|
+
raise DwApiError(
|
|
427
|
+
f"Unknown probe {probe!r} - one of {', '.join(ASSESSMENT_PROBES)}"
|
|
428
|
+
)
|
|
429
|
+
params = {}
|
|
430
|
+
if probe is not None:
|
|
431
|
+
params["probe"] = probe
|
|
432
|
+
if detail:
|
|
433
|
+
params["detail"] = "true"
|
|
434
|
+
return client.get_json(
|
|
435
|
+
api_path("api", "gallery", name, "assess"),
|
|
436
|
+
params=params or None,
|
|
437
|
+
workspace=workspace,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def delete_output(client, name=None, workspace=None, job_id=None):
|
|
442
|
+
"""Remove one file from the output directory. The gallery is the output
|
|
443
|
+
directory read back, so this is where a delete belongs.
|
|
444
|
+
|
|
445
|
+
The run directory goes too once its last media file is gone, sidecars
|
|
446
|
+
included, and a `<workflow>/<run id>` name removes a whole run - what a
|
|
447
|
+
failed run, which has a manifest and nothing else, needs (#134).
|
|
448
|
+
|
|
449
|
+
`job_id` is the other handle on a whole run: the job record carries
|
|
450
|
+
the `<workflow>/<run id>` its run wrote (`run_dir`, relative to the
|
|
451
|
+
output root), so the run is deleted without the caller listing the
|
|
452
|
+
gallery to find the name. Exactly one of `name` / `job_id`. A job that
|
|
453
|
+
never wrote a run directory - refused before it started, or from
|
|
454
|
+
before run tracking - has nothing to delete and says so. `workspace`
|
|
455
|
+
pins the call as it always has; without one, a job's delete goes to
|
|
456
|
+
the workspace the job itself ran in, since that is where its run
|
|
457
|
+
directory is."""
|
|
458
|
+
if (name is None) == (job_id is None):
|
|
459
|
+
raise DwApiError(
|
|
460
|
+
"Provide exactly one of `name` (a gallery name or a "
|
|
461
|
+
"`<workflow>/<run id>` run directory) or `job_id` (the run that "
|
|
462
|
+
"job wrote, deleted whole)."
|
|
463
|
+
)
|
|
464
|
+
if job_id is None:
|
|
465
|
+
return client.delete_json(api_path("api", "gallery", name), workspace=workspace)
|
|
466
|
+
|
|
467
|
+
job = client.get_json(api_path("api", "jobs", job_id))
|
|
468
|
+
run_dir = job.get("run_dir")
|
|
469
|
+
if not run_dir:
|
|
470
|
+
raise DwApiError(
|
|
471
|
+
f"Job {job_id} ({job.get('status') or 'unknown status'}) has no "
|
|
472
|
+
"run directory to delete - it never started a run, or predates "
|
|
473
|
+
"run tracking. If it left files, list_gallery(only_orphans=True) "
|
|
474
|
+
"finds the run directory by name."
|
|
475
|
+
)
|
|
476
|
+
# The record's path is slash-separated relative to the output root -
|
|
477
|
+
# exactly the run-directory form the gallery route accepts
|
|
478
|
+
run_dir = "/".join(part for part in str(run_dir).split("/") if part)
|
|
479
|
+
target = workspace or job.get("workspace") or None
|
|
480
|
+
deleted = client.delete_json(api_path("api", "gallery", run_dir), workspace=target)
|
|
481
|
+
return {**deleted, "job_id": job_id, "run_dir": run_dir}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _remote_root(client, workspace=None):
|
|
485
|
+
"""The workspace a remote write is confined to, or None when local.
|
|
486
|
+
|
|
487
|
+
Only the mounted MCP surface is remote: there the tool runs inside
|
|
488
|
+
dw.serve, so the path a caller names is a path on the operator's box
|
|
489
|
+
rather than on its own machine. A stdio `dw-mcp` returns None and keeps
|
|
490
|
+
writing wherever the user can.
|
|
491
|
+
|
|
492
|
+
`workspace` is an explicit per-call override (download_output's own
|
|
493
|
+
`workspace` argument); when omitted, `client.get_json`'s `_scoped`
|
|
494
|
+
already falls back to the session's own pin (#389).
|
|
495
|
+
"""
|
|
496
|
+
if not getattr(client, "mounted", False):
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
directories = (
|
|
500
|
+
client.get_json("/api/server", workspace=workspace).get("directories")
|
|
501
|
+
) or {}
|
|
502
|
+
root = directories.get("workspace")
|
|
503
|
+
if not root:
|
|
504
|
+
raise DwApiError(
|
|
505
|
+
"This server cannot say where its workspace is, so it will not "
|
|
506
|
+
"write a file for you. Use the url list_gallery reports, "
|
|
507
|
+
"get_output_image / get_output_audio / get_output_text, or "
|
|
508
|
+
"keep_output."
|
|
509
|
+
)
|
|
510
|
+
return os.path.realpath(os.path.abspath(os.path.expanduser(str(root))))
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _confine(destination, root):
|
|
514
|
+
"""Refuse a destination outside `root`, on the resolved real path.
|
|
515
|
+
|
|
516
|
+
Containment is on realpath, not on a substring: an absolute path or a
|
|
517
|
+
'~' needs no '..' to reach anywhere the server process can write (#113),
|
|
518
|
+
and a symlink inside the workspace would otherwise carry the write out.
|
|
519
|
+
"""
|
|
520
|
+
# realpath of the nearest existing ancestor: the file itself usually does
|
|
521
|
+
# not exist yet, and realpath of a missing path leaves symlinks in its
|
|
522
|
+
# existing prefix unresolved on some platforms
|
|
523
|
+
probe = destination
|
|
524
|
+
while not os.path.exists(probe) and os.path.dirname(probe) != probe:
|
|
525
|
+
probe = os.path.dirname(probe)
|
|
526
|
+
resolved = os.path.join(
|
|
527
|
+
os.path.realpath(probe), os.path.relpath(destination, probe)
|
|
528
|
+
)
|
|
529
|
+
resolved = os.path.normpath(resolved)
|
|
530
|
+
if resolved != root and not resolved.startswith(root + os.sep):
|
|
531
|
+
raise DwApiError(
|
|
532
|
+
f"Refusing to write {destination} - this MCP endpoint is served "
|
|
533
|
+
f"by dw.serve, so the file would land on the server, where a "
|
|
534
|
+
f"destination is confined to the workspace ({root}). Pass a "
|
|
535
|
+
f"relative destination, or - to see the file where you are - use "
|
|
536
|
+
f"the url list_gallery reports, get_output_image / "
|
|
537
|
+
f"get_output_audio / get_output_text for inline content, or "
|
|
538
|
+
f"keep_output to make it an asset for a later workflow."
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def download_output(client, name, destination=None, overwrite=False, workspace=None):
|
|
543
|
+
"""Fetch one output file and save it to local disk, for an agent that
|
|
544
|
+
wants the artifact itself rather than a description of it.
|
|
545
|
+
|
|
546
|
+
Unlike get_output_image/get_output_audio/get_output_text, this accepts
|
|
547
|
+
any content type and returns nothing to the conversation but a manifest
|
|
548
|
+
of where the file landed - the point is a file on disk, not a payload
|
|
549
|
+
in context. It is also the one tool in this package that writes a
|
|
550
|
+
local file, and the body is streamed to disk in chunks rather than
|
|
551
|
+
buffered whole, since it exists for files (large videos) the inline
|
|
552
|
+
tools can't return.
|
|
553
|
+
|
|
554
|
+
`destination` may be a full file path, a directory (the output's own
|
|
555
|
+
basename is used inside it), or omitted (saved to the current working
|
|
556
|
+
directory under its own basename). '~' expands to the user's home
|
|
557
|
+
directory. Missing parent directories are created. A `destination`
|
|
558
|
+
containing a '..' path segment is refused. An existing file at the
|
|
559
|
+
resolved path is left alone unless `overwrite=True`.
|
|
560
|
+
|
|
561
|
+
Over a `dw.serve --mcp` endpoint the file lands on the *server*, not on
|
|
562
|
+
the calling agent's machine, so there the destination is confined to that
|
|
563
|
+
workspace: an absolute or '~' path outside it is refused rather than
|
|
564
|
+
written (#113). An omitted `destination` is refused outright there
|
|
565
|
+
rather than defaulting into the workspace root - a file dropped loose in
|
|
566
|
+
the root has no run to delete it with and nothing names it back as an
|
|
567
|
+
output (#353); pass an explicit destination inside the workspace to save
|
|
568
|
+
one anyway. A stdio `dw-mcp` keeps writing anywhere the user can, and an
|
|
569
|
+
omitted `destination` keeps defaulting to the current working directory,
|
|
570
|
+
because there "local disk" is genuinely their own.
|
|
571
|
+
"""
|
|
572
|
+
root = _remote_root(client, workspace=workspace)
|
|
573
|
+
if destination is None:
|
|
574
|
+
if root:
|
|
575
|
+
raise DwApiError(
|
|
576
|
+
"destination is required over a dw.serve --mcp endpoint - "
|
|
577
|
+
"omitting it would drop the file loose in the workspace "
|
|
578
|
+
"root, where nothing can find or delete it later. Pass an "
|
|
579
|
+
"explicit destination inside the workspace, or use the url "
|
|
580
|
+
"list_gallery reports, get_output_image / get_output_audio / "
|
|
581
|
+
"get_output_frames for inline content, or keep_output to "
|
|
582
|
+
"make it a named asset instead."
|
|
583
|
+
)
|
|
584
|
+
destination = os.path.basename(name)
|
|
585
|
+
destination = os.path.expanduser(destination)
|
|
586
|
+
if ".." in pathlib.PurePath(destination).parts:
|
|
587
|
+
raise DwApiError(
|
|
588
|
+
f"destination {destination!r} contains a '..' path segment, "
|
|
589
|
+
"which is refused."
|
|
590
|
+
)
|
|
591
|
+
if os.path.isdir(destination) or destination.endswith(os.sep):
|
|
592
|
+
destination = os.path.join(destination, os.path.basename(name))
|
|
593
|
+
# A relative destination is joined onto whichever directory is "here" for
|
|
594
|
+
# this transport: the caller's own working directory for stdio, the
|
|
595
|
+
# server's workspace when the tool runs inside dw.serve - where the
|
|
596
|
+
# process's cwd is an implementation detail the caller never chose
|
|
597
|
+
destination = (
|
|
598
|
+
os.path.abspath(os.path.join(root, destination))
|
|
599
|
+
if root and not os.path.isabs(destination)
|
|
600
|
+
else os.path.abspath(destination)
|
|
601
|
+
)
|
|
602
|
+
if root:
|
|
603
|
+
_confine(destination, root)
|
|
604
|
+
|
|
605
|
+
if os.path.exists(destination) and not overwrite:
|
|
606
|
+
raise DwApiError(
|
|
607
|
+
f"{destination} already exists. Pass overwrite=True to replace it."
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
parent = os.path.dirname(destination)
|
|
611
|
+
try:
|
|
612
|
+
if parent:
|
|
613
|
+
os.makedirs(parent, exist_ok=True)
|
|
614
|
+
content_type, bytes_written = client.stream_to_file(
|
|
615
|
+
api_path("outputs", name), destination, workspace=workspace
|
|
616
|
+
)
|
|
617
|
+
except OSError as e:
|
|
618
|
+
# A client-side path handed to a `dw.serve --mcp` endpoint lands
|
|
619
|
+
# here: the write happens on the server, so a permission or
|
|
620
|
+
# missing-volume error is the surest sign the agent is on another
|
|
621
|
+
# machine. Say so, rather than letting the OSError surface as an
|
|
622
|
+
# anonymous "Error executing tool".
|
|
623
|
+
raise DwApiError(
|
|
624
|
+
f"Could not write {destination} on the machine running the MCP "
|
|
625
|
+
f"server ({e.strerror or e}). This tool saves on that machine - "
|
|
626
|
+
"over a dw.serve --mcp endpoint that is the GPU box, not where "
|
|
627
|
+
"you are. To see the file from here use the url list_gallery "
|
|
628
|
+
"reports, get_output_image / get_output_audio / get_output_text "
|
|
629
|
+
"for inline content, or keep_output to make it an asset for a "
|
|
630
|
+
"later workflow."
|
|
631
|
+
) from e
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
"name": name,
|
|
635
|
+
"saved_to": destination,
|
|
636
|
+
"content_type": content_type,
|
|
637
|
+
"bytes": bytes_written,
|
|
638
|
+
}
|