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
@@ -0,0 +1,444 @@
1
+ """What a variable's value is allowed to be, declared by the author.
2
+
3
+ `validate_workflow` passed `num_frames: 61` on an H3 template and answered
4
+ `valid: true`, naming `num_frames` in `checked_arguments` - so the answer
5
+ claimed to cover the caller's value. The run then spent 138.7 s loading the
6
+ weights and the turbo LoRA, entered the text encoder, and failed on a check
7
+ against two integers:
8
+
9
+ MiniMax-H3 generates between 5.0 and 15.0 seconds at 24 fps, so
10
+ `num_frames`, rounded up to the next `17 * n + 5` the video VAE can
11
+ encode, must be between 120 and 360, got 61 (rounded up to 73).
12
+
13
+ Every term in that message is a property of the model. None of it needed a
14
+ loaded pipeline - and nothing reachable over the API stated it, so a consumer
15
+ reading `num_frames: 124` with no range had no way to know the rule was
16
+ `17n + 5` from 124 (#96).
17
+
18
+ The rule is declared in the workflow, where `frame_snap` already puts the
19
+ same numbers for a chain step, and the engine holds no model knowledge of its
20
+ own (CLAUDE.md). One shape, not two: a `variable_constraints` entry takes
21
+ `frame_snap`'s field names, and a chain step's `frame_snap` may be the string
22
+ `"constraint:<variable>"` so a template states `17n + 5` once rather than
23
+ twice in one file.
24
+
25
+ A constraint key is a plain variable name, matched wherever a value by that
26
+ name sits: a top-level variable, or a field of an entry of a `for_each` list
27
+ where some step hands that field to a pipeline (#145). `dialogue-short` has
28
+ no top-level `num_frames` at all - it has a `shots` list whose entries carry
29
+ one - and the rule is the model's either way.
30
+
31
+ Checked in three places for the reasons the task-argument domains are
32
+ (dw/task_domains.py): statically in `validation_errors`, so a stored default
33
+ or a caller's argument outside the rule is a free refusal at the JSON path it
34
+ sits at; at run time before anything loads, where a value that arrived from
35
+ somewhere the static pass cannot see is snapped or refused; and reported
36
+ beside the variable's default by the catalog, which is the half that stops
37
+ the next consumer picking 61.
38
+ """
39
+
40
+ import logging
41
+ import math
42
+ import numbers
43
+
44
+ logger = logging.getLogger("dw")
45
+
46
+ CONSTRAINTS_KEY = "variable_constraints"
47
+ # What a chain step's `frame_snap` writes instead of repeating the numbers
48
+ CONSTRAINT_PREFIX = "constraint:"
49
+ # The fields a `frame_snap` block carries, which are the fields a constraint
50
+ # is checked on - the rest of a constraint says what to do about a violation
51
+ SNAP_FIELDS = ("modulus", "remainder", "min_frames", "max_frames")
52
+
53
+
54
+ def declared_constraints(definition):
55
+ """The workflow's constraint block, or {}."""
56
+ constraints = definition.get(CONSTRAINTS_KEY)
57
+ return constraints if isinstance(constraints, dict) else {}
58
+
59
+
60
+ def entry_constraint_fields(definition):
61
+ """Which declared constraints reach inside a list-driven variable, as
62
+ {variable: [constraint name, ...]}.
63
+
64
+ A bound is a property of the model the value is handed to, not of the
65
+ place the JSON put it: `dialogue-short` has no top-level `num_frames`
66
+ at all, it has a `shots` list whose entries carry one, and the same
67
+ `17 * n + 5` rule holds for each of them (#145). So a constraint key
68
+ stays a plain variable name and is matched wherever a value by that
69
+ name sits.
70
+
71
+ Matched by what a step *consumes* rather than by the key's spelling:
72
+ `list_fields` reads the `item:<field>` references the steps make, so a
73
+ constraint reaches an entry field only when some step hands that field
74
+ to a pipeline. An entry key nothing reads is already a warning
75
+ (`entry_field_warnings`) and is not silently bound here.
76
+ """
77
+ from .for_each import list_fields
78
+
79
+ constraints = declared_constraints(definition)
80
+ if not constraints:
81
+ return {}
82
+ matched = {}
83
+ for variable, spec in list_fields(definition).items():
84
+ fields = spec.get("fields")
85
+ if not fields:
86
+ continue
87
+ names = sorted(field for field in fields if field in constraints)
88
+ if names:
89
+ matched[variable] = names
90
+ return matched
91
+
92
+
93
+ def entry_targets(definition, values):
94
+ """Every (variable, index, field name, entry) a list-entry constraint
95
+ applies to - the one walk the three checking layers share."""
96
+ for variable, names in sorted(entry_constraint_fields(definition).items()):
97
+ entries = values.get(variable)
98
+ if not isinstance(entries, list):
99
+ continue
100
+ for index, entry in enumerate(entries):
101
+ if not isinstance(entry, dict):
102
+ continue
103
+ for name in names:
104
+ if name in entry:
105
+ yield variable, index, name, entry
106
+
107
+
108
+ def snap_block(constraint):
109
+ """The `frame_snap`-shaped part of a constraint - what a chain step needs
110
+ when it names the constraint rather than repeating it."""
111
+ return {
112
+ key: constraint[key]
113
+ for key in SNAP_FIELDS
114
+ if isinstance(constraint, dict) and key in constraint
115
+ }
116
+
117
+
118
+ def _as_integer(value):
119
+ """The value as an int, or None when this is not a number to check.
120
+
121
+ A string that is still a reference, a null, a list: not a violation, just
122
+ not something this pass can answer about.
123
+ """
124
+ if isinstance(value, bool):
125
+ return None
126
+ if isinstance(value, numbers.Integral):
127
+ return int(value)
128
+ if isinstance(value, numbers.Real) and float(value).is_integer():
129
+ return int(value)
130
+ if isinstance(value, str):
131
+ try:
132
+ return int(value)
133
+ except ValueError:
134
+ return None
135
+ return None
136
+
137
+
138
+ def aligned(value, constraint):
139
+ """The smallest value on the constraint's grid that is not below `value`,
140
+ ignoring the range, or None when there is no grid to align to.
141
+
142
+ The range is checked against *this* number rather than against what the
143
+ caller wrote, because that is what the pipeline does: diffusers'
144
+ `align_num_frames` snaps first and the duration bound then holds for the
145
+ aligned count, which is why 346 frames is refused (it becomes 362) and
146
+ 108 is accepted (it becomes 124).
147
+ """
148
+ number = _as_integer(value)
149
+ modulus = constraint.get("modulus")
150
+ if number is None or not modulus:
151
+ return None
152
+ remainder = constraint.get("remainder", 0) % modulus
153
+ steps = math.ceil((number - remainder) / modulus)
154
+ target = steps * modulus + remainder
155
+ while target < number:
156
+ target += modulus
157
+ return target
158
+
159
+
160
+ def effective(value, constraint):
161
+ """The number the run will actually use - `value` itself, or what
162
+ `snap: "up"` rounds it to. None when this is not a number to check."""
163
+ number = _as_integer(value)
164
+ if number is None:
165
+ return None
166
+ if constraint.get("snap") != "up":
167
+ return number
168
+ target = aligned(number, constraint)
169
+ return number if target is None else target
170
+
171
+
172
+ def snapped(value, constraint):
173
+ """What `value` becomes, or None when it is left alone.
174
+
175
+ Only `snap: "up"` snaps, and only off the grid: rounding is a change to
176
+ what the caller asked for, so it happens where the author said it should
177
+ and nowhere else. 61 frames on an H3 template is not a request for 124 -
178
+ it rounds to 73, which the range refuses; 130 is a request for 141 (#96).
179
+ """
180
+ number = _as_integer(value)
181
+ target = effective(value, constraint)
182
+ if target is None or target == number:
183
+ return None
184
+ return target
185
+
186
+
187
+ def violations(value, constraint):
188
+ """Why `value` breaks `constraint`, as phrases, or [] when it does not.
189
+
190
+ Judged on the value the run would use, so a constraint that rounds is
191
+ only ever refused for a bound the rounded number still breaks.
192
+ """
193
+ number = effective(value, constraint)
194
+ if number is None:
195
+ return []
196
+ problems = []
197
+ modulus = constraint.get("modulus")
198
+ remainder = constraint.get("remainder", 0)
199
+ if modulus and (number - remainder) % modulus != 0:
200
+ problems.append(f"must be {modulus} * n + {remainder}")
201
+ minimum = constraint.get("min_frames")
202
+ if minimum is not None and number < minimum:
203
+ problems.append(f"must be at least {minimum}")
204
+ maximum = constraint.get("max_frames")
205
+ if maximum is not None and number > maximum:
206
+ problems.append(f"must be at most {maximum}")
207
+ return problems
208
+
209
+
210
+ def _accepted(constraint):
211
+ """The rule as a phrase a consumer can act on, which is the half of #96
212
+ that stops the next caller picking 61: the range and the step, together."""
213
+ parts = []
214
+ low, high = constraint.get("min_frames"), constraint.get("max_frames")
215
+ if low is not None and high is not None:
216
+ parts.append(f"{low} to {high}")
217
+ elif low is not None:
218
+ parts.append(f"{low} or more")
219
+ elif high is not None:
220
+ parts.append(f"{high} or less")
221
+ modulus = constraint.get("modulus")
222
+ if modulus:
223
+ parts.append(f"{modulus} * n + {constraint.get('remainder', 0)}")
224
+ return ", ".join(parts)
225
+
226
+
227
+ def refusal(name, value, constraint):
228
+ """The one wording every layer uses for a value outside its constraint."""
229
+ problems = violations(value, constraint)
230
+ if not problems:
231
+ return None
232
+ reason = constraint.get("reason")
233
+ because = f" - {reason}" if reason else ""
234
+ target = snapped(value, constraint)
235
+ rounding = f" (rounds up to {target})" if target is not None else ""
236
+ return (
237
+ f"'{name}' is {value}{rounding}, which this workflow does not "
238
+ f"accept: {'; '.join(problems)}. Accepted: {_accepted(constraint)}{because}. "
239
+ f"The rule is declared on the workflow, so it is checked before "
240
+ f"anything loads rather than after the weights are in memory"
241
+ )
242
+
243
+
244
+ def snap_notice(name, value, constraint):
245
+ """The wording for a value that will be rounded, or None.
246
+
247
+ A value the range still refuses after rounding is not a notice - it is a
248
+ refusal, and saying both would be two answers to one mistake.
249
+ """
250
+ target = snapped(value, constraint)
251
+ if target is None or violations(value, constraint):
252
+ return None
253
+ reason = constraint.get("reason")
254
+ because = f" - {reason}" if reason else ""
255
+ return (
256
+ f"'{name}' is {value}, which this workflow rounds up to {target}"
257
+ f"{because}. The run generates {target}, not {value}"
258
+ )
259
+
260
+
261
+ def constraint_errors(definition, arguments=None, supplied=()):
262
+ """Every declared constraint a value breaks, as [{path, message}].
263
+
264
+ A value the caller supplied is reported at `arguments.<name>`, where they
265
+ wrote it; a stored default at `variables.<name>`. A constraint that
266
+ declares `snap: "up"` and can reach a legal value is a warning rather
267
+ than an error - the run will round it, and saying so is the point.
268
+ """
269
+ constraints = declared_constraints(definition)
270
+ if not constraints:
271
+ return []
272
+ variables = definition.get("variables") or {}
273
+ values = {**variables, **(arguments or {})}
274
+ errors = []
275
+ for name in sorted(constraints):
276
+ constraint = constraints[name]
277
+ if not isinstance(constraint, dict) or name not in values:
278
+ continue
279
+ message = refusal(name, values[name], constraint)
280
+ if message is None:
281
+ # Either legal, or legal once rounded - a value the workflow
282
+ # rounds is a warning (constraint_warnings), never an error
283
+ continue
284
+ where = "arguments" if name in (supplied or ()) else "variables"
285
+ errors.append({"path": f"{where}.{name}", "message": message})
286
+ for variable, index, name, entry in entry_targets(definition, values):
287
+ message = refusal(name, entry[name], constraints[name])
288
+ if message is None:
289
+ continue
290
+ where = "arguments" if variable in (supplied or ()) else "variables"
291
+ errors.append(
292
+ {"path": f"{where}.{variable}[{index}].{name}", "message": message}
293
+ )
294
+ return errors
295
+
296
+
297
+ def constraint_warnings(definition, arguments=None):
298
+ """Every value a declared constraint will round, as messages.
299
+
300
+ The silent half of #96: the engine already rounded `num_frames` up to the
301
+ VAE's grid with a `logger.warning`, which by the #82 rule does not exist
302
+ out there - a caller got a frame count they did not ask for and nothing
303
+ said so.
304
+ """
305
+ constraints = declared_constraints(definition)
306
+ if not constraints:
307
+ return []
308
+ values = {**(definition.get("variables") or {}), **(arguments or {})}
309
+ notices = []
310
+ for name in sorted(constraints):
311
+ constraint = constraints[name]
312
+ if not isinstance(constraint, dict) or name not in values:
313
+ continue
314
+ notice = snap_notice(name, values[name], constraint)
315
+ if notice is not None:
316
+ notices.append(notice)
317
+ for variable, index, name, entry in entry_targets(definition, values):
318
+ notice = snap_notice(name, entry[name], constraints[name])
319
+ if notice is not None:
320
+ notices.append(f"{variable}[{index}]: {notice}")
321
+ return notices
322
+
323
+
324
+ def apply_constraints(definition, variables):
325
+ """Refuse or round the run's variable values, before anything loads.
326
+
327
+ The run-time half, and the backstop for everything the static pass cannot
328
+ see - an inline workflow, a value a parent workflow passed down. Rounds in
329
+ place and emits a warning for each rounded value, so a frame count the run
330
+ changed reaches the job's `warnings` rather than only the log; raises
331
+ ValueError for a value no rule can reach.
332
+ """
333
+ from .events import emit_warning
334
+
335
+ constraints = declared_constraints(definition)
336
+ if not constraints or not isinstance(variables, dict):
337
+ return
338
+ for name in sorted(constraints):
339
+ constraint = constraints[name]
340
+ if not isinstance(constraint, dict) or name not in variables:
341
+ continue
342
+ value = variables[name]
343
+ notice = snap_notice(name, value, constraint)
344
+ if notice is not None:
345
+ variables[name] = snapped(value, constraint)
346
+ emit_warning(
347
+ notice, kind="value_snapped", variable=name, value=variables[name]
348
+ )
349
+ continue
350
+ message = refusal(name, value, constraint)
351
+ if message is not None:
352
+ raise ValueError(message)
353
+ # The same three answers for a value sitting in a list entry, where the
354
+ # rule is the model's all the same (#145)
355
+ for variable, index, name, entry in entry_targets(definition, variables):
356
+ constraint = constraints[name]
357
+ value = entry[name]
358
+ notice = snap_notice(name, value, constraint)
359
+ if notice is not None:
360
+ entry[name] = snapped(value, constraint)
361
+ emit_warning(
362
+ f"{variable}[{index}]: {notice}",
363
+ kind="value_snapped",
364
+ variable=f"{variable}[{index}].{name}",
365
+ value=entry[name],
366
+ )
367
+ continue
368
+ message = refusal(name, value, constraint)
369
+ if message is not None:
370
+ raise ValueError(f"{variable}[{index}]: {message}")
371
+
372
+
373
+ def resolve_constraint_references(definition):
374
+ """Replace every `"frame_snap": "constraint:<name>"` with the declared
375
+ constraint's numbers, in place.
376
+
377
+ So a template states `17n + 5` once - on the variable, where the catalog
378
+ reports it and validation checks it - rather than twice, with a chain
379
+ step's copy free to drift from it. A name that is not declared is an
380
+ error rather than a silently absent constraint: a chain that snapped to
381
+ nothing would stitch segments the pipeline refuses.
382
+ """
383
+ constraints = declared_constraints(definition)
384
+
385
+ def walk(node):
386
+ if isinstance(node, list):
387
+ for item in node:
388
+ walk(item)
389
+ return
390
+ if not isinstance(node, dict):
391
+ return
392
+ reference = node.get("frame_snap")
393
+ if isinstance(reference, str) and reference.startswith(CONSTRAINT_PREFIX):
394
+ name = reference[len(CONSTRAINT_PREFIX) :]
395
+ if name not in constraints:
396
+ raise ValueError(
397
+ f"'frame_snap': '{reference}' names no entry of this "
398
+ f"workflow's 'variable_constraints'. Declared: "
399
+ + (", ".join(sorted(constraints)) or "<none>")
400
+ )
401
+ node["frame_snap"] = snap_block(constraints[name])
402
+ for value in node.values():
403
+ walk(value)
404
+
405
+ walk(definition)
406
+ return definition
407
+
408
+
409
+ def constraint_reference_errors(definition):
410
+ """`frame_snap: "constraint:x"` naming nothing declared, as
411
+ [{path, message}] - the validation-time form of the error
412
+ resolve_constraint_references raises."""
413
+ constraints = declared_constraints(definition)
414
+ errors = []
415
+
416
+ def walk(node, path):
417
+ if isinstance(node, list):
418
+ for index, item in enumerate(node):
419
+ walk(item, f"{path}[{index}]")
420
+ return
421
+ if not isinstance(node, dict):
422
+ return
423
+ for key, value in node.items():
424
+ where = f"{path}.{key}" if path else key
425
+ if (
426
+ key == "frame_snap"
427
+ and isinstance(value, str)
428
+ and value.startswith(CONSTRAINT_PREFIX)
429
+ and value[len(CONSTRAINT_PREFIX) :] not in constraints
430
+ ):
431
+ errors.append(
432
+ {
433
+ "path": where,
434
+ "message": (
435
+ f"'{value}' names no entry of this workflow's "
436
+ f"'variable_constraints'. Declared: "
437
+ + (", ".join(sorted(constraints)) or "<none>")
438
+ ),
439
+ }
440
+ )
441
+ walk(value, where)
442
+
443
+ walk(definition, "")
444
+ return errors