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.
- diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
- diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
- diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
- diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
- diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
- diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
- dw/__init__.py +440 -0
- dw/adapter_compatibility.py +226 -0
- dw/arguments.py +1231 -0
- dw/assessment_rules.py +159 -0
- dw/assets.py +130 -0
- dw/cache_blocks.json +16 -0
- dw/cache_blocks.py +146 -0
- dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
- dw/content_types.py +150 -0
- dw/dissolve_frame_errors.py +121 -0
- dw/docs/ACCELERATION.md +352 -0
- dw/docs/AGENT_LOOP.md +95 -0
- dw/docs/DEPENDENCIES.md +91 -0
- dw/docs/IP_ADAPTER.md +109 -0
- dw/docs/LORAS.md +131 -0
- dw/docs/MCP.md +517 -0
- dw/docs/PROMPT_WEIGHTING.md +78 -0
- dw/docs/QUANTIZATION.md +230 -0
- dw/docs/RECIPES_24GB.md +201 -0
- dw/docs/RELEASING.md +195 -0
- dw/docs/REMOTE.md +140 -0
- dw/docs/REPL_COMMANDS.md +121 -0
- dw/docs/REPL_WORKER_GUIDE.md +51 -0
- dw/docs/SECURITY.md +272 -0
- dw/docs/SECURITY_QUICKREF.md +112 -0
- dw/docs/SERVER.md +679 -0
- dw/docs/TASKS.md +1741 -0
- dw/docs/TESTING.md +71 -0
- dw/docs/WORKFLOW_GUIDE.md +2038 -0
- dw/docs/WORKSPACES.md +316 -0
- dw/download_watch.py +335 -0
- dw/elision.py +306 -0
- dw/events.py +275 -0
- dw/for_each.py +409 -0
- dw/host_memory.py +258 -0
- dw/host_memory_projection.py +230 -0
- dw/hub_cache.py +432 -0
- dw/introspection.py +1228 -0
- dw/kernel_availability.py +208 -0
- dw/locations.py +599 -0
- dw/log_setup.py +45 -0
- dw/loudness.py +82 -0
- dw/media_audio.py +217 -0
- dw/media_frames.py +367 -0
- dw/media_info.py +297 -0
- dw/pipeline_processors/chain.py +821 -0
- dw/pipeline_processors/config_objects.py +237 -0
- dw/pipeline_processors/pipeline.py +2297 -0
- dw/pipeline_processors/remote.py +46 -0
- dw/plan.py +920 -0
- dw/previous_results.py +411 -0
- dw/probe_paths.py +59 -0
- dw/prompt_schema.json +48 -0
- dw/prompt_weighting.py +378 -0
- dw/prompts.py +159 -0
- dw/realize.py +250 -0
- dw/reference_limits.py +215 -0
- dw/reference_names.py +125 -0
- dw/repl.py +338 -0
- dw/repl_commands.py +836 -0
- dw/repl_worker.py +159 -0
- dw/result.py +1720 -0
- dw/result_fps.py +82 -0
- dw/run.py +162 -0
- dw/runs.py +768 -0
- dw/scalar_result_validation.py +97 -0
- dw/schema.py +283 -0
- dw/security.py +1038 -0
- dw/select_validation.py +115 -0
- dw/serve.py +277 -0
- dw/server/__init__.py +2 -0
- dw/server/app.py +4586 -0
- dw/server/assess.py +132 -0
- dw/server/catalog_shape.py +487 -0
- dw/server/enhancers.py +129 -0
- dw/server/exports.py +480 -0
- dw/server/guides.py +257 -0
- dw/server/jobs.py +1561 -0
- dw/server/mcp_mount.py +95 -0
- dw/server/netinfo.py +124 -0
- dw/server/observed_cost.py +379 -0
- dw/server/sysinfo.py +71 -0
- dw/server/ui/assets/abap-08VXUWAP.js +1 -0
- dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
- dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
- dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
- dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
- dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
- dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
- dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
- dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
- dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
- dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
- dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
- dw/server/ui/assets/css-DIMkf-bt.js +3 -0
- dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
- dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
- dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
- dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
- dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
- dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
- dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
- dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
- dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
- dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
- dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
- dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
- dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
- dw/server/ui/assets/go-C-y9NEjX.js +1 -0
- dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
- dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
- dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
- dw/server/ui/assets/html-PhsdjHSr.js +1 -0
- dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
- dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
- dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
- dw/server/ui/assets/index-DgrYhQd9.js +43 -0
- dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
- dw/server/ui/assets/java-BEtHBSE6.js +1 -0
- dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
- dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
- dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
- dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
- dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
- dw/server/ui/assets/less-B9JPFI3C.js +2 -0
- dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
- dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
- dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
- dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
- dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
- dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
- dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
- dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
- dw/server/ui/assets/monaco--ixms01u.css +1 -0
- dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
- dw/server/ui/assets/msdax-DauUninz.js +1 -0
- dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
- dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
- dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
- dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
- dw/server/ui/assets/perl-oz_6vUea.js +1 -0
- dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
- dw/server/ui/assets/php-nr791fC2.js +1 -0
- dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
- dw/server/ui/assets/postiats-43DmfD33.js +1 -0
- dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
- dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
- dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
- dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
- dw/server/ui/assets/python-Bcn70HdC.js +1 -0
- dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
- dw/server/ui/assets/r-BwWrilGY.js +1 -0
- dw/server/ui/assets/razor-D1HmNnby.js +1 -0
- dw/server/ui/assets/redis-ClamHrr6.js +1 -0
- dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
- dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
- dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
- dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
- dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
- dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
- dw/server/ui/assets/scheme-BeGwcela.js +1 -0
- dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
- dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
- dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
- dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
- dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
- dw/server/ui/assets/sql-NEE52Syq.js +1 -0
- dw/server/ui/assets/st-DbInun42.js +1 -0
- dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
- dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
- dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
- dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
- dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
- dw/server/ui/assets/twig-De2hgUGE.js +1 -0
- dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
- dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
- dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
- dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
- dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
- dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
- dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
- dw/server/ui/index.html +17 -0
- dw/server/updater.py +192 -0
- dw/settings.py +98 -0
- dw/shot_span_preflight.py +116 -0
- dw/shots.py +359 -0
- dw/slice_preflight.py +148 -0
- dw/step.py +187 -0
- dw/step_cache.py +442 -0
- dw/subfolders.py +107 -0
- dw/task_domains.py +307 -0
- dw/tasks/assess.py +826 -0
- dw/tasks/audio_transcription.py +88 -0
- dw/tasks/audio_utils.py +1862 -0
- dw/tasks/background_remover.py +43 -0
- dw/tasks/borders.py +113 -0
- dw/tasks/compose_text.py +74 -0
- dw/tasks/concat_videos.py +300 -0
- dw/tasks/depth_estimator.py +54 -0
- dw/tasks/diffusion_upscale.py +109 -0
- dw/tasks/dissolve_videos.py +342 -0
- dw/tasks/format_messages.py +24 -0
- dw/tasks/gather.py +173 -0
- dw/tasks/grade.py +97 -0
- dw/tasks/image_to_text.py +43 -0
- dw/tasks/image_utils.py +764 -0
- dw/tasks/interpolate_frames.py +252 -0
- dw/tasks/judge.py +68 -0
- dw/tasks/model_cache.py +55 -0
- dw/tasks/pair_audio.py +268 -0
- dw/tasks/qr_code.py +19 -0
- dw/tasks/restore_faces.py +175 -0
- dw/tasks/rife_model.py +192 -0
- dw/tasks/segment.py +121 -0
- dw/tasks/select.py +111 -0
- dw/tasks/speech_generation.py +228 -0
- dw/tasks/stabilize.py +129 -0
- dw/tasks/task.py +920 -0
- dw/tasks/tensor_image.py +57 -0
- dw/tasks/text_generation.py +169 -0
- dw/tasks/text_sections.py +80 -0
- dw/tasks/upscale.py +203 -0
- dw/tasks/video_utils.py +624 -0
- dw/tasks/zoe_depth.py +71 -0
- dw/teacache.py +381 -0
- dw/teacache_models.json +99 -0
- dw/test.py +29 -0
- dw/type_helpers.py +231 -0
- dw/validate.py +68 -0
- dw/variable_constraints.py +444 -0
- dw/variables.py +443 -0
- dw/video_extensions.py +141 -0
- dw/vram_estimate.py +116 -0
- dw/worker.py +764 -0
- dw/workflow.py +2007 -0
- dw/workflow_schema.json +1346 -0
- dw/workflow_sources.py +383 -0
- dw/workflows/h3_context_ir.json +57 -0
- dw/workflows/test.json +31 -0
- dw/workspace.py +730 -0
- dw_mcp/__init__.py +6 -0
- dw_mcp/__main__.py +133 -0
- dw_mcp/assets.py +336 -0
- dw_mcp/authoring.py +114 -0
- dw_mcp/catalog.py +360 -0
- dw_mcp/client.py +486 -0
- dw_mcp/diagnose.py +371 -0
- dw_mcp/exports.py +84 -0
- dw_mcp/guides.py +35 -0
- dw_mcp/media.py +638 -0
- dw_mcp/models.py +97 -0
- dw_mcp/prompts.py +104 -0
- dw_mcp/server.py +1343 -0
- dw_mcp/workspaces.py +212 -0
dw/server/enhancers.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Prompt enhancement presets and the inline workflows that run them.
|
|
2
|
+
|
|
3
|
+
The enhancer is not a new execution path - each preset builds a one-step
|
|
4
|
+
inline workflow that the ordinary job queue runs, so progress streaming,
|
|
5
|
+
cancellation, history and the worker's model cache all apply unchanged.
|
|
6
|
+
The step saves its text result, which is how the enhanced prompt comes
|
|
7
|
+
back: as the single file in the job's manifest.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
# A generic system prompt for expanding an idea into an image-generation
|
|
13
|
+
# prompt - the counterpart of the H3 preset's Context-IR spec, which lives
|
|
14
|
+
# in the builtin workflow rather than here
|
|
15
|
+
_T2I_SYSTEM_PROMPT = (
|
|
16
|
+
"You expand a user's idea into a single detailed text-to-image prompt. "
|
|
17
|
+
"Describe the subject, setting, lighting, mood, composition and style "
|
|
18
|
+
"concretely, in one flowing paragraph of comma-separated phrases. "
|
|
19
|
+
"Reply with the prompt text only - no preamble, no quotes, no headings."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Each preset names what the UI shows (label, placeholder, curated models)
|
|
23
|
+
# and what the builder needs (which workflow or task runs the enhancement).
|
|
24
|
+
# 'intended_models' are the stored-prompt intended_model values the preset
|
|
25
|
+
# is preselected for
|
|
26
|
+
PRESETS = {
|
|
27
|
+
"h3": {
|
|
28
|
+
"label": "MiniMax-H3 Context-IR",
|
|
29
|
+
"workflow": "builtin:h3_context_ir.json",
|
|
30
|
+
"default_model": "Qwen/Qwen3-4B-Instruct-2507",
|
|
31
|
+
"models": [
|
|
32
|
+
"Qwen/Qwen3-4B-Instruct-2507",
|
|
33
|
+
"Qwen/Qwen2.5-7B-Instruct",
|
|
34
|
+
"Qwen/Qwen2.5-1.5B-Instruct",
|
|
35
|
+
],
|
|
36
|
+
"intended_models": ["minimax-h3"],
|
|
37
|
+
"placeholder": (
|
|
38
|
+
"Task: T2VA. Duration: 5.17 seconds. "
|
|
39
|
+
"Idea: a red fox trotting through a snowy pine forest at dawn."
|
|
40
|
+
),
|
|
41
|
+
},
|
|
42
|
+
"t2i": {
|
|
43
|
+
"label": "Text-to-image augmenter",
|
|
44
|
+
"task": "text_generation",
|
|
45
|
+
"system_prompt": _T2I_SYSTEM_PROMPT,
|
|
46
|
+
"default_model": "Qwen/Qwen2.5-1.5B-Instruct",
|
|
47
|
+
"models": [
|
|
48
|
+
"Qwen/Qwen2.5-1.5B-Instruct",
|
|
49
|
+
"Qwen/Qwen3-4B-Instruct-2507",
|
|
50
|
+
],
|
|
51
|
+
"intended_models": ["z-image", "flux"],
|
|
52
|
+
"placeholder": "a cat portrait in a sunlit window",
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def preset_descriptions():
|
|
58
|
+
"""The presets as the UI consumes them - everything but how they run."""
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
"key": key,
|
|
62
|
+
"label": preset["label"],
|
|
63
|
+
"default_model": preset["default_model"],
|
|
64
|
+
"models": preset["models"],
|
|
65
|
+
"intended_models": preset["intended_models"],
|
|
66
|
+
"placeholder": preset["placeholder"],
|
|
67
|
+
}
|
|
68
|
+
for key, preset in PRESETS.items()
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_enhance_workflow(preset_key, idea, model_name=None, device=None):
|
|
73
|
+
"""An inline workflow that enhances one idea with one preset.
|
|
74
|
+
|
|
75
|
+
The workflow id is unique per call: output file names derive from the id,
|
|
76
|
+
so two enhancements never overwrite each other's text in the output
|
|
77
|
+
directory.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
preset_key: Key into PRESETS
|
|
81
|
+
idea: The idea text to expand
|
|
82
|
+
model_name: LLM repo id; the preset's default when omitted
|
|
83
|
+
device: Device override for the language model; the workflow or
|
|
84
|
+
task default (cpu) when omitted
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The inline workflow definition as a dict
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ValueError: If the preset is unknown or the idea is empty
|
|
91
|
+
"""
|
|
92
|
+
preset = PRESETS.get(preset_key)
|
|
93
|
+
if preset is None:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"Unknown enhancer preset '{preset_key}' - one of {sorted(PRESETS)}"
|
|
96
|
+
)
|
|
97
|
+
if not idea or not idea.strip():
|
|
98
|
+
raise ValueError("Provide an idea to enhance")
|
|
99
|
+
|
|
100
|
+
model = model_name or preset["default_model"]
|
|
101
|
+
|
|
102
|
+
if "workflow" in preset:
|
|
103
|
+
arguments = {"prompt": idea, "model_name": model}
|
|
104
|
+
if device:
|
|
105
|
+
arguments["device"] = device
|
|
106
|
+
action = {"workflow": {"path": preset["workflow"], "arguments": arguments}}
|
|
107
|
+
else:
|
|
108
|
+
arguments = {
|
|
109
|
+
"prompt": idea,
|
|
110
|
+
"model_name": model,
|
|
111
|
+
"system_prompt": preset["system_prompt"],
|
|
112
|
+
"max_new_tokens": 400,
|
|
113
|
+
}
|
|
114
|
+
if device:
|
|
115
|
+
arguments["device"] = device
|
|
116
|
+
action = {"task": {"command": preset["task"], "arguments": arguments}}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"id": f"enhance_{uuid.uuid4().hex[:8]}",
|
|
120
|
+
"steps": [
|
|
121
|
+
{
|
|
122
|
+
"name": "enhance",
|
|
123
|
+
**action,
|
|
124
|
+
# The save is the return channel: the builtin's own steps stay
|
|
125
|
+
# save:false, so the manifest holds exactly this text file
|
|
126
|
+
"result": {"content_type": "text/plain", "save": True},
|
|
127
|
+
}
|
|
128
|
+
],
|
|
129
|
+
}
|
dw/server/exports.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Gathering one finished job into a directory that stands on its own.
|
|
2
|
+
|
|
3
|
+
The record of a run is split across four places on one machine: the job row,
|
|
4
|
+
the run's manifest, the workflow that ran, and the media on either side of it.
|
|
5
|
+
This module puts them in one tree - text at the top, media in folders, no
|
|
6
|
+
absolute paths inside the files - so the run can be committed, moved or handed
|
|
7
|
+
to someone else.
|
|
8
|
+
|
|
9
|
+
exports/<job id>/
|
|
10
|
+
README.md what this is, how it was made, how to run it again
|
|
11
|
+
workflow.json the realized workflow, when the run wrote one
|
|
12
|
+
manifest.json the run's manifest, or a synthesized stand-in
|
|
13
|
+
job.json the job row: status, times, arguments, warnings, error
|
|
14
|
+
assets/ every asset: the workflow names, under its own name
|
|
15
|
+
inputs/ every file an output: reference named, by run
|
|
16
|
+
outputs/ every file the manifest lists
|
|
17
|
+
|
|
18
|
+
Copies are copies, never hard links: exports/ is what a user moves or deletes,
|
|
19
|
+
and a hard link would make deleting it look like deleting the original.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
|
|
29
|
+
from ..assets import ASSET_PREFIX
|
|
30
|
+
from ..realize import strings_with_prefix
|
|
31
|
+
from ..runs import (
|
|
32
|
+
MANIFEST_FILE_NAME,
|
|
33
|
+
OUTPUT_PREFIX,
|
|
34
|
+
is_output_reference,
|
|
35
|
+
resolve_output_reference,
|
|
36
|
+
)
|
|
37
|
+
from ..security import (
|
|
38
|
+
PathTraversalError,
|
|
39
|
+
SecurityError,
|
|
40
|
+
validate_asset_reference,
|
|
41
|
+
validate_output_path,
|
|
42
|
+
validate_path,
|
|
43
|
+
)
|
|
44
|
+
from ..workspace import EXPORTS_SUBDIR
|
|
45
|
+
from .jobs import TERMINAL_STATES
|
|
46
|
+
|
|
47
|
+
# A job id is one path segment of the manager's making - hex today, but any
|
|
48
|
+
# name without a separator or a leading dot is accepted so history stays
|
|
49
|
+
# readable if the shape ever changes
|
|
50
|
+
JOB_ID_SEGMENT = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*")
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger("dw")
|
|
53
|
+
|
|
54
|
+
WORKFLOW_FILE_NAME = "workflow.json"
|
|
55
|
+
JOB_FILE_NAME = "job.json"
|
|
56
|
+
README_FILE_NAME = "README.md"
|
|
57
|
+
|
|
58
|
+
# What job.json holds, in this order, whatever the job was read from. A live
|
|
59
|
+
# job's detail and a historical one's differ in shape - history carries the
|
|
60
|
+
# submitted spec and a 'historical' flag, a live job a traceback and an event
|
|
61
|
+
# count - and an export is a record, so it takes one fixed key set: a
|
|
62
|
+
# traceback is a developer's artifact of one process, an event count
|
|
63
|
+
# describes a log this tree does not hold, and the spec is what
|
|
64
|
+
# workflow.json already is. 'realized' is added beside them
|
|
65
|
+
JOB_RECORD_KEYS = (
|
|
66
|
+
"id",
|
|
67
|
+
"workflow",
|
|
68
|
+
"workflow_name",
|
|
69
|
+
"status",
|
|
70
|
+
"created_at",
|
|
71
|
+
"started_at",
|
|
72
|
+
"finished_at",
|
|
73
|
+
"workspace",
|
|
74
|
+
"arguments",
|
|
75
|
+
"warnings",
|
|
76
|
+
"manifest",
|
|
77
|
+
"error",
|
|
78
|
+
"run_id",
|
|
79
|
+
"run_dir",
|
|
80
|
+
"run_version",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
README_TEMPLATE = """# {workflow_name} - job {job_id}
|
|
84
|
+
|
|
85
|
+
{realized_sentence}
|
|
86
|
+
|
|
87
|
+
## The run
|
|
88
|
+
|
|
89
|
+
| | |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| Job | `{job_id}` |
|
|
92
|
+
| Workflow | `{workflow_name}` |
|
|
93
|
+
| Catalog entry | {catalog_name} |
|
|
94
|
+
| Run | {run} |
|
|
95
|
+
| Status | {status} |
|
|
96
|
+
| Started | {started_at} |
|
|
97
|
+
| Finished | {finished_at} |
|
|
98
|
+
| Device | {device} |
|
|
99
|
+
| Engine | dw {dw_version} |
|
|
100
|
+
| Seed | `{seed}` |
|
|
101
|
+
|
|
102
|
+
Arguments as submitted:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{arguments}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Stored prompts
|
|
109
|
+
|
|
110
|
+
{prompts}
|
|
111
|
+
|
|
112
|
+
## Sub-workflows
|
|
113
|
+
|
|
114
|
+
{sub_workflows}
|
|
115
|
+
|
|
116
|
+
## Inputs
|
|
117
|
+
|
|
118
|
+
{inputs}
|
|
119
|
+
|
|
120
|
+
## Running it again
|
|
121
|
+
|
|
122
|
+
From a checkout of diffusers-workflow, with this directory as the working
|
|
123
|
+
directory:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
python -m dw.run workflow.json
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Over MCP, pass the contents of `workflow.json` to `run_workflow` as
|
|
130
|
+
`inline_workflow`. Either way the `asset:` and `output:` names in it resolve
|
|
131
|
+
against the server's libraries, not against this directory - the copies under
|
|
132
|
+
`assets/` and `inputs/` are the record of what those names meant, not a
|
|
133
|
+
substitute for them.
|
|
134
|
+
|
|
135
|
+
## Missing
|
|
136
|
+
|
|
137
|
+
{missing}
|
|
138
|
+
|
|
139
|
+
## A note on committing this
|
|
140
|
+
|
|
141
|
+
`assets/`, `inputs/` and `outputs/` hold generated and source media, which is
|
|
142
|
+
usually large and always binary. If this tree goes into Git, put those three
|
|
143
|
+
directories in Git LFS; the four files at the top are text and belong in Git
|
|
144
|
+
proper.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class ExportSummary:
|
|
150
|
+
"""What an export produced, computed from what actually landed."""
|
|
151
|
+
|
|
152
|
+
job_id: str
|
|
153
|
+
directory: str
|
|
154
|
+
files: list = field(default_factory=list)
|
|
155
|
+
total_bytes: int = 0
|
|
156
|
+
missing: list = field(default_factory=list)
|
|
157
|
+
|
|
158
|
+
def as_dict(self):
|
|
159
|
+
return {
|
|
160
|
+
"job_id": self.job_id,
|
|
161
|
+
"directory": self.directory,
|
|
162
|
+
"files": self.files,
|
|
163
|
+
"total_bytes": self.total_bytes,
|
|
164
|
+
"missing": self.missing,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def export_directory(workspace_root, job_id):
|
|
169
|
+
"""Where one job's export lives, confined to the workspace root.
|
|
170
|
+
|
|
171
|
+
The job id is caller input - it arrives in a URL - so it is joined and
|
|
172
|
+
then validated rather than trusted to be the hex string the manager
|
|
173
|
+
generates.
|
|
174
|
+
"""
|
|
175
|
+
if not workspace_root:
|
|
176
|
+
raise ValueError("This server has no workspace root to export into")
|
|
177
|
+
# One plain segment, before the join: validate_path accepts a path equal
|
|
178
|
+
# to its root, so '.' or '' would otherwise name the exports root itself
|
|
179
|
+
# and the zip route would archive every export in the workspace
|
|
180
|
+
if not JOB_ID_SEGMENT.fullmatch(job_id or ""):
|
|
181
|
+
raise PathTraversalError(f"Not a job id: {job_id!r}")
|
|
182
|
+
root = validate_output_path(os.path.join(workspace_root, EXPORTS_SUBDIR), None)
|
|
183
|
+
return validate_path(os.path.join(root, job_id), root)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def export_job(manager, job_id, workspace_root, asset_roots, overwrite=False):
|
|
187
|
+
"""Gather one finished job into '<workspace_root>/exports/<job id>/'.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
manager: The JobManager holding the job (live or historical).
|
|
191
|
+
job_id: The job to export.
|
|
192
|
+
workspace_root: The workspace the export lands in.
|
|
193
|
+
asset_roots: The workspace's asset search path, in order - the same
|
|
194
|
+
order 'asset:' resolves in, so what is copied is what the run
|
|
195
|
+
loaded.
|
|
196
|
+
overwrite: Replace an existing export rather than refusing.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
An ExportSummary.
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
ValueError: Unknown job, or a job that has not finished.
|
|
203
|
+
FileExistsError: The export exists and overwrite is False.
|
|
204
|
+
"""
|
|
205
|
+
job = manager.get(job_id)
|
|
206
|
+
if job is None:
|
|
207
|
+
raise ValueError(f"Unknown job {job_id}")
|
|
208
|
+
detail = job if isinstance(job, dict) else manager.describe(job)
|
|
209
|
+
status = detail.get("status")
|
|
210
|
+
if status not in TERMINAL_STATES:
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f"Job {job_id} is {status} - only a finished job can be exported"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
spec = job.get("spec", {}) if isinstance(job, dict) else job.spec
|
|
216
|
+
run_dir = job.get("run_dir") if isinstance(job, dict) else job.run_dir
|
|
217
|
+
output_root = validate_output_path(
|
|
218
|
+
spec.get("output_dir") or manager.output_dir, None
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
target = export_directory(workspace_root, job_id)
|
|
222
|
+
if os.path.exists(target):
|
|
223
|
+
if not overwrite:
|
|
224
|
+
raise FileExistsError(
|
|
225
|
+
f"An export of job {job_id} already exists - pass overwrite to "
|
|
226
|
+
f"replace it"
|
|
227
|
+
)
|
|
228
|
+
shutil.rmtree(target)
|
|
229
|
+
os.makedirs(target)
|
|
230
|
+
|
|
231
|
+
summary = ExportSummary(job_id=job_id, directory=target)
|
|
232
|
+
|
|
233
|
+
realized = manager.realized(job_id)
|
|
234
|
+
workflow = realized if realized is not None else (manager.definition(job_id) or {})
|
|
235
|
+
_write_json(summary, target, WORKFLOW_FILE_NAME, workflow)
|
|
236
|
+
|
|
237
|
+
manifest = _run_manifest(output_root, run_dir)
|
|
238
|
+
if manifest is None:
|
|
239
|
+
# No run directory, or it no longer holds a manifest: the job row's
|
|
240
|
+
# own per-step file list is what is left, and it says so
|
|
241
|
+
manifest = {
|
|
242
|
+
"synthesized": True,
|
|
243
|
+
"run_id": None,
|
|
244
|
+
"status": status,
|
|
245
|
+
"steps": detail.get("manifest") or [],
|
|
246
|
+
}
|
|
247
|
+
_write_json(summary, target, MANIFEST_FILE_NAME, manifest)
|
|
248
|
+
|
|
249
|
+
record = {key: detail.get(key) for key in JOB_RECORD_KEYS}
|
|
250
|
+
record["realized"] = realized is not None
|
|
251
|
+
_write_json(summary, target, JOB_FILE_NAME, record)
|
|
252
|
+
|
|
253
|
+
_copy_assets(summary, workflow, target, asset_roots)
|
|
254
|
+
_copy_inputs(summary, workflow, target, output_root)
|
|
255
|
+
_copy_outputs(summary, manifest, target, output_root, run_dir)
|
|
256
|
+
|
|
257
|
+
readme = _readme(job_id, detail, manifest, workflow, summary, realized is not None)
|
|
258
|
+
_write_text(summary, target, README_FILE_NAME, readme)
|
|
259
|
+
return summary
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# -------------------------------------------------------------- the pieces
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _run_manifest(output_root, run_dir):
|
|
266
|
+
"""The manifest the run left, or None when there is no reading it."""
|
|
267
|
+
if not run_dir:
|
|
268
|
+
return None
|
|
269
|
+
try:
|
|
270
|
+
path = validate_path(
|
|
271
|
+
os.path.join(output_root, run_dir, MANIFEST_FILE_NAME), output_root
|
|
272
|
+
)
|
|
273
|
+
with open(path, "r") as file:
|
|
274
|
+
return json.load(file)
|
|
275
|
+
except (SecurityError, OSError, ValueError) as e:
|
|
276
|
+
logger.debug(f"No run manifest to export from {run_dir}: {e}")
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _copy_assets(summary, workflow, target, asset_roots):
|
|
281
|
+
"""Every 'asset:' the workflow names, under its own name in assets/."""
|
|
282
|
+
for reference in strings_with_prefix(workflow, ASSET_PREFIX):
|
|
283
|
+
try:
|
|
284
|
+
name = validate_asset_reference(
|
|
285
|
+
reference.removeprefix(ASSET_PREFIX).strip()
|
|
286
|
+
)
|
|
287
|
+
except SecurityError:
|
|
288
|
+
summary.missing.append(reference)
|
|
289
|
+
continue
|
|
290
|
+
source = None
|
|
291
|
+
for root in asset_roots:
|
|
292
|
+
try:
|
|
293
|
+
candidate = validate_path(os.path.join(root, name), root)
|
|
294
|
+
except SecurityError:
|
|
295
|
+
continue
|
|
296
|
+
if os.path.isfile(candidate):
|
|
297
|
+
source = candidate
|
|
298
|
+
break
|
|
299
|
+
if source is None:
|
|
300
|
+
summary.missing.append(reference)
|
|
301
|
+
continue
|
|
302
|
+
_copy(
|
|
303
|
+
summary, source, target, os.path.join("assets", *name.split("/")), reference
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _copy_inputs(summary, workflow, target, output_root):
|
|
308
|
+
"""Every 'output:' the workflow names, kept under the run it came from.
|
|
309
|
+
|
|
310
|
+
The reference itself is not rewritten - the realized workflow is the
|
|
311
|
+
immutable record of the run - so the directory name is the reference's
|
|
312
|
+
own name, and the README says where each one came from.
|
|
313
|
+
"""
|
|
314
|
+
for reference in strings_with_prefix(workflow, OUTPUT_PREFIX):
|
|
315
|
+
if not is_output_reference(reference):
|
|
316
|
+
continue
|
|
317
|
+
name = reference.removeprefix(OUTPUT_PREFIX).strip()
|
|
318
|
+
try:
|
|
319
|
+
source = resolve_output_reference(reference, output_root)
|
|
320
|
+
except (SecurityError, OSError, ValueError):
|
|
321
|
+
summary.missing.append(reference)
|
|
322
|
+
continue
|
|
323
|
+
_copy(
|
|
324
|
+
summary, source, target, os.path.join("inputs", *name.split("/")), reference
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _copy_outputs(summary, manifest, target, output_root, run_dir):
|
|
329
|
+
"""Every file the manifest lists, under outputs/.
|
|
330
|
+
|
|
331
|
+
A manifest entry names a file relative to the run directory when the run
|
|
332
|
+
wrote it, and absolutely when a step-cache hit republished an earlier
|
|
333
|
+
run's file. Both land here: the first under its own relative name, the
|
|
334
|
+
second under its path relative to the output root, which keeps the
|
|
335
|
+
identity and run id that say where it really came from.
|
|
336
|
+
"""
|
|
337
|
+
run_root = os.path.join(output_root, run_dir) if run_dir else output_root
|
|
338
|
+
# One file can be listed by two steps (a chain's output is the next
|
|
339
|
+
# step's input); it is one file in the export, copied and counted once
|
|
340
|
+
copied = set()
|
|
341
|
+
for entry in manifest.get("steps") or []:
|
|
342
|
+
if not isinstance(entry, dict):
|
|
343
|
+
continue
|
|
344
|
+
for recorded in entry.get("files") or []:
|
|
345
|
+
source = (
|
|
346
|
+
recorded
|
|
347
|
+
if os.path.isabs(recorded)
|
|
348
|
+
else os.path.join(run_root, recorded)
|
|
349
|
+
)
|
|
350
|
+
try:
|
|
351
|
+
source = validate_path(source, output_root)
|
|
352
|
+
except SecurityError:
|
|
353
|
+
summary.missing.append(recorded)
|
|
354
|
+
continue
|
|
355
|
+
if not os.path.isfile(source):
|
|
356
|
+
summary.missing.append(recorded)
|
|
357
|
+
continue
|
|
358
|
+
if source in copied:
|
|
359
|
+
continue
|
|
360
|
+
copied.add(source)
|
|
361
|
+
try:
|
|
362
|
+
relative = os.path.relpath(source, run_root)
|
|
363
|
+
except ValueError: # different drive on Windows
|
|
364
|
+
relative = os.path.basename(source)
|
|
365
|
+
if relative == os.pardir or relative.startswith(os.pardir + os.sep):
|
|
366
|
+
relative = os.path.relpath(source, output_root)
|
|
367
|
+
_copy(
|
|
368
|
+
summary,
|
|
369
|
+
source,
|
|
370
|
+
target,
|
|
371
|
+
os.path.join("outputs", *relative.split(os.sep)),
|
|
372
|
+
recorded,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _readme(job_id, detail, manifest, workflow, summary, realized):
|
|
377
|
+
workflow_block = manifest.get("workflow") or {}
|
|
378
|
+
prompts = workflow_block.get("prompts") or []
|
|
379
|
+
sub_workflows = workflow_block.get("sub_workflows") or {}
|
|
380
|
+
inputs = [
|
|
381
|
+
entry["path"] for entry in summary.files if entry["path"].startswith("inputs/")
|
|
382
|
+
]
|
|
383
|
+
return README_TEMPLATE.format(
|
|
384
|
+
job_id=job_id,
|
|
385
|
+
workflow_name=detail.get("workflow") or "unknown",
|
|
386
|
+
catalog_name=(
|
|
387
|
+
f"`{detail['workflow_name']}`"
|
|
388
|
+
if detail.get("workflow_name")
|
|
389
|
+
else "none - an inline definition"
|
|
390
|
+
),
|
|
391
|
+
realized_sentence=(
|
|
392
|
+
"`workflow.json` is the *realized* workflow: every mutable input "
|
|
393
|
+
"is pinned, so it reproduces this run whatever changes afterwards."
|
|
394
|
+
if realized
|
|
395
|
+
else "`workflow.json` is the definition as submitted - this job "
|
|
396
|
+
"predates run tracking, so its arguments and prompts are not "
|
|
397
|
+
"pinned into it."
|
|
398
|
+
),
|
|
399
|
+
run=(
|
|
400
|
+
f"`{detail['run_id']}`"
|
|
401
|
+
+ (
|
|
402
|
+
f" - version {manifest['version']}"
|
|
403
|
+
if isinstance(manifest.get("version"), int)
|
|
404
|
+
else ""
|
|
405
|
+
)
|
|
406
|
+
if detail.get("run_id")
|
|
407
|
+
else "not recorded"
|
|
408
|
+
),
|
|
409
|
+
status=detail.get("status"),
|
|
410
|
+
started_at=detail.get("started_at"),
|
|
411
|
+
finished_at=detail.get("finished_at"),
|
|
412
|
+
device=manifest.get("device", "unknown"),
|
|
413
|
+
dw_version=manifest.get("dw_version", "unknown"),
|
|
414
|
+
seed=manifest.get("seed", workflow.get("seed", "not recorded")),
|
|
415
|
+
arguments=json.dumps(detail.get("arguments") or {}, indent=2),
|
|
416
|
+
prompts=_bullets(f"`{name}` - inlined into `workflow.json`" for name in prompts)
|
|
417
|
+
or "None: this workflow named no stored prompt.",
|
|
418
|
+
sub_workflows=_bullets(
|
|
419
|
+
f"`{path}` - sha256 `{digest}`" if digest else f"`{path}` - unreadable"
|
|
420
|
+
for path, digest in sorted(sub_workflows.items())
|
|
421
|
+
)
|
|
422
|
+
or "None: this workflow composed no other workflow by path.",
|
|
423
|
+
inputs=_bullets(
|
|
424
|
+
f"`{path}` - copied from the run named in its own path" for path in inputs
|
|
425
|
+
)
|
|
426
|
+
or "None: this workflow named no file from an earlier run.",
|
|
427
|
+
missing=_bullets(f"`{name}`" for name in summary.missing)
|
|
428
|
+
or "Nothing: every file this run referenced was found and copied.",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _bullets(lines):
|
|
433
|
+
return "\n".join(f"- {line}" for line in lines)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ------------------------------------------------------------------- files
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _copy(summary, source, target, relative, name):
|
|
440
|
+
"""Copy one file into the export and record it.
|
|
441
|
+
|
|
442
|
+
`name` is how a failure is reported - the reference or the manifest's own
|
|
443
|
+
entry, never the absolute path, because `missing` is read out of the
|
|
444
|
+
README on another machine where that path means nothing.
|
|
445
|
+
"""
|
|
446
|
+
destination = os.path.join(target, relative)
|
|
447
|
+
try:
|
|
448
|
+
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
|
449
|
+
shutil.copyfile(source, destination)
|
|
450
|
+
except OSError as e:
|
|
451
|
+
logger.warning(f"Could not copy {source} into the export: {e}")
|
|
452
|
+
summary.missing.append(name)
|
|
453
|
+
return
|
|
454
|
+
_record(summary, destination, target)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _write_json(summary, target, name, payload):
|
|
458
|
+
_write_text(summary, target, name, json.dumps(payload, indent=2, default=str))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _write_text(summary, target, name, text):
|
|
462
|
+
path = os.path.join(target, name)
|
|
463
|
+
try:
|
|
464
|
+
with open(path, "w", encoding="utf-8") as file:
|
|
465
|
+
file.write(text)
|
|
466
|
+
except OSError as e:
|
|
467
|
+
logger.warning(f"Could not write {path}: {e}")
|
|
468
|
+
return
|
|
469
|
+
_record(summary, path, target)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _record(summary, path, target):
|
|
473
|
+
try:
|
|
474
|
+
size = os.path.getsize(path)
|
|
475
|
+
except OSError:
|
|
476
|
+
return
|
|
477
|
+
summary.files.append(
|
|
478
|
+
{"path": os.path.relpath(path, target).replace(os.sep, "/"), "bytes": size}
|
|
479
|
+
)
|
|
480
|
+
summary.total_bytes += size
|