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/server/assess.py ADDED
@@ -0,0 +1,132 @@
1
+ """Assess a finished output in the server process (#388).
2
+
3
+ `GET /api/gallery/{name}/assess` runs the assessment probes
4
+ (`dw/tasks/assess.py`) against one gallery file or asset without queueing
5
+ anything: the probes are CPU-only and read a file, so the route is a sync
6
+ `def` FastAPI runs in its threadpool, beside whatever job holds the GPU.
7
+
8
+ One request is one decode: the file is streamed once into a `Media` and
9
+ every applicable probe reads that. The shot boundaries are the ones the
10
+ caller's root recorded - the run manifest for an output, the sidecar
11
+ `keep_output` wrote for an asset (#393) - resolved here rather than by the
12
+ probe, which cannot know which workspace the file belongs to.
13
+
14
+ What comes back are places to look, not verdicts; nothing acts on a
15
+ finding (`dw/assessment_rules.py`).
16
+ """
17
+
18
+ import json
19
+
20
+ from ..tasks.assess import (
21
+ read_media,
22
+ seams_answer,
23
+ shots_answer,
24
+ sync_drift_answer,
25
+ )
26
+
27
+ PROBES = {
28
+ "analyze_shots": shots_answer,
29
+ "analyze_seams": seams_answer,
30
+ "analyze_sync_drift": sync_drift_answer,
31
+ }
32
+
33
+
34
+ def unknown_probe(probe):
35
+ """The 400 detail for a probe outside the whitelist, or None."""
36
+ if probe is None or probe in PROBES:
37
+ return None
38
+ return f"Unknown probe {probe!r} - one of {', '.join(PROBES)}"
39
+
40
+
41
+ def _dedupe_findings(findings):
42
+ """Findings with exact duplicates dropped, order preserved.
43
+
44
+ `_shot_span_findings` (`dw/tasks/assess.py`) is shared by all three
45
+ probes and raises the identical finding from each one that ran, so a
46
+ file with a span overrun listed it three times in the compact merge
47
+ (#427) - one true finding, not three.
48
+ """
49
+ seen = set()
50
+ deduped = []
51
+ for found in findings:
52
+ key = json.dumps(found, sort_keys=True, default=str)
53
+ if key in seen:
54
+ continue
55
+ seen.add(key)
56
+ deduped.append(found)
57
+ return deduped
58
+
59
+
60
+ def _not_applicable(kind, media, records):
61
+ """Why each probe cannot say anything about this file: {probe: why}."""
62
+ if media is None:
63
+ why = (
64
+ "a still has no shots, seams or soundtrack to measure"
65
+ if kind == "image"
66
+ else "not a video or audio file"
67
+ )
68
+ return dict.fromkeys(PROBES, why)
69
+ reasons = {}
70
+ if media.audio is None:
71
+ reasons["analyze_shots"] = "no audio track"
72
+ reasons["analyze_sync_drift"] = "no audio track"
73
+ elif media.thumbs is None:
74
+ reasons["analyze_sync_drift"] = "no picture to sync against"
75
+ if not records or len(records) < 2:
76
+ reasons["analyze_seams"] = (
77
+ "no shot boundaries recorded for this file - analyze_seams "
78
+ "needs two or more shots"
79
+ )
80
+ return reasons
81
+
82
+
83
+ def assess(path, kind, shots, probe=None, detail=False):
84
+ """Run the applicable probes over one file.
85
+
86
+ path: the validated file; kind: its MEDIA_KINDS entry ("video", "audio",
87
+ "image", ...); shots: the shot records its root recorded, or None.
88
+
89
+ With `probe`, that probe's full answer. Otherwise every applicable
90
+ probe's findings merged, with `rules_applied`, `rules_skipped` and
91
+ `not_applicable`; `detail` adds each probe's full answer under `probes`.
92
+ """
93
+ media = read_media(path) if kind in ("audio", "video") else None
94
+ records = [dict(shot) for shot in shots] if shots else None
95
+ source = "manifest" if records else "none"
96
+ reasons = _not_applicable(kind, media, records)
97
+
98
+ if probe is not None:
99
+ # Asked by name: a probe the file cannot feed at all says why; one
100
+ # it can (a shotless seam pass included) answers in full, its own
101
+ # rules_skipped saying what could not be measured
102
+ blocked = reasons.get(probe)
103
+ if media is None or (blocked and probe != "analyze_seams"):
104
+ return {"probe": probe, "not_applicable": {probe: blocked}}
105
+ return {"probe": probe, **PROBES[probe](media, records, source)}
106
+
107
+ answers = {
108
+ name: run(media, records, source)
109
+ for name, run in PROBES.items()
110
+ if name not in reasons
111
+ }
112
+ body = {
113
+ "shots_source": source,
114
+ "findings": _dedupe_findings(
115
+ found for answer in answers.values() for found in answer["findings"]
116
+ ),
117
+ "rules_applied": [
118
+ rule for answer in answers.values() for rule in answer["rules_applied"]
119
+ ],
120
+ "rules_skipped": [
121
+ {"probe": name, **skipped}
122
+ for name, answer in answers.items()
123
+ for skipped in answer["rules_skipped"]
124
+ ],
125
+ "not_applicable": reasons,
126
+ }
127
+ if detail:
128
+ body["probes"] = answers
129
+ return body
130
+
131
+
132
+ __all__ = ["PROBES", "assess", "unknown_probe"]
@@ -0,0 +1,487 @@
1
+ """What a workflow makes, read off its definition.
2
+
3
+ A catalog entry is chosen by shape before anything else - one still, a set,
4
+ one shot, a cut sequence - and the definition already says which, in the
5
+ steps it has and what they feed each other. Deriving it here means the
6
+ vocabulary cannot go stale against the file and needs no backfill; a
7
+ declared `shape`/`traits`/`summary` overrides for the odd workflow the
8
+ rules misread.
9
+
10
+ Reads the raw JSON only: no variable substitution, no type loading, so it
11
+ costs a dict walk and behaves the same on a repo template and a file an
12
+ agent saved a second ago. Nothing here names a model family - the rules
13
+ read structure (a concat step, a `references` argument), never checkpoints.
14
+ """
15
+
16
+ import re
17
+
18
+ from ..for_each import list_fields
19
+ from ..variable_constraints import declared_constraints, entry_constraint_fields
20
+
21
+ SHAPES = (
22
+ "image",
23
+ "image-set",
24
+ "image-edit",
25
+ "shot",
26
+ "sequence",
27
+ "audio",
28
+ "text",
29
+ "utility",
30
+ )
31
+ TRAITS = (
32
+ "has-audio",
33
+ "chained",
34
+ "image-conditioned",
35
+ "identity-referenced",
36
+ "needs-input-media",
37
+ "composes-workflows",
38
+ )
39
+ # Tasks that create content rather than process it. A workflow made only
40
+ # of processing tasks is a utility.
41
+ GENERATIVE_TASKS = frozenset(
42
+ {
43
+ "generate_speech",
44
+ "text_generation",
45
+ "image_to_text",
46
+ "diffusion_upscale",
47
+ "interpolate_frames",
48
+ }
49
+ )
50
+ SUMMARY_LIMIT = 120
51
+
52
+ _KIND_PRECEDENCE = ("video", "audio", "image", "text")
53
+ _EDIT_PIPELINE = re.compile(r"inpaint|img2img|edit|upscale|outpaint", re.I)
54
+ _CHAIN_ARGUMENTS = frozenset(
55
+ {"last_frame", "last_segment", "last_image", "match_audio"}
56
+ )
57
+ _MEDIA_ARGUMENTS = frozenset(
58
+ {"image", "video", "audio", "mask_image", "urls", "videos", "clip"}
59
+ )
60
+ _CUT_TASKS = frozenset({"concat_videos", "dissolve_videos"})
61
+ # Components that exist only to synthesise a waveform. A video pipeline
62
+ # carrying one emits an audio track whether or not it says so in `output`.
63
+ _AUDIO_COMPONENTS = frozenset({"vocoder", "audio_vae"})
64
+ _SENTENCE_END = re.compile(r"(?<=[.!?])\s|\n")
65
+
66
+
67
+ def _steps(definition):
68
+ steps = definition.get("steps") if isinstance(definition, dict) else None
69
+ return (
70
+ [step for step in steps if isinstance(step, dict)]
71
+ if isinstance(steps, list)
72
+ else []
73
+ )
74
+
75
+
76
+ def _block(step):
77
+ """The step's one body: pipeline, pipeline_reference, task or workflow."""
78
+ for key in ("pipeline", "pipeline_reference", "task", "workflow"):
79
+ body = step.get(key)
80
+ if isinstance(body, dict):
81
+ return key, body
82
+ return None, {}
83
+
84
+
85
+ def _arguments(step):
86
+ _kind, body = _block(step)
87
+ arguments = body.get("arguments")
88
+ return arguments if isinstance(arguments, dict) else {}
89
+
90
+
91
+ def _kind(step):
92
+ result = step.get("result")
93
+ if isinstance(result, dict) and isinstance(result.get("content_type"), str):
94
+ return result["content_type"].split("/")[0]
95
+ return None
96
+
97
+
98
+ def _generates(step):
99
+ """Whether the step creates content: a pipeline, a reference to one, a
100
+ sub-workflow, or a generative task."""
101
+ key, body = _block(step)
102
+ if key in ("pipeline", "pipeline_reference", "workflow"):
103
+ return True
104
+ return key == "task" and body.get("command") in GENERATIVE_TASKS
105
+
106
+
107
+ def _component_type(step):
108
+ key, body = _block(step)
109
+ if key != "pipeline":
110
+ return ""
111
+ configuration = body.get("configuration")
112
+ if not isinstance(configuration, dict):
113
+ return ""
114
+ return str(configuration.get("component_type", ""))
115
+
116
+
117
+ def _fed_by(value):
118
+ """Distinct step names a value's previous_result references name."""
119
+ found = set()
120
+ if isinstance(value, str) and value.startswith("previous_result:"):
121
+ found.add(value.split(":", 1)[1].split(".", 1)[0])
122
+ elif isinstance(value, list):
123
+ for item in value:
124
+ found |= _fed_by(item)
125
+ elif isinstance(value, dict):
126
+ for item in value.values():
127
+ found |= _fed_by(item)
128
+ return found
129
+
130
+
131
+ def _walk(value):
132
+ yield value
133
+ if isinstance(value, dict):
134
+ for item in value.values():
135
+ yield from _walk(item)
136
+ elif isinstance(value, list):
137
+ for item in value:
138
+ yield from _walk(item)
139
+
140
+
141
+ def _needs_input_media(steps):
142
+ for step in steps:
143
+ arguments = _arguments(step)
144
+ for name, value in arguments.items():
145
+ if name not in _MEDIA_ARGUMENTS:
146
+ continue
147
+ # A list argument (gather_images' `urls`) carries the same fact
148
+ # one level in.
149
+ candidates = value if isinstance(value, list) else [value]
150
+ if any(
151
+ isinstance(item, str) and item.startswith("variable:")
152
+ for item in candidates
153
+ ):
154
+ return True
155
+ for value in _walk(arguments):
156
+ if isinstance(value, str) and value.startswith("asset:"):
157
+ return True
158
+ if isinstance(value, dict) and "location" in value:
159
+ return True
160
+ return False
161
+
162
+
163
+ def _cuts_together(steps):
164
+ """A concat or dissolve fed by two or more distinct steps, or by a list
165
+ of shots handed in whole - one `variable:` reference is a supplied list
166
+ whose length only the caller knows, one `gather:` reference is every
167
+ member of a for_each group, and a cut over either is still an edit."""
168
+ for step in steps:
169
+ key, body = _block(step)
170
+ if key == "task" and body.get("command") in _CUT_TASKS:
171
+ videos = _arguments(step).get("videos")
172
+ if isinstance(videos, str) and videos.startswith(("variable:", "gather:")):
173
+ return True
174
+ sources = _fed_by(videos)
175
+ if isinstance(videos, list):
176
+ # Each supplied shot - a variable, an asset, a path - is its
177
+ # own source; two previous_result entries naming one step are not
178
+ sources |= {
179
+ item
180
+ for item in videos
181
+ if isinstance(item, str) and not item.startswith("previous_result:")
182
+ }
183
+ if len(sources) >= 2:
184
+ return True
185
+ return False
186
+
187
+
188
+ def _audio_components(step):
189
+ """Audio-only components the step's pipeline configures."""
190
+ key, body = _block(step)
191
+ if key != "pipeline":
192
+ return set()
193
+ configuration = body.get("configuration")
194
+ if not isinstance(configuration, dict) or not isinstance(
195
+ configuration.get("components"), dict
196
+ ):
197
+ return set()
198
+ return set(configuration["components"]) & _AUDIO_COMPONENTS
199
+
200
+
201
+ def _derive_shape(steps, kind):
202
+ # Cutting shots together makes a sequence even when every shot was
203
+ # supplied rather than generated - an edit is what comes out, and the
204
+ # utility fallthrough would otherwise hide the assembly templates.
205
+ if kind == "video" and _cuts_together(steps):
206
+ return "sequence"
207
+ if kind is None or not any(_generates(step) for step in steps):
208
+ return "utility"
209
+ if kind == "text":
210
+ return "text"
211
+ if kind == "audio":
212
+ return "audio"
213
+ if kind == "video":
214
+ return "shot"
215
+ image_steps = [
216
+ step for step in steps if _generates(step) and _kind(step) == "image"
217
+ ]
218
+ for step in image_steps:
219
+ if _EDIT_PIPELINE.search(_component_type(step)):
220
+ return "image-edit"
221
+ if {"image", "mask_image"} & set(_arguments(step)):
222
+ return "image-edit"
223
+ if len(image_steps) >= 2 or any(
224
+ _block(step)[0] == "workflow" for step in image_steps
225
+ ):
226
+ return "image-set"
227
+ return "image"
228
+
229
+
230
+ def _derive_traits(steps):
231
+ """The independent facts about how the output is made or what it needs.
232
+
233
+ `has-audio` says the workflow emits a generated audio track - a step
234
+ whose own result is a waveform, a speech task, a video pipeline asked
235
+ for audio, or one carrying a component that exists only to synthesise
236
+ one. Not specifically dialogue, and not a track the workflow was handed.
237
+ """
238
+ traits = set()
239
+ for step in steps:
240
+ key, body = _block(step)
241
+ arguments = _arguments(step)
242
+ if key == "task" and body.get("command") == "generate_speech":
243
+ traits.add("has-audio")
244
+ # A pipeline whose own result is a waveform generates one. The three
245
+ # checks below it all ask about a *video* step that also carries
246
+ # audio, which left Music 3's template - the one entry in the catalog
247
+ # whose whole output is a track - answering a `has-audio` filter with
248
+ # nothing.
249
+ if _generates(step) and _kind(step) == "audio":
250
+ traits.add("has-audio")
251
+ output = arguments.get("output")
252
+ if _kind(step) == "video" and isinstance(output, list) and "audio" in output:
253
+ traits.add("has-audio")
254
+ if _kind(step) == "video" and _audio_components(step):
255
+ traits.add("has-audio")
256
+ if key == "pipeline" and "chain" in body:
257
+ traits.add("chained")
258
+ if _CHAIN_ARGUMENTS & set(arguments):
259
+ traits.add("chained")
260
+ if _kind(step) == "video" and (
261
+ "image" in arguments or "ImageToVideo" in _component_type(step)
262
+ ):
263
+ traits.add("image-conditioned")
264
+ if "references" in arguments:
265
+ traits.add("identity-referenced")
266
+ if key == "workflow":
267
+ traits.add("composes-workflows")
268
+ if _needs_input_media(steps):
269
+ traits.add("needs-input-media")
270
+ return sorted(traits)
271
+
272
+
273
+ def _truncate(text):
274
+ """One line, cut at a word boundary and elided if it runs past the limit.
275
+
276
+ A declared summary goes through this too: `workflow_details` reads a file
277
+ without validating it, so the schema's maxLength never runs on that path
278
+ and a long declaration would otherwise reach a listing unclipped.
279
+ """
280
+ if len(text) <= SUMMARY_LIMIT:
281
+ return text, False
282
+ cut = text[: SUMMARY_LIMIT - 1]
283
+ cut = cut[: cut.rfind(" ")] if " " in cut else cut
284
+ return cut.rstrip() + "…", True
285
+
286
+
287
+ def _derive_summary(description):
288
+ text = str(description or "").strip()
289
+ if not text:
290
+ return "", False
291
+ return _truncate(_SENTENCE_END.split(text, maxsplit=1)[0].strip())
292
+
293
+
294
+ def derive_catalog_metadata(definition):
295
+ """shape, traits and summary for one definition, declarations honoured.
296
+
297
+ Returns {shape, traits, summary, summary_truncated, lists, declared};
298
+ `lists` names what an entry of each for_each-driven variable has to
299
+ carry (`list_fields`) plus its default's length, so an agent can write
300
+ the list argument without opening the definition; `declared` names
301
+ which of the three prose fields came from the file rather than the
302
+ rules, so a test can refuse a declaration that merely repeats the
303
+ derivation.
304
+ """
305
+ if not isinstance(definition, dict):
306
+ definition = {}
307
+ steps = _steps(definition)
308
+ kinds = {_kind(step) for step in steps} - {None}
309
+ kind = next((k for k in _KIND_PRECEDENCE if k in kinds), None)
310
+
311
+ declared = set()
312
+ shape = _derive_shape(steps, kind)
313
+ if definition.get("shape") in SHAPES:
314
+ shape = definition["shape"]
315
+ declared.add("shape")
316
+ traits = _derive_traits(steps)
317
+ if isinstance(definition.get("traits"), list):
318
+ traits = sorted(t for t in definition["traits"] if t in TRAITS)
319
+ declared.add("traits")
320
+ summary, truncated = _derive_summary(definition.get("description"))
321
+ if isinstance(definition.get("summary"), str) and definition["summary"].strip():
322
+ summary, truncated = _truncate(definition["summary"].strip())
323
+ declared.add("summary")
324
+ variables = definition.get("variables")
325
+ variables = variables if isinstance(variables, dict) else {}
326
+ # A bound an entry field carries is reported beside that field, not
327
+ # only in the top-level `constraints` block: a caller reading what a
328
+ # `shots` entry takes reads the rule for `num_frames` there (#145)
329
+ rules = declared_constraints(definition)
330
+ entry_rules = entry_constraint_fields(definition)
331
+ lists = {
332
+ name: {
333
+ **fields,
334
+ "entries": (
335
+ len(variables[name]) if isinstance(variables.get(name), list) else None
336
+ ),
337
+ **(
338
+ {
339
+ "constraints": {
340
+ field: terse_constraint(rules[field])
341
+ for field in entry_rules[name]
342
+ }
343
+ }
344
+ if name in entry_rules
345
+ else {}
346
+ ),
347
+ }
348
+ for name, fields in list_fields(definition).items()
349
+ }
350
+ return {
351
+ "shape": shape,
352
+ "traits": traits,
353
+ "summary": summary,
354
+ "summary_truncated": truncated,
355
+ "lists": lists,
356
+ "declared": declared,
357
+ }
358
+
359
+
360
+ COMPACT_FIELDS = (
361
+ "summary",
362
+ "shape",
363
+ "traits",
364
+ "cost",
365
+ "kinds",
366
+ "variable_names",
367
+ "lists",
368
+ "constraints",
369
+ "configures",
370
+ )
371
+
372
+ # Carried in the compact view only when set: a template has no
373
+ # `configures`, most workflows have no list-driven step, and most declare
374
+ # no bound on a variable
375
+ _COMPACT_WHEN_SET = frozenset({"configures", "lists", "constraints"})
376
+
377
+
378
+ def terse_constraint(rule):
379
+ """One variable's rule as a phrase, for the compact listing.
380
+
381
+ The block itself carries a `reason` in the author's words, which is what
382
+ a consumer reading one workflow wants and what the whole catalog cannot
383
+ afford - the compact listing has a token budget (#101), and the numbers
384
+ are the part that stops the next consumer picking 61 (#96).
385
+ """
386
+ if not isinstance(rule, dict):
387
+ return rule
388
+ parts = []
389
+ if rule.get("modulus"):
390
+ parts.append(f"{rule['modulus']}*n+{rule.get('remainder', 0)}")
391
+ low, high = rule.get("min_frames"), rule.get("max_frames")
392
+ if low is not None and high is not None:
393
+ parts.append(f"{low}-{high}")
394
+ elif low is not None:
395
+ parts.append(f"{low}+")
396
+ elif high is not None:
397
+ parts.append(f"up to {high}")
398
+ if rule.get("snap") == "up":
399
+ parts.append("rounds up")
400
+ return ", ".join(parts)
401
+
402
+
403
+ def project_listing(
404
+ details,
405
+ *,
406
+ shape=None,
407
+ traits=None,
408
+ configures=None,
409
+ include_models=False,
410
+ view=None,
411
+ ):
412
+ """The listing an agent asked for: filtered by shape and traits, and in
413
+ the compact view stripped to what choosing a template needs.
414
+
415
+ Compact is templates-only unless `include_models` or `configures` says
416
+ otherwise - nine checkpoint variants of text-to-image are the noise the
417
+ two-tree split removed. A workflow with no `configures` is a template
418
+ for this purpose, whichever directory it sits in: a user wrote it to be
419
+ found. The full view never drops entries or fields.
420
+ """
421
+ if shape is not None and shape not in SHAPES:
422
+ raise ValueError(
423
+ f"Unknown shape {shape!r}. The shapes are: {', '.join(SHAPES)}."
424
+ )
425
+ traits = list(traits or [])
426
+ unknown = [t for t in traits if t not in TRAITS]
427
+ if unknown:
428
+ raise ValueError(
429
+ f"Unknown trait(s) {', '.join(unknown)}. The traits are: {', '.join(TRAITS)}."
430
+ )
431
+ if view not in (None, "compact"):
432
+ raise ValueError("view must be 'compact' or omitted")
433
+
434
+ compact = view == "compact"
435
+ keep_models = include_models or configures is not None or not compact
436
+ projected = {}
437
+ for name, detail in details.items():
438
+ is_model = bool(detail.get("configures") or detail.get("configures_missing"))
439
+ if shape is not None and detail.get("shape") != shape:
440
+ continue
441
+ if traits and not set(traits) <= set(detail.get("traits", [])):
442
+ continue
443
+ if configures is not None and detail.get("configures") != configures:
444
+ continue
445
+ if is_model and not keep_models:
446
+ continue
447
+ if compact:
448
+ slim = {
449
+ key: detail.get(key)
450
+ for key in COMPACT_FIELDS
451
+ if key not in _COMPACT_WHEN_SET or detail.get(key)
452
+ }
453
+ if slim.get("constraints"):
454
+ # A rule that reaches only a list-entry field is reported
455
+ # beside that field under `lists`, so repeating it here
456
+ # would state it twice in one answer - and next to
457
+ # `variable_names`, which does not carry the name (#145).
458
+ # The full listing and `get_workflow` keep the block as the
459
+ # author wrote it
460
+ in_lists = {
461
+ field
462
+ for entry in (slim.get("lists") or {}).values()
463
+ for field in (entry.get("constraints") or {})
464
+ }
465
+ declared = set(detail.get("variable_names") or ())
466
+ slim["constraints"] = {
467
+ variable: terse_constraint(rule)
468
+ for variable, rule in slim["constraints"].items()
469
+ if variable in declared or variable not in in_lists
470
+ }
471
+ if not slim["constraints"]:
472
+ del slim["constraints"]
473
+ # This box's own history, at its two-key budget (#93/#101): the
474
+ # cold median, which is the one comparable to a curated `cost`,
475
+ # and how many runs stand behind it. The block with the
476
+ # cold/warm split, the drivers and `since` is in the full
477
+ # listing and in `get_workflow`
478
+ observed = detail.get("observed") or {}
479
+ if observed.get("cold_minutes") is not None:
480
+ slim["observed_minutes"] = observed["cold_minutes"]
481
+ slim["observed_runs"] = observed["cold_runs"]
482
+ if detail.get("configures_missing"):
483
+ slim["configures_missing"] = detail["configures_missing"]
484
+ projected[name] = slim
485
+ else:
486
+ projected[name] = detail
487
+ return projected