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.
Files changed (260) hide show
  1. diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
  2. diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
  3. diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
  4. diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
  5. diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
  6. diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
  7. dw/__init__.py +440 -0
  8. dw/adapter_compatibility.py +226 -0
  9. dw/arguments.py +1231 -0
  10. dw/assessment_rules.py +159 -0
  11. dw/assets.py +130 -0
  12. dw/cache_blocks.json +16 -0
  13. dw/cache_blocks.py +146 -0
  14. dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
  15. dw/content_types.py +150 -0
  16. dw/dissolve_frame_errors.py +121 -0
  17. dw/docs/ACCELERATION.md +352 -0
  18. dw/docs/AGENT_LOOP.md +95 -0
  19. dw/docs/DEPENDENCIES.md +91 -0
  20. dw/docs/IP_ADAPTER.md +109 -0
  21. dw/docs/LORAS.md +131 -0
  22. dw/docs/MCP.md +517 -0
  23. dw/docs/PROMPT_WEIGHTING.md +78 -0
  24. dw/docs/QUANTIZATION.md +230 -0
  25. dw/docs/RECIPES_24GB.md +201 -0
  26. dw/docs/RELEASING.md +195 -0
  27. dw/docs/REMOTE.md +140 -0
  28. dw/docs/REPL_COMMANDS.md +121 -0
  29. dw/docs/REPL_WORKER_GUIDE.md +51 -0
  30. dw/docs/SECURITY.md +272 -0
  31. dw/docs/SECURITY_QUICKREF.md +112 -0
  32. dw/docs/SERVER.md +679 -0
  33. dw/docs/TASKS.md +1741 -0
  34. dw/docs/TESTING.md +71 -0
  35. dw/docs/WORKFLOW_GUIDE.md +2038 -0
  36. dw/docs/WORKSPACES.md +316 -0
  37. dw/download_watch.py +335 -0
  38. dw/elision.py +306 -0
  39. dw/events.py +275 -0
  40. dw/for_each.py +409 -0
  41. dw/host_memory.py +258 -0
  42. dw/host_memory_projection.py +230 -0
  43. dw/hub_cache.py +432 -0
  44. dw/introspection.py +1228 -0
  45. dw/kernel_availability.py +208 -0
  46. dw/locations.py +599 -0
  47. dw/log_setup.py +45 -0
  48. dw/loudness.py +82 -0
  49. dw/media_audio.py +217 -0
  50. dw/media_frames.py +367 -0
  51. dw/media_info.py +297 -0
  52. dw/pipeline_processors/chain.py +821 -0
  53. dw/pipeline_processors/config_objects.py +237 -0
  54. dw/pipeline_processors/pipeline.py +2297 -0
  55. dw/pipeline_processors/remote.py +46 -0
  56. dw/plan.py +920 -0
  57. dw/previous_results.py +411 -0
  58. dw/probe_paths.py +59 -0
  59. dw/prompt_schema.json +48 -0
  60. dw/prompt_weighting.py +378 -0
  61. dw/prompts.py +159 -0
  62. dw/realize.py +250 -0
  63. dw/reference_limits.py +215 -0
  64. dw/reference_names.py +125 -0
  65. dw/repl.py +338 -0
  66. dw/repl_commands.py +836 -0
  67. dw/repl_worker.py +159 -0
  68. dw/result.py +1720 -0
  69. dw/result_fps.py +82 -0
  70. dw/run.py +162 -0
  71. dw/runs.py +768 -0
  72. dw/scalar_result_validation.py +97 -0
  73. dw/schema.py +283 -0
  74. dw/security.py +1038 -0
  75. dw/select_validation.py +115 -0
  76. dw/serve.py +277 -0
  77. dw/server/__init__.py +2 -0
  78. dw/server/app.py +4586 -0
  79. dw/server/assess.py +132 -0
  80. dw/server/catalog_shape.py +487 -0
  81. dw/server/enhancers.py +129 -0
  82. dw/server/exports.py +480 -0
  83. dw/server/guides.py +257 -0
  84. dw/server/jobs.py +1561 -0
  85. dw/server/mcp_mount.py +95 -0
  86. dw/server/netinfo.py +124 -0
  87. dw/server/observed_cost.py +379 -0
  88. dw/server/sysinfo.py +71 -0
  89. dw/server/ui/assets/abap-08VXUWAP.js +1 -0
  90. dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
  91. dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
  92. dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
  93. dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
  94. dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
  95. dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
  96. dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
  97. dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
  98. dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
  99. dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
  100. dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
  101. dw/server/ui/assets/css-DIMkf-bt.js +3 -0
  102. dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
  103. dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
  104. dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
  105. dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
  106. dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
  107. dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
  108. dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
  109. dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
  110. dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
  111. dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
  112. dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
  113. dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
  114. dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
  115. dw/server/ui/assets/go-C-y9NEjX.js +1 -0
  116. dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
  117. dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
  118. dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
  119. dw/server/ui/assets/html-PhsdjHSr.js +1 -0
  120. dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
  121. dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
  122. dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
  123. dw/server/ui/assets/index-DgrYhQd9.js +43 -0
  124. dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
  125. dw/server/ui/assets/java-BEtHBSE6.js +1 -0
  126. dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
  127. dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
  128. dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
  129. dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
  130. dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
  131. dw/server/ui/assets/less-B9JPFI3C.js +2 -0
  132. dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
  133. dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
  134. dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
  135. dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
  136. dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
  137. dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
  138. dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
  139. dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
  140. dw/server/ui/assets/monaco--ixms01u.css +1 -0
  141. dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
  142. dw/server/ui/assets/msdax-DauUninz.js +1 -0
  143. dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
  144. dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
  145. dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
  146. dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
  147. dw/server/ui/assets/perl-oz_6vUea.js +1 -0
  148. dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
  149. dw/server/ui/assets/php-nr791fC2.js +1 -0
  150. dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
  151. dw/server/ui/assets/postiats-43DmfD33.js +1 -0
  152. dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
  153. dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
  154. dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
  155. dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
  156. dw/server/ui/assets/python-Bcn70HdC.js +1 -0
  157. dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
  158. dw/server/ui/assets/r-BwWrilGY.js +1 -0
  159. dw/server/ui/assets/razor-D1HmNnby.js +1 -0
  160. dw/server/ui/assets/redis-ClamHrr6.js +1 -0
  161. dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
  162. dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
  163. dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
  164. dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
  165. dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
  166. dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
  167. dw/server/ui/assets/scheme-BeGwcela.js +1 -0
  168. dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
  169. dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
  170. dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
  171. dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
  172. dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
  173. dw/server/ui/assets/sql-NEE52Syq.js +1 -0
  174. dw/server/ui/assets/st-DbInun42.js +1 -0
  175. dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
  176. dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
  177. dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
  178. dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
  179. dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
  180. dw/server/ui/assets/twig-De2hgUGE.js +1 -0
  181. dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
  182. dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
  183. dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
  184. dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
  185. dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
  186. dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
  187. dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
  188. dw/server/ui/index.html +17 -0
  189. dw/server/updater.py +192 -0
  190. dw/settings.py +98 -0
  191. dw/shot_span_preflight.py +116 -0
  192. dw/shots.py +359 -0
  193. dw/slice_preflight.py +148 -0
  194. dw/step.py +187 -0
  195. dw/step_cache.py +442 -0
  196. dw/subfolders.py +107 -0
  197. dw/task_domains.py +307 -0
  198. dw/tasks/assess.py +826 -0
  199. dw/tasks/audio_transcription.py +88 -0
  200. dw/tasks/audio_utils.py +1862 -0
  201. dw/tasks/background_remover.py +43 -0
  202. dw/tasks/borders.py +113 -0
  203. dw/tasks/compose_text.py +74 -0
  204. dw/tasks/concat_videos.py +300 -0
  205. dw/tasks/depth_estimator.py +54 -0
  206. dw/tasks/diffusion_upscale.py +109 -0
  207. dw/tasks/dissolve_videos.py +342 -0
  208. dw/tasks/format_messages.py +24 -0
  209. dw/tasks/gather.py +173 -0
  210. dw/tasks/grade.py +97 -0
  211. dw/tasks/image_to_text.py +43 -0
  212. dw/tasks/image_utils.py +764 -0
  213. dw/tasks/interpolate_frames.py +252 -0
  214. dw/tasks/judge.py +68 -0
  215. dw/tasks/model_cache.py +55 -0
  216. dw/tasks/pair_audio.py +268 -0
  217. dw/tasks/qr_code.py +19 -0
  218. dw/tasks/restore_faces.py +175 -0
  219. dw/tasks/rife_model.py +192 -0
  220. dw/tasks/segment.py +121 -0
  221. dw/tasks/select.py +111 -0
  222. dw/tasks/speech_generation.py +228 -0
  223. dw/tasks/stabilize.py +129 -0
  224. dw/tasks/task.py +920 -0
  225. dw/tasks/tensor_image.py +57 -0
  226. dw/tasks/text_generation.py +169 -0
  227. dw/tasks/text_sections.py +80 -0
  228. dw/tasks/upscale.py +203 -0
  229. dw/tasks/video_utils.py +624 -0
  230. dw/tasks/zoe_depth.py +71 -0
  231. dw/teacache.py +381 -0
  232. dw/teacache_models.json +99 -0
  233. dw/test.py +29 -0
  234. dw/type_helpers.py +231 -0
  235. dw/validate.py +68 -0
  236. dw/variable_constraints.py +444 -0
  237. dw/variables.py +443 -0
  238. dw/video_extensions.py +141 -0
  239. dw/vram_estimate.py +116 -0
  240. dw/worker.py +764 -0
  241. dw/workflow.py +2007 -0
  242. dw/workflow_schema.json +1346 -0
  243. dw/workflow_sources.py +383 -0
  244. dw/workflows/h3_context_ir.json +57 -0
  245. dw/workflows/test.json +31 -0
  246. dw/workspace.py +730 -0
  247. dw_mcp/__init__.py +6 -0
  248. dw_mcp/__main__.py +133 -0
  249. dw_mcp/assets.py +336 -0
  250. dw_mcp/authoring.py +114 -0
  251. dw_mcp/catalog.py +360 -0
  252. dw_mcp/client.py +486 -0
  253. dw_mcp/diagnose.py +371 -0
  254. dw_mcp/exports.py +84 -0
  255. dw_mcp/guides.py +35 -0
  256. dw_mcp/media.py +638 -0
  257. dw_mcp/models.py +97 -0
  258. dw_mcp/prompts.py +104 -0
  259. dw_mcp/server.py +1343 -0
  260. 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
+ }