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/arguments.py ADDED
@@ -0,0 +1,1231 @@
1
+ import io
2
+ import os
3
+ import copy
4
+ import logging
5
+ import tempfile
6
+ from urllib.parse import unquote, urlparse
7
+ from inspect import Parameter, signature
8
+ from .type_helpers import load_type_from_name, load_constant_from_name, has_method
9
+ from .prompts import PROMPT_PREFIX, fetch_prompt
10
+ from .assets import fetch_asset, is_asset_reference
11
+ from .runs import fetch_output, is_output_reference
12
+ from diffusers.utils import load_image, load_video
13
+ from PIL import Image
14
+ from .security import (
15
+ validate_path,
16
+ validate_constant_name,
17
+ validate_url,
18
+ validate_file_extension,
19
+ SecurityError,
20
+ ALLOWED_IMAGE_EXTENSIONS,
21
+ ALLOWED_VIDEO_EXTENSIONS,
22
+ ALLOWED_AUDIO_EXTENSIONS,
23
+ )
24
+ from .locations import safe_get, validate_media_path
25
+
26
+ logger = logging.getLogger("dw")
27
+
28
+ # Keys that end in '_type' but name a category rather than a python type. Any other such
29
+ # key can be escaped where it is used, by wrapping its value in braces
30
+ NON_TYPE_KEYS = {"content_type", "offload_type"}
31
+
32
+
33
+ class EscapedString(str):
34
+ """A string whose {} escape has already been consumed.
35
+
36
+ Arguments are realized twice - once for the workflow's variables, and again for
37
+ the steps the variables are substituted into. A variable's own name can look
38
+ like a type reference ("weights_dtype": "{int4}"), so the first pass strips the
39
+ braces and the second would load the bare name as a type. Marking the stripped
40
+ value keeps the second pass from touching it, while it stays an ordinary string
41
+ everywhere else.
42
+ """
43
+
44
+
45
+ def is_escaped(value):
46
+ """Whether a value is a type reference escaped with {} braces"""
47
+ return isinstance(value, str) and value.startswith("{") and value.endswith("}")
48
+
49
+
50
+ # The key naming the file an argument object is constructed from
51
+ FROM_FILE_KEY = "from_file"
52
+
53
+ # The key naming the step whose output an argument object is constructed from
54
+ FROM_PREVIOUS_RESULT_KEY = "from_previous_result"
55
+
56
+ # The key holding the arguments an argument object is constructed from, for a type
57
+ # that takes its contents as plain fields rather than opening media itself
58
+ FROM_ARGUMENTS_KEY = "from_arguments"
59
+
60
+ # The prefix marking a value as a reference to an earlier step's output. Those are
61
+ # substituted once that step has run, so an object whose arguments hold one is
62
+ # constructed then rather than at load time
63
+ PREVIOUS_RESULT_PREFIX = "previous_result:"
64
+
65
+ # The prefix marking a value as a reference to a constant declared in python - the
66
+ # schedule a distilled model was trained on, the negative prompt a model family ships.
67
+ # Copying those into a workflow is how they go stale when the library moves on
68
+ CONSTANT_PREFIX = "constant:"
69
+
70
+
71
+ class _Omitted:
72
+ """An object description whose media is null - it is left out entirely.
73
+
74
+ An optional reference (a character's voice, a style image) is written as
75
+ a normal entry in a 'references' list with its source a variable, and the
76
+ variable declared null means "this run has none". Dropping the entry is
77
+ the only thing that can mean, since there is no object to build: a
78
+ pipeline handed a reference with no media in it fails, and a workflow
79
+ that had to carry two spellings of its references - one with the entry,
80
+ one without - is the copy a variable exists to avoid.
81
+ """
82
+
83
+ def __repr__(self):
84
+ return "<omitted>"
85
+
86
+
87
+ OMITTED = _Omitted()
88
+
89
+
90
+ # The media such an object may be built from - it opens the file itself, so the
91
+ # extension is all that is checked here
92
+ ALLOWED_FROM_FILE_EXTENSIONS = (
93
+ ALLOWED_IMAGE_EXTENSIONS | ALLOWED_VIDEO_EXTENSIONS | ALLOWED_AUDIO_EXTENSIONS
94
+ )
95
+
96
+ # The media kinds an object built from a previous step's output can carry. A type
97
+ # declares which one it is with a 'kind' attribute, the way MiniMax-H3's reference
98
+ # classes do, and that is what says which of its fields the media goes into
99
+ MEDIA_KINDS = ("image", "video", "audio")
100
+
101
+
102
+ # Helper functions for processing and loading workflow arguments
103
+ def realize_args(arg, base_dir=None, apply_key_conventions=True):
104
+ """
105
+ Recursively processes workflow arguments to:
106
+ 1. Convert type references into actual Python types
107
+ 2. Load images from file paths/URLs
108
+ 3. Load videos from file paths/URLs
109
+ 4. Construct objects that name a type and the file to build it from
110
+
111
+ Args:
112
+ arg: The arguments to process, modified in place
113
+ base_dir: Directory relative file paths are resolved against - the
114
+ workflow file's directory. Defaults to the process working directory
115
+ apply_key_conventions: Whether a bare value loads by what its key looks
116
+ like (an 'image'/'video'/'_type' name). Explicit references
117
+ (asset:, output:, constant:, prompt:, a {media_type, location}
118
+ dict) always resolve regardless of this flag - only the fallback
119
+ that guesses from the key name is gated. The top-level variables
120
+ dict is realized with this off (dw/workflow.py): a variable's own
121
+ name is not the argument it will end up filling, so 'image' guessed
122
+ a variable named that way into a PIL Image before the step that
123
+ actually names its argument 'video' ever saw the value (#365).
124
+ Nested structures still recurse with this at its default, since by
125
+ then a dict key is a real argument name again
126
+ """
127
+ if isinstance(arg, dict):
128
+ logger.debug(f"Processing dictionary arguments: {list(arg.keys())}")
129
+ for k, v in arg.items():
130
+ # An asset reference resolves to the path of a file in the asset
131
+ # library, and does it first: what it stands for is a path, so
132
+ # everything below - the media conventions, an object's
133
+ # 'from_file' - then handles it as the path it always was
134
+ if is_path_reference(v) or isinstance(v, (list, dict)):
135
+ v = arg[k] = resolve_path_references(v, base_dir)
136
+ # A constant resolves under any argument name, and before the
137
+ # conventions below - what it holds is the value, not a file to load
138
+ if is_constant_reference(v):
139
+ arg[k] = fetch_constant(v)
140
+ # A stored prompt resolves the same way - to its text, under any
141
+ # name, before the media conventions could mistake it for a file.
142
+ # The workflow's directory anchors prompt-library discovery
143
+ elif is_prompt_reference(v):
144
+ arg[k] = fetch_prompt(v, base_dir=base_dir)
145
+ # An explicit media reference loads under any argument name - the
146
+ # key conventions below only cover arguments named like their media
147
+ elif is_media_reference(v):
148
+ arg[k] = fetch_media(v, base_dir)
149
+ # Handle image loading for keys ending in '_image' or exactly 'image'
150
+ elif apply_key_conventions and (k.endswith("_image") or k == "image"):
151
+ logger.debug(f"Loading image for key: {k}")
152
+ arg[k] = _fetch_image_with_context(v, base_dir, k)
153
+ # get_frame/get_first_frame/get_last_frame only ever need one frame
154
+ # out of their 'video' - loading the ordinary way decodes the whole
155
+ # clip to throw all but one frame away, which is what OOM-killed a
156
+ # long clip (#367). Recognized by the sibling 'command' on this same
157
+ # task object, since that is the only place the command name and
158
+ # this argument meet before a task handler runs
159
+ elif (
160
+ apply_key_conventions
161
+ and k == "arguments"
162
+ and arg.get("command") in _LAZY_FRAME_COMMANDS
163
+ ):
164
+ _realize_lazy_frame_arguments(v, base_dir)
165
+ # Handle video loading for keys ending in '_video' or exactly 'video'
166
+ elif apply_key_conventions and (k.endswith("_video") or k == "video"):
167
+ logger.debug(f"Loading video for key: {k}")
168
+ arg[k] = _fetch_video_with_context(v, base_dir, k)
169
+ # Handle type references, and the keys that only look like one
170
+ elif apply_key_conventions and (
171
+ k.endswith("_type") or k.endswith("_dtype") or k == "dtype"
172
+ ):
173
+ if isinstance(v, EscapedString):
174
+ # An earlier pass already consumed this value's escape
175
+ continue
176
+ if k in NON_TYPE_KEYS:
177
+ # The value stays a string, but the {} escape is still honored
178
+ # so both the escaped and the bare spelling name the category
179
+ if is_escaped(v):
180
+ arg[k] = EscapedString(v.strip("{}"))
181
+ continue
182
+ logger.debug(f"Processing type reference for key: {k}")
183
+ # Allow escaping type references using {} brackets
184
+ # this is for instances when the argument name is "something_type" but it is
185
+ # not a reference to a python type, but rather a category or something else
186
+ if isinstance(v, str):
187
+ if is_escaped(v):
188
+ arg[k] = EscapedString(v.strip("{}"))
189
+ else:
190
+ arg[k] = load_type_from_name(v, k)
191
+ elif isinstance(v, type):
192
+ # the value already a type
193
+ arg[k] = v
194
+ # Recursively process nested dictionaries, then build any object they
195
+ # describe - the type reference it names is realized by the recursion
196
+ else:
197
+ realize_args(v, base_dir)
198
+ realized = realize_object(v, base_dir)
199
+ if realized is OMITTED:
200
+ raise ValueError(
201
+ f"'{k}' names an object to build but the media it "
202
+ f"would be built from is null. An optional one belongs "
203
+ f"in a list, where it can be left out; on its own "
204
+ f"there is nothing to leave it out of"
205
+ )
206
+ arg[k] = realized
207
+
208
+ # Recursively process lists
209
+ elif isinstance(arg, list):
210
+ logger.debug("Processing list arguments")
211
+ for i, item in enumerate(arg):
212
+ if is_path_reference(item):
213
+ item = arg[i] = resolve_path_references(item, base_dir)
214
+ if is_constant_reference(item):
215
+ arg[i] = fetch_constant(item)
216
+ continue
217
+ if is_prompt_reference(item):
218
+ arg[i] = fetch_prompt(item, base_dir=base_dir)
219
+ continue
220
+ if is_media_reference(item):
221
+ arg[i] = fetch_media(item, base_dir)
222
+ continue
223
+ try:
224
+ realize_args(item, base_dir)
225
+ except ValueError as error:
226
+ if isinstance(item, dict) and "name" in item:
227
+ raise ValueError(f"{error} (step '{item['name']}')") from error
228
+ raise
229
+ arg[i] = realize_object(item, base_dir)
230
+ # An optional entry whose media is null leaves the list rather than
231
+ # reaching the pipeline as a reference with nothing in it
232
+ if any(item is OMITTED for item in arg):
233
+ kept = [item for item in arg if item is not OMITTED]
234
+ arg[:] = kept
235
+
236
+
237
+ def is_path_reference(value):
238
+ """Whether a value is a reference that stands for a file's path - a
239
+ stored asset, or something an earlier run wrote."""
240
+ return is_asset_reference(value) or is_output_reference(value)
241
+
242
+
243
+ def resolve_path_references(value, base_dir=None):
244
+ """Replace any reference that stands for a path with the path itself.
245
+
246
+ A list is walked in place, because an 'image' argument may be a list of
247
+ references and the key conventions hand the whole list to the loader at
248
+ once - by then it is too late for a reference to be recognized.
249
+ A {"location": ...} dict is walked the same way, for the same reason: the
250
+ media conventions hand the whole dict to the loader, which reads the
251
+ location out of it and joins it onto the workflow directory - so
252
+ {"location": "asset:x.png"} passed validation (which sees every string)
253
+ and then failed the run on a path that was never resolved. Other
254
+ dictionaries are left alone: realize_args recurses into those itself,
255
+ and each of their values reaches this on the way through.
256
+ """
257
+ if is_asset_reference(value):
258
+ return fetch_asset(value, base_dir=base_dir)
259
+ if is_output_reference(value):
260
+ return fetch_output(value)
261
+ if isinstance(value, list):
262
+ for index, item in enumerate(value):
263
+ value[index] = resolve_path_references(item, base_dir)
264
+ elif isinstance(value, dict) and is_path_reference(value.get("location")):
265
+ value["location"] = resolve_path_references(value["location"], base_dir)
266
+ return value
267
+
268
+
269
+ def is_constant_reference(value):
270
+ """Whether a value references a constant declared in python."""
271
+ return isinstance(value, str) and value.startswith(CONSTANT_PREFIX)
272
+
273
+
274
+ def is_prompt_reference(value):
275
+ """Whether a value references a stored prompt in the prompt library."""
276
+ return isinstance(value, str) and value.startswith(PROMPT_PREFIX)
277
+
278
+
279
+ def fetch_constant(reference):
280
+ """Read the value a 'constant:' reference names.
281
+
282
+ A constant is data - a schedule, a default prompt, a token budget - so what the
283
+ name resolves to has to be data too. Anything callable is refused: a type is
284
+ named with a '_type' key and constructed there, and a workflow that could reach
285
+ a function through this would be evaluating python rather than referencing it.
286
+
287
+ Mutable values are copied. The workflow holds the module's own object otherwise,
288
+ and a pipeline that consumes its sigmas in place would edit the library's
289
+ constant for every later run in the process - the REPL keeps one alive for a
290
+ whole session.
291
+
292
+ Args:
293
+ reference: The 'constant:dotted.NAME' string
294
+
295
+ Returns:
296
+ The value the name refers to
297
+
298
+ Raises:
299
+ ValueError: If the name resolves to nothing, or to something callable
300
+ InvalidInputError: If the name is not a dotted python name
301
+ """
302
+ name = validate_constant_name(reference.removeprefix(CONSTANT_PREFIX).strip())
303
+
304
+ try:
305
+ value = load_constant_from_name(name)
306
+ except (ImportError, AttributeError) as error:
307
+ raise ValueError(
308
+ f"No constant named '{name}' - a constant reference names the module "
309
+ f"it is declared in and the attribute to read from it ({error})"
310
+ ) from error
311
+
312
+ if callable(value):
313
+ raise ValueError(
314
+ f"'{name}' is a {type(value).__name__}, not a constant - "
315
+ f"'{CONSTANT_PREFIX}' reads a value, and a type is named with a "
316
+ f"'_type' argument instead"
317
+ )
318
+
319
+ logger.info(f"Reading constant {name}")
320
+ return copy.deepcopy(value) if isinstance(value, (list, dict, set)) else value
321
+
322
+
323
+ def realize_constants(arg):
324
+ """Resolve every constant reference in a structure, and nothing else.
325
+
326
+ Variables are declared before they are set: a value passed in is converted to
327
+ the type of the declared default, so a default that is still the string naming
328
+ a constant would type a schedule as text. Resolving them first makes the
329
+ constant the declared value, which is what it is meant to be.
330
+
331
+ Args:
332
+ arg: The structure to resolve, modified in place
333
+ """
334
+ if isinstance(arg, dict):
335
+ for k, v in arg.items():
336
+ if is_constant_reference(v):
337
+ arg[k] = fetch_constant(v)
338
+ else:
339
+ realize_constants(v)
340
+ elif isinstance(arg, list):
341
+ for i, item in enumerate(arg):
342
+ if is_constant_reference(item):
343
+ arg[i] = fetch_constant(item)
344
+ else:
345
+ realize_constants(item)
346
+
347
+
348
+ def is_media_reference(value):
349
+ """Whether a value is an explicit media reference.
350
+
351
+ The form { "media_type": "image", "location": "subject.png" } says what the
352
+ media is instead of relying on what its argument is called, so a "mask" or
353
+ "depth_map" argument can load a file too. A bare {"location": ...} dict is
354
+ NOT treated as one - it stays whatever its consumer expects.
355
+ """
356
+ return isinstance(value, dict) and "media_type" in value and "location" in value
357
+
358
+
359
+ def fetch_media(spec, base_dir=None):
360
+ """Load the media an explicit reference names.
361
+
362
+ Args:
363
+ spec: Dict with 'media_type' ('image' or 'video') and 'location'
364
+ base_dir: Directory relative paths are resolved against
365
+
366
+ Returns:
367
+ The loaded media - or the location string unchanged when it is a
368
+ deferred variable/previous_result reference
369
+
370
+ Raises:
371
+ ValueError: If media_type names neither image nor video
372
+ SecurityError: If the location fails validation
373
+ """
374
+ media_type = spec["media_type"]
375
+ location = {"location": spec["location"]}
376
+ if media_type == "image":
377
+ return fetch_image(location, base_dir)
378
+ if media_type == "video":
379
+ return fetch_video(location, base_dir)
380
+ raise ValueError(f"Unknown media_type {media_type!r} - use 'image' or 'video'")
381
+
382
+
383
+ def object_type_key(value, from_key):
384
+ """The '*_type' key of an object description, or None if it is not one.
385
+
386
+ The type to construct is named by a '*_type' key, matching the convention
387
+ realize_args resolves. Without one the dict is not an object description - it
388
+ is left for whatever consumes it, exactly as it was before this feature.
389
+
390
+ Args:
391
+ value: The dict to inspect
392
+ from_key: The key that marked it as an object description, named in errors
393
+
394
+ Returns:
395
+ The name of the key holding the type, or None
396
+
397
+ Raises:
398
+ ValueError: If the dict names more than one type
399
+ """
400
+ type_keys = [k for k in value if k.endswith("_type") and k not in NON_TYPE_KEYS]
401
+ if not type_keys:
402
+ return None
403
+ if len(type_keys) > 1:
404
+ raise ValueError(
405
+ f"'{from_key}' needs exactly one '_type' argument naming the type to "
406
+ f"construct, got {type_keys}"
407
+ )
408
+ return type_keys[0]
409
+
410
+
411
+ def _names_no_media(value):
412
+ """Whether a dict is an object description whose media came out null.
413
+
414
+ It has to name a type, the way every object description does, and the
415
+ key saying where its media comes from has to be there and be null -
416
+ which is what a "variable:" source resolves to when the variable is
417
+ declared null. A dict missing the source key altogether is not this: it
418
+ is whatever it always was, and is left alone.
419
+ """
420
+ for from_key in (FROM_FILE_KEY, FROM_PREVIOUS_RESULT_KEY, FROM_ARGUMENTS_KEY):
421
+ if value.get(from_key, False) is None:
422
+ return object_type_key(value, from_key) is not None
423
+ return False
424
+
425
+
426
+ def realize_object(value, base_dir=None):
427
+ """Construct an argument that names a type and the media to build it from.
428
+
429
+ Some pipelines take arguments that are objects rather than plain media - MiniMax-H3's
430
+ references, which carry the frame rate or the sample rate of the media they hold.
431
+ Those are written as a type and where the media comes from, either a file:
432
+
433
+ { "reference_type": "...MiniMaxH3ImageReference", "from_file": "subject.png" }
434
+
435
+ built by the type's own from_file(), since only it knows what to bring along with
436
+ the media, or an earlier step of the workflow:
437
+
438
+ { "reference_type": "...MiniMaxH3ImageReference", "from_previous_result": "draw_subject" }
439
+
440
+ which is built by build_objects() instead, once the step it names has run. Only the
441
+ from_file form is constructed here - the other names media that does not exist yet.
442
+ Any other keys are arguments to from_file() where it takes them, and fields set on
443
+ the object it returns where it does not.
444
+
445
+ A type that has no from_file() of its own - LTX-2's conditions, which are plain
446
+ dataclasses holding frames the caller already loaded - is written as the arguments
447
+ to construct it with instead:
448
+
449
+ { "condition_type": "...LTX2VideoCondition",
450
+ "from_arguments": { "frames": { "media_type": "image", "location": "last.png" },
451
+ "index": -1, "strength": 1.0 } }
452
+
453
+ Those arguments are ordinary arguments: the media in them is loaded by the recursion
454
+ that reaches this, and one naming an earlier step ("previous_result:draw_subject")
455
+ waits for build_objects() the same way the from_previous_result form does.
456
+
457
+ Args:
458
+ value: An already realized argument - anything but a dict naming a '_type'
459
+ and a 'from_file' is returned unchanged
460
+ base_dir: Directory a relative 'from_file' path is resolved against
461
+
462
+ Returns:
463
+ The constructed object, or value unchanged
464
+
465
+ Raises:
466
+ ValueError: If the dict names more than one type, a type that cannot be built
467
+ from a file, or a file location that cannot be resolved
468
+ SecurityError: If the file it names fails validation
469
+ """
470
+ if isinstance(value, dict) and _names_no_media(value):
471
+ # An optional reference this run was given nothing for
472
+ return OMITTED
473
+
474
+ if isinstance(value, dict) and FROM_PREVIOUS_RESULT_KEY in value:
475
+ # Validated now and built later - a type that cannot hold the step's output
476
+ # is a workflow error worth raising before any of it runs
477
+ validate_deferred_object(value)
478
+ return value
479
+
480
+ if isinstance(value, dict) and FROM_ARGUMENTS_KEY in value:
481
+ object_type, arguments = validate_constructed_object(value)
482
+ if names_a_previous_result(arguments):
483
+ # One of its arguments is a step's output, which does not exist yet -
484
+ # build_objects constructs it once that step has run
485
+ return value
486
+ return construct_object(object_type, arguments)
487
+
488
+ if not isinstance(value, dict) or FROM_FILE_KEY not in value:
489
+ return value
490
+
491
+ type_key = object_type_key(value, FROM_FILE_KEY)
492
+ if type_key is None:
493
+ return value
494
+ type_keys = [type_key]
495
+
496
+ object_type = value[type_keys[0]]
497
+ if not isinstance(object_type, type):
498
+ raise ValueError(
499
+ f"'{type_keys[0]}' must name a type to construct, got {object_type!r}"
500
+ )
501
+ if not has_method(object_type, FROM_FILE_KEY):
502
+ raise ValueError(
503
+ f"{object_type.__name__} cannot be constructed from a file - "
504
+ f"it has no {FROM_FILE_KEY}()"
505
+ )
506
+
507
+ location = value[FROM_FILE_KEY]
508
+ if isinstance(location, str):
509
+ # These resolve per step iteration, after objects are already built -
510
+ # a clear error here beats a path-validation failure naming the wrong cause
511
+ if location.startswith("previous_result:"):
512
+ raise ValueError(
513
+ f"'{FROM_FILE_KEY}' cannot reference a previous step's result - "
514
+ f"it names a file the object is constructed from. Use "
515
+ f"'{FROM_PREVIOUS_RESULT_KEY}' to build it from what a step "
516
+ f"generated instead"
517
+ )
518
+ if location.startswith("variable:"):
519
+ raise ValueError(
520
+ f"'{FROM_FILE_KEY}' references {location!r} but no such "
521
+ f"variable is defined"
522
+ )
523
+
524
+ location = validate_media_location(location, base_dir)
525
+ logger.info(f"Constructing {object_type.__name__} from {location}")
526
+
527
+ arguments = {
528
+ k: v for k, v in value.items() if k not in (FROM_FILE_KEY, type_keys[0])
529
+ }
530
+ accepted, overrides = split_from_file_arguments(object_type, arguments)
531
+ return apply_field_overrides(
532
+ object_type.from_file(location, **accepted), overrides, object_type
533
+ )
534
+
535
+
536
+ def split_from_file_arguments(object_type, arguments):
537
+ """Split the keys of a from_file description by where the type can take them.
538
+
539
+ A from_file() that takes keyword arguments is handed all of them - that is the
540
+ generic case, where the type decodes the media and the arguments say how. One that
541
+ takes the media and nothing else, which is what MiniMax-H3's references do, gets
542
+ only what its signature names, and the rest are set on the object it returns. That
543
+ is the way diffusers documents correcting a reference whose container lied about
544
+ its frame rate, and without it there is no way to say so from a workflow.
545
+
546
+ Args:
547
+ object_type: The type being constructed
548
+ arguments: The description's keys, minus the type and the location
549
+
550
+ Returns:
551
+ (arguments for from_file, fields to set on the result)
552
+ """
553
+ parameters = signature(getattr(object_type, FROM_FILE_KEY)).parameters
554
+ if any(
555
+ parameter.kind is Parameter.VAR_KEYWORD for parameter in parameters.values()
556
+ ):
557
+ return arguments, {}
558
+
559
+ named = {
560
+ name
561
+ for name, parameter in parameters.items()
562
+ if parameter.kind in (Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY)
563
+ }
564
+ accepted = {k: v for k, v in arguments.items() if k in named}
565
+ return accepted, {k: v for k, v in arguments.items() if k not in named}
566
+
567
+
568
+ def apply_field_overrides(constructed, overrides, object_type):
569
+ """Set the fields a description names on the object that was built from its media.
570
+
571
+ Args:
572
+ constructed: The object from_file() returned
573
+ overrides: The keys from_file() could not take
574
+ object_type: The type, named in errors
575
+
576
+ Returns:
577
+ The object, with the fields set
578
+
579
+ Raises:
580
+ ValueError: If it has no field by one of those names
581
+ """
582
+ for name, value in overrides.items():
583
+ if not hasattr(constructed, name):
584
+ fields = getattr(object_type, "__dataclass_fields__", None)
585
+ takes = f" - it holds {', '.join(fields)}" if fields else ""
586
+ raise ValueError(
587
+ f"{object_type.__name__} has no field '{name}', and its "
588
+ f"{FROM_FILE_KEY}() does not take it either{takes}"
589
+ )
590
+ logger.debug(f"Setting {object_type.__name__}.{name} from the workflow")
591
+ setattr(constructed, name, value)
592
+
593
+ return constructed
594
+
595
+
596
+ def validate_deferred_object(description):
597
+ """Check an object description built from a previous step, at load time.
598
+
599
+ The media it names does not exist until the step it references has run, so the
600
+ construction itself waits. What can be checked now is checked now: a workflow
601
+ that names a type it cannot build should say so before the first model loads.
602
+
603
+ Args:
604
+ description: The dict naming a '_type' and a 'from_previous_result'
605
+
606
+ Raises:
607
+ ValueError: If it names no type, more than one, something that is not a type,
608
+ or a type that declares no media kind
609
+ """
610
+ type_key = object_type_key(description, FROM_PREVIOUS_RESULT_KEY)
611
+ if type_key is None:
612
+ raise ValueError(
613
+ f"'{FROM_PREVIOUS_RESULT_KEY}' needs a '_type' argument naming the type "
614
+ f"to construct from the step's output"
615
+ )
616
+
617
+ object_type = description[type_key]
618
+ if not isinstance(object_type, type):
619
+ raise ValueError(
620
+ f"'{type_key}' must name a type to construct, got {object_type!r}"
621
+ )
622
+
623
+ kind = getattr(object_type, "kind", None)
624
+ if kind not in MEDIA_KINDS:
625
+ raise ValueError(
626
+ f"{object_type.__name__} cannot be constructed from a step's output - "
627
+ f"a type built this way declares which media it holds with a 'kind' of "
628
+ f"{', '.join(MEDIA_KINDS)}, and this one declares {kind!r}"
629
+ )
630
+
631
+
632
+ def validate_constructed_object(description):
633
+ """Check an object description built from the arguments it names, at load time.
634
+
635
+ Args:
636
+ description: The dict naming a '_type' and a 'from_arguments'
637
+
638
+ Returns:
639
+ (the type to construct, the arguments to construct it with)
640
+
641
+ Raises:
642
+ ValueError: If it names no type, more than one, something that is not a type,
643
+ carries keys beside the two, or arguments that are not a dict
644
+ """
645
+ type_key = object_type_key(description, FROM_ARGUMENTS_KEY)
646
+ if type_key is None:
647
+ raise ValueError(
648
+ f"'{FROM_ARGUMENTS_KEY}' needs a '_type' argument naming the type to "
649
+ f"construct from the arguments it holds"
650
+ )
651
+
652
+ object_type = description[type_key]
653
+ if not isinstance(object_type, type):
654
+ raise ValueError(
655
+ f"'{type_key}' must name a type to construct, got {object_type!r}"
656
+ )
657
+
658
+ arguments = description[FROM_ARGUMENTS_KEY]
659
+ if not isinstance(arguments, dict):
660
+ raise ValueError(
661
+ f"'{FROM_ARGUMENTS_KEY}' must hold the arguments {object_type.__name__} "
662
+ f"is constructed with, got {type(arguments).__name__}"
663
+ )
664
+
665
+ extra = set(description) - {type_key, FROM_ARGUMENTS_KEY}
666
+ if extra:
667
+ raise ValueError(
668
+ f"'{FROM_ARGUMENTS_KEY}' holds every argument {object_type.__name__} is "
669
+ f"constructed with - move {', '.join(sorted(extra))} inside it"
670
+ )
671
+
672
+ return object_type, arguments
673
+
674
+
675
+ def names_a_previous_result(value):
676
+ """Whether anything in an argument structure references an earlier step."""
677
+ if isinstance(value, dict):
678
+ return any(names_a_previous_result(item) for item in value.values())
679
+ if isinstance(value, list):
680
+ return any(names_a_previous_result(item) for item in value)
681
+ return isinstance(value, str) and value.startswith(PREVIOUS_RESULT_PREFIX)
682
+
683
+
684
+ def construct_object(object_type, arguments):
685
+ """Build one object from the arguments its description named.
686
+
687
+ Args:
688
+ object_type: The type to construct
689
+ arguments: The arguments to construct it with, with any reference to an
690
+ earlier step already substituted for what it named
691
+
692
+ Returns:
693
+ The constructed object
694
+
695
+ Raises:
696
+ ValueError: If the type cannot be constructed from those arguments
697
+ """
698
+ logger.info(f"Constructing {object_type.__name__} from {', '.join(arguments)}")
699
+ try:
700
+ return object_type(**arguments)
701
+ except TypeError as error:
702
+ fields = getattr(object_type, "__dataclass_fields__", None)
703
+ takes = f" - it takes {', '.join(fields)}" if fields else ""
704
+ raise ValueError(
705
+ f"Cannot construct {object_type.__name__} from "
706
+ f"{', '.join(arguments) or 'no arguments'}{takes}: {error}"
707
+ ) from error
708
+
709
+
710
+ def build_objects(arguments):
711
+ """Construct the objects whose media came from an earlier step.
712
+
713
+ Runs after previous_results has substituted each 'from_previous_result' with the
714
+ artifact it named, which is the earliest the media exists. The same goes for a
715
+ 'from_arguments' description one of whose arguments named a step - realize_object
716
+ left it standing, and by now the reference inside it holds what the step produced.
717
+ Containers are rebuilt rather than mutated, and only where something below them
718
+ changed - the arguments of one iteration share their nested values with every
719
+ other iteration, so an in-place edit here would reach into all of them.
720
+
721
+ Args:
722
+ arguments: One iteration's arguments, with previous results substituted
723
+
724
+ Returns:
725
+ The arguments with every object description replaced by the built object -
726
+ the same object where there was nothing to build
727
+ """
728
+ if isinstance(arguments, dict):
729
+ if FROM_PREVIOUS_RESULT_KEY in arguments:
730
+ return object_from_result(arguments)
731
+ if FROM_ARGUMENTS_KEY in arguments:
732
+ # Its own arguments may hold descriptions too - a condition built from a
733
+ # reference built from a step - so they are built before it is
734
+ object_type, described = validate_constructed_object(arguments)
735
+ return construct_object(object_type, build_objects(described))
736
+ built = {k: build_objects(v) for k, v in arguments.items()}
737
+ return (
738
+ built if any(built[k] is not v for k, v in arguments.items()) else arguments
739
+ )
740
+
741
+ if isinstance(arguments, list):
742
+ built = [build_objects(item) for item in arguments]
743
+ return (
744
+ built
745
+ if any(new is not old for new, old in zip(built, arguments))
746
+ else arguments
747
+ )
748
+
749
+ return arguments
750
+
751
+
752
+ def object_from_result(description):
753
+ """Build one object from the step output substituted into its description.
754
+
755
+ The media is already in memory and already at the rates the step produced it at,
756
+ so it is handed to the constructor field by field rather than through from_file().
757
+ Which field it lands in comes from the type's own 'kind' - the convention
758
+ MiniMax-H3's reference classes follow, and the same one the segment chain reads
759
+ when it carries a generated segment back in as a reference.
760
+
761
+ Args:
762
+ description: The dict naming a '_type', with 'from_previous_result' now
763
+ holding the artifact rather than the step name
764
+
765
+ Returns:
766
+ The constructed object
767
+
768
+ Raises:
769
+ ValueError: If the artifact is not the media the type's kind calls for
770
+ """
771
+ validate_deferred_object(description)
772
+ type_key = object_type_key(description, FROM_PREVIOUS_RESULT_KEY)
773
+ object_type = description[type_key]
774
+ artifact = description[FROM_PREVIOUS_RESULT_KEY]
775
+
776
+ # A workflow can still name a field itself - the frame rate of a step that
777
+ # generated at something other than the consuming pipeline's own, say - and
778
+ # what it names wins over what the artifact carried
779
+ overrides = {
780
+ k: v
781
+ for k, v in description.items()
782
+ if k not in (FROM_PREVIOUS_RESULT_KEY, type_key)
783
+ }
784
+ arguments = media_arguments(object_type, artifact)
785
+ arguments.update(overrides)
786
+
787
+ logger.info(
788
+ f"Constructing {object_type.__name__} from a previous result "
789
+ f"({', '.join(arguments)})"
790
+ )
791
+ return object_type(**arguments)
792
+
793
+
794
+ def _carried_audio(artifact, torch, as_channels_samples):
795
+ """The (waveform tensor, rate) an artifact carries as '.audio'/'.sample_rate'
796
+ - an AudioVideo or an AudioTrack - or (None, None) for anything else."""
797
+ if getattr(artifact, "audio", None) is None:
798
+ return None, None
799
+ audio = torch.as_tensor(as_channels_samples(artifact.audio))
800
+ return audio, getattr(artifact, "sample_rate", None)
801
+
802
+
803
+ def media_arguments(object_type, artifact):
804
+ """The constructor arguments a step's artifact makes, for a type's media kind.
805
+
806
+ Imported here rather than at module scope - these pull in the result and task
807
+ helpers, which is a heavier import than an argument file needs for the path
808
+ that never builds one of these.
809
+
810
+ Args:
811
+ object_type: The type being constructed, declaring its 'kind'
812
+ artifact: One artifact of the step the description named
813
+
814
+ Returns:
815
+ Dict of constructor arguments
816
+
817
+ Raises:
818
+ ValueError: If the artifact is not the media the kind calls for
819
+ """
820
+ import torch
821
+ from PIL import Image
822
+
823
+ from .tasks.audio_utils import as_channels_samples
824
+ from .tasks.video_utils import frames_as_pil_list
825
+
826
+ kind = object_type.kind
827
+
828
+ if kind == "image":
829
+ if not isinstance(artifact, Image.Image):
830
+ raise ValueError(
831
+ f"{object_type.__name__} holds an image, but the step it names "
832
+ f"produced a {type(artifact).__name__} - reference a step that "
833
+ f"generates images, or its 'images' property"
834
+ )
835
+ return {"image": artifact}
836
+
837
+ if kind == "video":
838
+ # Frames first: an audio-only artifact has none, and that is the thing
839
+ # to say rather than whatever the frame helper raises about it
840
+ if (
841
+ not hasattr(artifact, "frames")
842
+ and not isinstance(artifact, (list, tuple))
843
+ and not hasattr(artifact, "ndim")
844
+ ):
845
+ raise ValueError(
846
+ f"{object_type.__name__} holds a video, but the step it names "
847
+ f"produced a {type(artifact).__name__} - reference a step that "
848
+ f"generates video"
849
+ )
850
+ frames = frames_as_pil_list(artifact)
851
+ if not frames:
852
+ raise ValueError(
853
+ f"{object_type.__name__} holds a video, but the step it names "
854
+ f"produced no frames"
855
+ )
856
+ arguments = {"frames": frames}
857
+ audio, sample_rate = _carried_audio(artifact, torch, as_channels_samples)
858
+ if audio is not None:
859
+ arguments["audio"] = audio
860
+ arguments["sample_rate"] = sample_rate
861
+ return arguments
862
+
863
+ audio, sample_rate = _carried_audio(artifact, torch, as_channels_samples)
864
+ if audio is None and hasattr(artifact, "ndim") and artifact.ndim <= 3:
865
+ # A step that generates audio alone - a music pipeline, a slice_audio
866
+ # task - produces the waveform itself rather than an AudioVideo. It
867
+ # carries no rate of its own, so the reference declares 'sample_rate'
868
+ # alongside 'from_previous_result'
869
+ audio = torch.as_tensor(as_channels_samples(artifact))
870
+
871
+ if audio is None:
872
+ raise ValueError(
873
+ f"{object_type.__name__} holds audio, but the step it names produced "
874
+ f"none - reference a step that generates a soundtrack"
875
+ )
876
+ arguments = {"audio": audio}
877
+ if sample_rate is not None:
878
+ arguments["sample_rate"] = sample_rate
879
+ return arguments
880
+
881
+
882
+ def validate_media_location(location, base_dir=None):
883
+ """Validate the media file an argument object is constructed from.
884
+
885
+ The object decodes the file itself, so only where it comes from is checked here.
886
+
887
+ Args:
888
+ location: Path or URL of the media file
889
+ base_dir: Directory a relative path is resolved against - the workflow
890
+ file's directory. Defaults to the process working directory
891
+
892
+ Returns:
893
+ The validated path or URL
894
+
895
+ Raises:
896
+ ValueError: If the location is not a string
897
+ SecurityError: If the path, URL or file extension is not allowed
898
+ """
899
+ if not isinstance(location, str):
900
+ raise ValueError(
901
+ f"'{FROM_FILE_KEY}' must be a path or a URL, got {type(location)}"
902
+ )
903
+
904
+ if location.startswith("http://") or location.startswith("https://"):
905
+ return validate_url(location)
906
+
907
+ validated_path = validate_path(
908
+ resolve_relative_path(location, base_dir), allow_create=False
909
+ )
910
+ return validate_file_extension(validated_path, ALLOWED_FROM_FILE_EXTENSIONS)
911
+
912
+
913
+ def resolve_relative_path(path, base_dir):
914
+ """Resolve a relative file path against the workflow file's directory.
915
+
916
+ Workflow files name their media relative to themselves; absolute paths and
917
+ callers with no base_dir keep the path as given (process working directory).
918
+ """
919
+ if base_dir and not os.path.isabs(os.path.expanduser(path)):
920
+ return os.path.join(base_dir, path)
921
+ return path
922
+
923
+
924
+ def _describe_value_source(value):
925
+ """A short, human phrase for what a mistyped value already is - the
926
+ 'source' half of an argument-mismatch error, since the type name alone
927
+ (PIL.Image.Image) doesn't say *how* it got there."""
928
+ if hasattr(value, "mode") and hasattr(value, "size"):
929
+ return "an already-loaded image"
930
+ if isinstance(value, tuple) and value and hasattr(value[0], "size"):
931
+ return "already-loaded video frames"
932
+ return f"a {type(value).__name__}"
933
+
934
+
935
+ def _fetch_image_with_context(v, base_dir, key):
936
+ """fetch_image, with the argument key folded into a type-mismatch error -
937
+ a bare 'got <class ...>' names neither the argument nor what the value
938
+ already was (#365)."""
939
+ try:
940
+ return fetch_image(v, base_dir)
941
+ except ValueError as error:
942
+ raise ValueError(
943
+ f"{error} (argument '{key}' expected an image, got "
944
+ f"{_describe_value_source(v)} - check what variable or previous "
945
+ f"result feeds it)"
946
+ ) from error
947
+
948
+
949
+ def _fetch_video_with_context(v, base_dir, key):
950
+ """fetch_video, with the same argument-key context as
951
+ _fetch_image_with_context."""
952
+ try:
953
+ return fetch_video(v, base_dir)
954
+ except ValueError as error:
955
+ raise ValueError(
956
+ f"{error} (argument '{key}' expected a video, got "
957
+ f"{_describe_value_source(v)} - check what variable or previous "
958
+ f"result feeds it)"
959
+ ) from error
960
+
961
+
962
+ def fetch_image(img_spec, base_dir=None):
963
+ """
964
+ Load image from file path or URL with security validation.
965
+
966
+ Args:
967
+ img_spec: Image specification (file path, URL, dict with 'location' key, PIL Image, or list of any of these)
968
+ base_dir: Directory relative file paths are resolved against - the
969
+ workflow file's directory. Defaults to the process working directory
970
+
971
+ Returns:
972
+ Loaded PIL Image, list of PIL Images, or None if img_spec is None
973
+
974
+ Raises:
975
+ SecurityError: If validation fails
976
+ ValueError: If img_spec is invalid type
977
+ """
978
+ if img_spec is None:
979
+ return None
980
+
981
+ # Handle lists of images (recursively process each)
982
+ if isinstance(img_spec, list):
983
+ logger.debug(f"Loading list of {len(img_spec)} images")
984
+ return [fetch_image(img, base_dir) for img in img_spec]
985
+
986
+ # If already a PIL Image, return as-is (allows multiple realize_args calls)
987
+ if hasattr(img_spec, "mode") and hasattr(img_spec, "size"):
988
+ logger.debug("Image already loaded, returning as-is")
989
+ return img_spec
990
+
991
+ # Handle dict format: {"location": "url_or_path"}
992
+ if isinstance(img_spec, dict):
993
+ if "location" not in img_spec:
994
+ raise ValueError(
995
+ f"Image dict must have 'location' key, got keys: {list(img_spec.keys())}"
996
+ )
997
+ img_spec = img_spec["location"]
998
+
999
+ if not isinstance(img_spec, str):
1000
+ raise ValueError(f"Image specification must be a string, got {type(img_spec)}")
1001
+
1002
+ # Skip cross-step and variable references — these are resolved later during execution
1003
+ if img_spec.startswith("previous_result:") or img_spec.startswith("variable:"):
1004
+ logger.debug(f"Skipping deferred reference: {img_spec}")
1005
+ return img_spec
1006
+
1007
+ logger.debug(f"Loading image from: {img_spec}")
1008
+
1009
+ try:
1010
+ # Check if it's a URL
1011
+ if isinstance(img_spec, str) and (
1012
+ img_spec.startswith("http://") or img_spec.startswith("https://")
1013
+ ):
1014
+ # Fetched here rather than by load_image, which follows redirects
1015
+ # without re-checking them; load_image still does the EXIF
1016
+ # transpose and RGB conversion on the decoded result
1017
+ response = safe_get(img_spec, "an image argument", timeout=60)
1018
+ return load_image(Image.open(io.BytesIO(response.content)))
1019
+ else:
1020
+ # Treat as file path, relative to the workflow file, and confined
1021
+ # to the directories this workflow may read (dw/locations.py)
1022
+ validated_path = validate_media_path(
1023
+ str(img_spec), base_dir, "an image argument"
1024
+ )
1025
+ # Validate file extension
1026
+ ext = os.path.splitext(validated_path)[1].lower()
1027
+ if ext not in ALLOWED_IMAGE_EXTENSIONS:
1028
+ raise SecurityError(f"Image file extension not allowed: {ext}")
1029
+ return load_image(validated_path)
1030
+
1031
+ except SecurityError:
1032
+ raise
1033
+ except Exception as e:
1034
+ logger.error(f"Failed to load image {img_spec}: {e}")
1035
+ raise
1036
+
1037
+
1038
+ def _with_frame_rate(frames, location):
1039
+ """The loaded frames carrying the rate their file declares, and the
1040
+ shot boundaries its run (or kept-asset sidecar) recorded for it.
1041
+
1042
+ `load_video` reads frames and drops both: a step that paired a 24 fps
1043
+ file with a soundtrack wrote it back at 8 - three times long, silently
1044
+ (#104) - and a video loaded from an `asset:`/`output:` path had no
1045
+ `shots` to hand `pair_audio`, even when the server had them on file
1046
+ for that exact video (#398). The rate is read from the container
1047
+ without decoding anything; the shots come from `shots_beside`, which
1048
+ only looks at a real local path, so a URL carries none. A file that
1049
+ says neither stays a plain list.
1050
+ """
1051
+ from .runs import shots_beside
1052
+ from .tasks.video_utils import FrameList, file_fps
1053
+
1054
+ if not isinstance(frames, list):
1055
+ return frames
1056
+ fps = file_fps(location)
1057
+ shots = (
1058
+ shots_beside(location)
1059
+ if not (location.startswith("http://") or location.startswith("https://"))
1060
+ else None
1061
+ )
1062
+ return FrameList(frames, fps, shots) if (fps or shots) else frames
1063
+
1064
+
1065
+ def _fetch_remote_video(url):
1066
+ """A video URL's frames, fetched through `safe_get` and decoded from a
1067
+ temporary file. `load_video` would fetch the URL itself and follow its
1068
+ redirects unchecked; handed a path, it only decodes. The suffix comes
1069
+ from the URL, as `load_video`'s own download names it, since a `.gif`
1070
+ decodes differently."""
1071
+ from .tasks.video_utils import FrameList, file_fps
1072
+
1073
+ response = safe_get(url, "a video argument", timeout=300)
1074
+ suffix = os.path.splitext(unquote(urlparse(url).path))[1] or ".mp4"
1075
+ handle = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
1076
+ try:
1077
+ with handle:
1078
+ handle.write(response.content)
1079
+ frames = load_video(handle.name)
1080
+ fps = file_fps(handle.name)
1081
+ finally:
1082
+ os.remove(handle.name)
1083
+ # A URL has no run beside it, so it carries no shots
1084
+ return FrameList(frames, fps, None) if fps else frames
1085
+
1086
+
1087
+ def fetch_video(video_spec, base_dir=None):
1088
+ """
1089
+ Load video from file path or URL with security validation.
1090
+
1091
+ Args:
1092
+ video_spec: Video specification (file path, URL, dict with 'location' key, loaded frames, or list of any of these)
1093
+ base_dir: Directory relative file paths are resolved against - the
1094
+ workflow file's directory. Defaults to the process working directory
1095
+
1096
+ Returns:
1097
+ Loaded video frames, list of video frames, or None if video_spec is None
1098
+
1099
+ Raises:
1100
+ SecurityError: If validation fails
1101
+ ValueError: If video_spec is invalid type
1102
+ """
1103
+ if video_spec is None:
1104
+ return None
1105
+
1106
+ # An explicit {"media_type": ..., "location": ...} reference says what the
1107
+ # media is regardless of the argument it fills - a still handed to a
1108
+ # 'video' argument this way loads as an image rather than hitting the
1109
+ # extension gate below (#443). Checked ahead of the list/dict handling so
1110
+ # it also applies per-item inside a list of mixed video/image references,
1111
+ # which realize_args's own is_media_reference check never sees - a list
1112
+ # is not itself a dict, so a 'video'-named list reaches fetch_video whole
1113
+ if is_media_reference(video_spec):
1114
+ return fetch_media(video_spec, base_dir)
1115
+
1116
+ # Handle lists of videos (need to distinguish from video frames)
1117
+ # Check if it's a list of specifications (dicts/strings) rather than video frames
1118
+ if isinstance(video_spec, list) and len(video_spec) > 0:
1119
+ # If first element is a dict with 'location' or a string, treat as list of video specs
1120
+ if isinstance(video_spec[0], (dict, str)):
1121
+ logger.debug(f"Loading list of {len(video_spec)} videos")
1122
+ return [fetch_video(vid, base_dir) for vid in video_spec]
1123
+ # Otherwise assume it's already loaded video frames
1124
+ else:
1125
+ logger.debug("Video frames already loaded, returning as-is")
1126
+ return video_spec
1127
+
1128
+ # If already loaded video frames (tuple), return as-is
1129
+ if isinstance(video_spec, tuple):
1130
+ logger.debug("Video frames already loaded, returning as-is")
1131
+ return video_spec
1132
+
1133
+ # Handle dict format: {"location": "url_or_path"}
1134
+ if isinstance(video_spec, dict):
1135
+ if "location" not in video_spec:
1136
+ raise ValueError(
1137
+ f"Video dict must have 'location' key, got keys: {list(video_spec.keys())}"
1138
+ )
1139
+ video_spec = video_spec["location"]
1140
+
1141
+ if not isinstance(video_spec, str):
1142
+ raise ValueError(
1143
+ f"Video specification must be a string, got {type(video_spec)}"
1144
+ )
1145
+
1146
+ # Skip cross-step and variable references — these are resolved later during execution
1147
+ if video_spec.startswith("previous_result:") or video_spec.startswith("variable:"):
1148
+ logger.debug(f"Skipping deferred reference: {video_spec}")
1149
+ return video_spec
1150
+
1151
+ logger.debug(f"Loading video from: {video_spec}")
1152
+
1153
+ try:
1154
+ # Check if it's a URL
1155
+ if isinstance(video_spec, str) and (
1156
+ video_spec.startswith("http://") or video_spec.startswith("https://")
1157
+ ):
1158
+ return _fetch_remote_video(video_spec)
1159
+ else:
1160
+ # Treat as file path, relative to the workflow file, and confined
1161
+ # to the directories this workflow may read (dw/locations.py)
1162
+ validated_path = validate_media_path(
1163
+ str(video_spec), base_dir, "a video argument"
1164
+ )
1165
+ # Validate file extension
1166
+ ext = os.path.splitext(validated_path)[1].lower()
1167
+ if ext not in ALLOWED_VIDEO_EXTENSIONS:
1168
+ raise SecurityError(f"Video file extension not allowed: {ext}")
1169
+ return _with_frame_rate(load_video(validated_path), validated_path)
1170
+
1171
+ except SecurityError:
1172
+ raise
1173
+ except Exception as e:
1174
+ logger.error(f"Failed to load video {video_spec}: {e}")
1175
+ raise
1176
+
1177
+
1178
+ # get_frame and its two fixed-index siblings, and the assessment probes
1179
+ # (dw/tasks/assess.py), which stream the file themselves - decoding it to a
1180
+ # frame list first dropped the soundtrack they measure and failed every probe
1181
+ # on an asset:/output: video (#387) - see _realize_lazy_frame_arguments
1182
+ _LAZY_FRAME_COMMANDS = frozenset(
1183
+ {
1184
+ "get_frame",
1185
+ "get_first_frame",
1186
+ "get_last_frame",
1187
+ "analyze_shots",
1188
+ "analyze_seams",
1189
+ "analyze_sync_drift",
1190
+ }
1191
+ )
1192
+
1193
+
1194
+ def _realize_lazy_frame_arguments(arguments, base_dir):
1195
+ """Realize a get_frame/get_first_frame/get_last_frame or probe step's
1196
+ arguments, reading a file-based 'video' by reference rather than decoding
1197
+ it (#367, #387).
1198
+
1199
+ Everything but 'video' is realized the ordinary way. A 'video' naming a
1200
+ real file or an asset/output path becomes a VideoFileReference the task
1201
+ reads one frame out of by seeking, or a probe streams; a 'previous_result:'/'variable:'
1202
+ reference is still deferred, and a URL still goes through the ordinary
1203
+ eager fetch_video, since a seek needs a local, seekable file.
1204
+ """
1205
+ from .tasks.video_utils import VideoFileReference
1206
+
1207
+ if "video" in arguments:
1208
+ video = arguments["video"]
1209
+ if is_path_reference(video) or isinstance(video, (list, dict)):
1210
+ video = resolve_path_references(video, base_dir)
1211
+ deferred = isinstance(video, str) and (
1212
+ video.startswith("previous_result:") or video.startswith("variable:")
1213
+ )
1214
+ url = isinstance(video, str) and (
1215
+ video.startswith("http://") or video.startswith("https://")
1216
+ )
1217
+ if isinstance(video, str) and not deferred and not url:
1218
+ validated_path = validate_media_path(video, base_dir, "a video argument")
1219
+ ext = os.path.splitext(validated_path)[1].lower()
1220
+ if ext not in ALLOWED_VIDEO_EXTENSIONS:
1221
+ raise SecurityError(f"Video file extension not allowed: {ext}")
1222
+ arguments["video"] = VideoFileReference(validated_path)
1223
+ elif video is not None:
1224
+ arguments["video"] = fetch_video(video, base_dir)
1225
+
1226
+ for k, v in list(arguments.items()):
1227
+ if k == "video":
1228
+ continue
1229
+ single = {k: v}
1230
+ realize_args(single, base_dir)
1231
+ arguments[k] = single[k]