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/step_cache.py ADDED
@@ -0,0 +1,442 @@
1
+ """Skip re-executing a step whose resolved definition, seed, and upstream
2
+ results are all unchanged since the last run in this process.
3
+
4
+ Reusing a loaded pipeline (Workflow.run's previous_pipelines) is only half
5
+ of what makes REPL iteration fast - the other half is not re-running a
6
+ step's forward pass at all when nothing feeding it changed, the way
7
+ Mellon's NodeBase skips a node whose resolved params match its last call
8
+ (deep value equality, not just identity - a step's arguments are plain
9
+ dicts/lists/scalars after variable substitution, not hashable).
10
+
11
+ A step is safe to skip only if:
12
+ 1. its own resolved definition (step_data) matches last run's, AND
13
+ 2. its seed matches last run's - step_data does NOT carry the seed
14
+ (Workflow.run resolves it separately, and draws a fresh random one
15
+ per run when the workflow sets none), so seed must be compared
16
+ explicitly or two differently-seeded runs would wrongly look identical
17
+ 3. every previous_result: it reads was ITSELF served from cache this run
18
+ AND the upstream entry now in the cache is the same generation this
19
+ entry was computed from - "hit this run" alone is not enough, because
20
+ a run cancelled between an upstream's put and this step's leaves this
21
+ entry describing an upstream that has since been recomputed
22
+ 4. the effective output directory matches last run's - like seed, this
23
+ is out-of-band (not part of step_data), and a cache hit reuses the
24
+ entry's saved_files/manifest paths verbatim, so a mismatch must force
25
+ a miss or a changed output dir would silently keep pointing at the
26
+ old directory's files
27
+ 5. every file the cached result names still exists - a hit republishes
28
+ those paths into the manifest and job history, so a file deleted
29
+ since (gallery delete button, or by hand) must force a re-run
30
+ 6. the entry retained the step's Result if this run needs one - an entry
31
+ stored for a step nothing downstream read holds only its saved_files
32
+
33
+ Entries are keyed by (workflow_id, step_name), not by step name alone:
34
+ saved files are named "{workflow_id}-{step_name}.{index}", so a bare-name
35
+ key would let a different workflow (or the same one after an id rename, or
36
+ a sub-workflow sharing a name with its parent) hit and republish the other
37
+ workflow's file paths while writing none of its own.
38
+
39
+ The cache is per-process and bounded two ways, LRU either way: by entry
40
+ count (DEFAULT_MAX_ENTRIES), sized so a maximal for_each run (32 entries
41
+ over two groups plus fixed steps, ~70 members) never evicts its own earlier
42
+ members before it ends - a smaller cap would turn a long list-driven run
43
+ into one that thrashes its own cache - and by the approximate byte size of
44
+ the retained media itself (DEFAULT_MAX_RETAINED_BYTES). A for_each group
45
+ whose members are each a full decoded video (dialogue-short's `shot`, gathered
46
+ by `episode`) is retained in full for every member - correctly, since the
47
+ final step genuinely reads all of them - but nothing ever released that once
48
+ the run finished, so a handful of members held several GB resident
49
+ indefinitely (#368). The byte cap does not know which entries a future run
50
+ would most want back; it just keeps the newest-used bytes under budget the
51
+ same way the entry cap keeps the newest-used count under one, oldest first.
52
+ """
53
+
54
+ import copy
55
+ import dataclasses
56
+ import itertools
57
+ import logging
58
+ import os
59
+ from collections import OrderedDict
60
+
61
+ import numpy as np
62
+ import torch
63
+ from PIL import Image
64
+
65
+ logger = logging.getLogger("dw")
66
+
67
+ # Monotonic across the process: every put stamps its entry, and a downstream
68
+ # entry records the stamp of each upstream it was computed from. Never reset
69
+ # (clear() included) - a reused number would make a stale entry look fresh.
70
+ _generations = itertools.count(1)
71
+
72
+
73
+ def reference_resolves_to(reference, name):
74
+ """Whether a previous_result reference resolves to the result `name`.
75
+
76
+ A reference either names a result outright or extends it with a property
77
+ ('segment.mask'). The step cache, release_unreferenced_results and
78
+ get_previous_results all ask some form of this question.
79
+ """
80
+ return reference == name or reference.startswith(name + ".")
81
+
82
+
83
+ def referenced_result_names(steps):
84
+ """Every previous_result reference the given steps make, as full names.
85
+
86
+ Scans nested dicts and lists, so references inside pipeline arguments,
87
+ task arguments and sub-workflow argument maps are all found - including a
88
+ constructed object's 'from_previous_result', which names a step without
89
+ the 'previous_result:' prefix.
90
+ """
91
+ prefix = "previous_result:"
92
+ names = set()
93
+
94
+ def scan(value):
95
+ if isinstance(value, str) and value.startswith(prefix):
96
+ names.add(value[len(prefix) :])
97
+ elif isinstance(value, dict):
98
+ reference = value.get("from_previous_result")
99
+ if isinstance(reference, str):
100
+ names.add(reference)
101
+ for item in value.values():
102
+ scan(item)
103
+ elif isinstance(value, list):
104
+ for item in value:
105
+ scan(item)
106
+
107
+ for step in steps:
108
+ scan(step)
109
+ return names
110
+
111
+
112
+ # Tasks that reset a result's level before anything downstream ships it -
113
+ # a result only these read is not itself a headroom concern (dw/result.py,
114
+ # warn_without_headroom)
115
+ NORMALIZING_COMMANDS = {"normalize_audio", "match_levels"}
116
+
117
+
118
+ def normalized_downstream(steps, name):
119
+ """Whether a later normalize_audio/match_levels step consumes result `name`.
120
+
121
+ write_song's raw Music 3 mp3 (templates/minimax/music-video) always lands
122
+ at or over full scale and is always normalized before the deliverable
123
+ mux - the pre-ship level is the template's documented, designed-in input
124
+ condition, not a mistake, so a headroom warning on that intermediate
125
+ save trains the reader to ignore job.warnings (#286). Scoped to the two
126
+ tasks that actually reset level, not to any downstream consumer: a step
127
+ that only reads the raw result (a conditioning slice, say) does not
128
+ change what its own written file will sound like.
129
+ """
130
+ for step in steps:
131
+ if not isinstance(step, dict):
132
+ continue
133
+ task = step.get("task")
134
+ if (
135
+ not isinstance(task, dict)
136
+ or task.get("command") not in NORMALIZING_COMMANDS
137
+ ):
138
+ continue
139
+ if any(
140
+ reference_resolves_to(ref, name) for ref in referenced_result_names([step])
141
+ ):
142
+ return True
143
+ return False
144
+
145
+
146
+ def deep_equal(a, b):
147
+ """Value equality across the JSON-ish types a resolved step definition holds.
148
+
149
+ realize_args runs before the step loop (and before for_each expansion's
150
+ per-member `from_file` construction), so a resolved argument can be a
151
+ diffusers reference dataclass wrapping in-memory media - a
152
+ MiniMaxH3ImageReference, an LTX2ReferenceCondition - built fresh by
153
+ `from_file()` on every run from the same source file. Two such instances
154
+ hold identical media but are never `==`: PIL.Image has no value equality
155
+ (falls back to identity), and comparing torch.Tensor/np.ndarray fields
156
+ with `==` yields an array rather than a bool, which the dataclass's
157
+ generated `__eq__` cannot resolve to True/False and which the fallback
158
+ below (correctly) treats as "cannot tell, so unequal". Left unhandled,
159
+ that made every for_each member carrying an image/audio/video reference
160
+ an unconditional cache miss - identical inputs included (#253). These
161
+ checks give those types real value equality before the generic fallback.
162
+ """
163
+ if a is b:
164
+ return True
165
+ if type(a) is not type(b):
166
+ return False
167
+ if isinstance(a, dict):
168
+ return a.keys() == b.keys() and all(deep_equal(a[k], b[k]) for k in a)
169
+ if isinstance(a, (list, tuple)):
170
+ return len(a) == len(b) and all(deep_equal(x, y) for x, y in zip(a, b))
171
+ if isinstance(a, Image.Image):
172
+ return a.mode == b.mode and a.size == b.size and a.tobytes() == b.tobytes()
173
+ if isinstance(a, np.ndarray):
174
+ return a.shape == b.shape and a.dtype == b.dtype and bool(np.array_equal(a, b))
175
+ if isinstance(a, torch.Tensor):
176
+ return (
177
+ a.shape == b.shape
178
+ and a.dtype == b.dtype
179
+ and bool(torch.equal(a.cpu(), b.cpu()))
180
+ )
181
+ if dataclasses.is_dataclass(a) and not isinstance(a, type):
182
+ return all(
183
+ deep_equal(getattr(a, f.name), getattr(b, f.name))
184
+ for f in dataclasses.fields(a)
185
+ )
186
+ try:
187
+ # An exotic object's == can still raise, or (for a type not caught
188
+ # above) return something other than a bool. A value that cannot
189
+ # answer "are these equal" cleanly is treated as unequal: a cache
190
+ # miss just re-runs the step, where a raised exception would abort
191
+ # the whole run
192
+ return bool(a == b)
193
+ except (ValueError, TypeError, RuntimeError):
194
+ return False
195
+
196
+
197
+ def _approx_bytes(value, seen):
198
+ """Approximate resident size of a retained result item, in bytes.
199
+
200
+ Walks the same shapes a pipeline output actually takes - a dict/dataclass
201
+ of arrays wrapping frames and audio, nested lists of per-frame images -
202
+ rather than every Python object, so an unrecognized type (a bare string,
203
+ a plain number) costs nothing rather than raising. `seen` is shared
204
+ across one Result's whole result_list so an object two artifacts both
205
+ reference (get_artifact_list's fitted-in-place audio, say) is not
206
+ double-counted.
207
+ """
208
+ key = id(value)
209
+ if key in seen:
210
+ return 0
211
+ seen.add(key)
212
+ if isinstance(value, torch.Tensor):
213
+ return value.element_size() * value.nelement()
214
+ if isinstance(value, np.ndarray):
215
+ return value.nbytes
216
+ if isinstance(value, Image.Image):
217
+ bands = len(value.getbands()) or 1
218
+ return value.width * value.height * bands
219
+ if isinstance(value, (bytes, bytearray)):
220
+ return len(value)
221
+ if isinstance(value, dict):
222
+ return sum(_approx_bytes(v, seen) for v in value.values())
223
+ if isinstance(value, (list, tuple)):
224
+ return sum(_approx_bytes(v, seen) for v in value)
225
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
226
+ return sum(
227
+ _approx_bytes(getattr(value, f.name), seen)
228
+ for f in dataclasses.fields(value)
229
+ )
230
+ return 0
231
+
232
+
233
+ def _result_bytes(result):
234
+ seen = set()
235
+ return sum(_approx_bytes(item, seen) for item in result.result_list)
236
+
237
+
238
+ class StepCache:
239
+ """Per-process cache of the last Result produced for each
240
+ (workflow_id, step_name).
241
+
242
+ Bounded: entries hold realized media, so an unbounded cache would work
243
+ directly against release_unreferenced_results' OOM avoidance. The
244
+ least-recently-used entry is evicted once the cap is reached. The
245
+ default is sized so a maximal for_each run - 32 entries over two groups
246
+ plus fixed steps, ~70 members - fits without evicting its own earlier
247
+ members.
248
+ """
249
+
250
+ DEFAULT_MAX_ENTRIES = 128
251
+ # 4 GiB: enough for several retained shot@ videos at once, small next to
252
+ # the VRAM/RAM a generation step itself needs, and never the only thing
253
+ # standing between a run and OOM - release_unreferenced_results and the
254
+ # entry cap both still apply
255
+ DEFAULT_MAX_RETAINED_BYTES = 4 * 1024**3
256
+
257
+ def __init__(self, max_entries=None, max_retained_bytes=None):
258
+ # (workflow_id, step_name) -> {"step_data", "step_seed", "result",
259
+ # "output_dir", "generation", "upstream_generations", "retained",
260
+ # "size"}, ordered least- to most-recently-used
261
+ self._entries = OrderedDict()
262
+ self.max_entries = (
263
+ self.DEFAULT_MAX_ENTRIES if max_entries is None else max_entries
264
+ )
265
+ self.max_retained_bytes = (
266
+ self.DEFAULT_MAX_RETAINED_BYTES
267
+ if max_retained_bytes is None
268
+ else max_retained_bytes
269
+ )
270
+ self._retained_bytes = 0
271
+
272
+ def clear(self):
273
+ # The generation counter deliberately survives: it only has to be
274
+ # monotonic, and restarting it could make a stale reference match
275
+ self._entries.clear()
276
+ self._retained_bytes = 0
277
+
278
+ def stats(self):
279
+ """A read-only snapshot for a caller that cannot see inside the
280
+ worker process otherwise (#418) - entries/retained_bytes are exactly
281
+ the eviction accounting above, so this is how a stale-drop or an
282
+ LRU-evict actually freeing bytes becomes externally observable."""
283
+ return {
284
+ "entries": len(self._entries),
285
+ "max_entries": self.max_entries,
286
+ "retained_bytes": self._retained_bytes,
287
+ "max_retained_bytes": self.max_retained_bytes,
288
+ }
289
+
290
+ def get(
291
+ self, workflow_id, step_data, step_seed, hits_this_run, output_dir, needs_result
292
+ ):
293
+ """Return the cached Result for this step if it's still valid, else None.
294
+
295
+ `needs_result` says whether this run reads the step's Result (a later
296
+ step references it, or it is the workflow's return value); an entry
297
+ stored without one cannot serve such a run.
298
+ """
299
+ name = step_data["name"]
300
+ key = (workflow_id, name)
301
+ entry = self._entries.get(key)
302
+ if entry is None:
303
+ logger.debug(f"No cache entry for step '{name}' - treating as a miss")
304
+ return None
305
+ if entry["step_seed"] != step_seed:
306
+ logger.debug(
307
+ f"Cached result for step '{name}' used a different seed - treating as a miss"
308
+ )
309
+ return None
310
+ # The output *root* a run was told to write to. A run directory is
311
+ # new every execution and would defeat the cache; the root changing
312
+ # means the caller asked for output somewhere the cached files are not
313
+ if entry["output_dir"] != output_dir:
314
+ logger.debug(
315
+ f"Cached result for step '{name}' used a different output_dir - treating as a miss"
316
+ )
317
+ return None
318
+ if needs_result and not entry["retained"]:
319
+ logger.debug(
320
+ f"Cached result for step '{name}' did not retain its Result, and this run needs one - treating as a miss"
321
+ )
322
+ return None
323
+ if not deep_equal(entry["step_data"], step_data):
324
+ logger.debug(
325
+ f"Cached result for step '{name}' has different resolved arguments - treating as a miss"
326
+ )
327
+ return None
328
+
329
+ upstream = referenced_result_names([step_data])
330
+ if not all(self._is_hit(ref, hits_this_run) for ref in upstream):
331
+ return None
332
+ # Hitting this run is not the same as being the run this entry was
333
+ # computed from - compare the upstream generations too
334
+ for upstream_name, generation in entry["upstream_generations"].items():
335
+ current = self._entries.get((workflow_id, upstream_name))
336
+ if current is None or current["generation"] != generation:
337
+ logger.debug(
338
+ f"Cached result for step '{name}' was computed from an "
339
+ f"older '{upstream_name}' - treating as a miss"
340
+ )
341
+ return None
342
+
343
+ # A hit reports the entry's saved_files verbatim into the manifest
344
+ # and job history - if the user deleted one of them (gallery delete
345
+ # button, or by hand), the entry is stale and the step must re-run
346
+ if not self._saved_files_exist(entry["result"]):
347
+ logger.debug(
348
+ f"Cached result for step '{name}' names a file that no longer "
349
+ "exists - treating as a miss"
350
+ )
351
+ stale = self._entries.pop(key, None)
352
+ if stale is not None:
353
+ self._retained_bytes -= stale["size"]
354
+ return None
355
+
356
+ self._entries.move_to_end(key)
357
+ return entry["result"]
358
+
359
+ def put(self, workflow_id, step_data, step_seed, result, output_dir, retain_result):
360
+ """Record this step's outcome.
361
+
362
+ `retain_result` says whether anything reads the Result itself. When
363
+ False only a stripped copy is kept - saved_files and definition, but
364
+ no result_list - so decoded frames and latent tensors are not pinned
365
+ for the life of the cache, which is exactly what
366
+ release_unreferenced_results drops them to avoid.
367
+
368
+ A Result whose result_list holds an artifact save() already spilled
369
+ and cleaned up (a chain step's save_segments - see Result.retainable)
370
+ is downgraded to the same stripped storage even when retain_result is
371
+ True: its files are gone already by the time put() runs (save()
372
+ happens before put() in the step loop), so keeping the result_list
373
+ would let a later cache hit fail opening files that no longer exist.
374
+ """
375
+ retain_result = retain_result and getattr(result, "retainable", True)
376
+ name = step_data["name"]
377
+ key = (workflow_id, name)
378
+ size = _result_bytes(result) if retain_result else 0
379
+ previous = self._entries.get(key)
380
+ if previous is not None:
381
+ self._retained_bytes -= previous["size"]
382
+ self._entries[key] = {
383
+ "step_data": step_data,
384
+ "step_seed": step_seed,
385
+ "result": result if retain_result else self._strip_result(result),
386
+ "retained": retain_result,
387
+ "output_dir": output_dir,
388
+ "generation": next(_generations),
389
+ "upstream_generations": self._upstream_generations(workflow_id, step_data),
390
+ "size": size,
391
+ }
392
+ self._retained_bytes += size
393
+ self._entries.move_to_end(key)
394
+ while len(self._entries) > 1 and (
395
+ len(self._entries) > self.max_entries
396
+ or self._retained_bytes > self.max_retained_bytes
397
+ ):
398
+ (evicted_workflow, evicted_step), evicted = self._entries.popitem(
399
+ last=False
400
+ )
401
+ self._retained_bytes -= evicted["size"]
402
+ logger.debug(
403
+ "Step cache full - evicting least recently used "
404
+ f"'{evicted_workflow}/{evicted_step}'"
405
+ )
406
+
407
+ def _upstream_generations(self, workflow_id, step_data):
408
+ """The generation of every cached step this step's references read."""
409
+ generations = {}
410
+ for reference in referenced_result_names([step_data]):
411
+ for (entry_workflow, entry_step), entry in self._entries.items():
412
+ if entry_workflow == workflow_id and reference_resolves_to(
413
+ reference, entry_step
414
+ ):
415
+ generations[entry_step] = entry["generation"]
416
+ return generations
417
+
418
+ @staticmethod
419
+ def _strip_result(result):
420
+ """A shallow copy of the Result with its realized media dropped."""
421
+ stripped = copy.copy(result)
422
+ stripped.result_list = []
423
+ # A fresh cache, not the shallow copy's shared one: save() fills
424
+ # _artifact_cache with the artifacts it extracted - decoded frames, a
425
+ # waveform, possibly still on the GPU - so carrying it over would pin
426
+ # exactly what dropping result_list exists to release. The original
427
+ # Result keeps its own cache; only this entry's copy starts empty.
428
+ stripped._artifact_cache = {}
429
+ return stripped
430
+
431
+ @staticmethod
432
+ def _saved_files_exist(result):
433
+ # Read straight through: a result-like object with no saved_files is
434
+ # a programming error, not an entry that quietly skips the file check
435
+ return all(os.path.exists(path) for path in result.saved_files)
436
+
437
+ @staticmethod
438
+ def _is_hit(ref, hits_this_run):
439
+ return any(reference_resolves_to(ref, n) for n in hits_this_run)
440
+
441
+
442
+ step_cache = StepCache()
dw/subfolders.py ADDED
@@ -0,0 +1,107 @@
1
+ """A step's result 'subfolder': where under the run directory its files go.
2
+
3
+ A run writes everything into one directory, so a finished episode sits
4
+ beside the twenty scratch files that went into it, distinguished only by
5
+ the step name in each file name. 'subfolder' on a step's result block puts
6
+ that step's files into a subfolder of the run directory instead - by
7
+ convention 'final' or 'intermediate', though the engine treats no name
8
+ specially. Nothing else about placement changes: a step without one writes
9
+ where it always did.
10
+
11
+ This module owns the shape check and the static pass over an expanded
12
+ definition. Containment - that the joined path really is inside the run
13
+ directory - is the engine's, at the moment it joins (see
14
+ Workflow.step_output_dir).
15
+ """
16
+
17
+ from .for_each import MEMBER_SEPARATOR, render_path
18
+ from .security import (
19
+ InvalidInputError,
20
+ validate_file_base_name,
21
+ validate_subfolder,
22
+ )
23
+
24
+ SUBFOLDER_KEY = "subfolder"
25
+ FILE_BASE_NAME_KEY = "file_base_name"
26
+
27
+ # Reference prefixes substitution resolves before this pass runs. One still
28
+ # spelled out here is one nothing resolved, and that is the undeclared-
29
+ # variable pass's complaint rather than a shape error
30
+ _UNRESOLVED_PREFIXES = ("variable:", "item:")
31
+
32
+
33
+ def step_subfolder(step_definition):
34
+ """The validated subfolder a step's result names, or '' when it names
35
+ none.
36
+
37
+ Raises:
38
+ InvalidInputError: If the value is not a string or not a valid
39
+ subfolder
40
+ """
41
+ result = step_definition.get("result")
42
+ if not isinstance(result, dict) or SUBFOLDER_KEY not in result:
43
+ return ""
44
+ value = result[SUBFOLDER_KEY]
45
+ if not isinstance(value, str):
46
+ raise InvalidInputError(
47
+ f"Invalid subfolder: {value!r} - a subfolder is a string like 'final'"
48
+ )
49
+ return validate_subfolder(value)
50
+
51
+
52
+ def subfolder_errors(workflow_definition, source_indices=None):
53
+ """Every result 'subfolder' or 'file_base_name' that cannot be written,
54
+ as [{path, message}].
55
+
56
+ The definition handed here has already been substituted and expanded,
57
+ so every value in it is literal; a 'variable:' or 'item:' still spelled
58
+ out is left alone. `source_indices`, when given, is the source step
59
+ index of each step - a 'for_each' group turns one written step into
60
+ several, and the path an error carries has to be one the author can
61
+ find in the file they wrote; the member is named in the message.
62
+ """
63
+ steps = workflow_definition.get("steps")
64
+ if not isinstance(steps, list):
65
+ return []
66
+
67
+ errors = []
68
+ for index, step in enumerate(steps):
69
+ if not isinstance(step, dict):
70
+ continue
71
+ result = step.get("result")
72
+ if not isinstance(result, dict):
73
+ continue
74
+ source = (
75
+ source_indices[index]
76
+ if source_indices is not None and index < len(source_indices)
77
+ else index
78
+ )
79
+ name = step.get("name")
80
+ where = (
81
+ f" in member '{name}'"
82
+ if isinstance(name, str) and MEMBER_SEPARATOR in name
83
+ else ""
84
+ )
85
+ for key, check in (
86
+ (SUBFOLDER_KEY, validate_subfolder),
87
+ (FILE_BASE_NAME_KEY, validate_file_base_name),
88
+ ):
89
+ if key not in result:
90
+ continue
91
+ value = result[key]
92
+ if isinstance(value, str) and value.startswith(_UNRESOLVED_PREFIXES):
93
+ continue
94
+ try:
95
+ if not isinstance(value, str):
96
+ raise InvalidInputError(
97
+ f"Invalid {key}: {value!r} - expected a string"
98
+ )
99
+ check(value)
100
+ except InvalidInputError as e:
101
+ errors.append(
102
+ {
103
+ "path": render_path(("steps", source, "result", key)),
104
+ "message": f"{e}{where}",
105
+ }
106
+ )
107
+ return errors