This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
diffusers-workflow is a declarative workflow engine for HuggingFace Diffusers. Users define AI image/video generation pipelines in JSON — variable substitution, multi-step composition, cross-step data flow, and utility tasks — without writing Python. Supports CUDA (NVIDIA), MPS (Apple Silicon), and CPU.
# Install
bash ./install.sh && source ./activate
# Run a workflow - templates/text-to-image.json uses a small, ungated model and a literal
# prompt, so it needs no Hugging Face login and downloads only a few GB
python -m dw.run workflows/templates/text-to-image.json
python -m dw.run workflows/templates/text-to-image.json prompt="a cat" num_images_per_prompt=4
# A gated model (e.g. workflows/models/flux-dev.json) needs Hugging Face auth
# first: huggingface-cli login
# Validate a workflow against schema
python -m dw.validate workflows/models/z-image.json
# Basic system test (torch, diffusers import check)
python -m dw.test
# Interactive REPL
python -m dw.repl
# HTTP server + web UI (http://127.0.0.1:8765, API docs at /docs)
python -m dw.servedw/serve.py runs a FastAPI app over the same persistent worker the REPL uses,
queueing jobs FIFO and persisting history to ~/.diffusers_helper/jobs.sqlite.
See docs/SERVER.md, dw/server/CLAUDE.md and ui/CLAUDE.md.
The stdio MCP server lives in dw_mcp/ — see dw_mcp/CLAUDE.md and docs/MCP.md.
.claude-plugin/marketplace.json publishes the dw plugin in plugins/dw/: one
composition skill per model family (minimax-h3, minimax-music3, ltx-2.5) that
chooses a template for a request's shape and states the family's hard rules. Model
knowledge lives there and in the catalog, never in engine code; every number a skill
states is pinned to a diffusers symbol by tests/test_plugin_skills.py. plugin.json's
version is the engine's, bumped by scripts/release.sh. Adding or re-auditing a family
is .claude/skills/model-family-onboarding/.
The REPL (dw/repl.py) uses a persistent worker subprocess (dw/worker.py) to keep GPU models cached between runs. Communication is via multiprocessing.Queue. Worker management is in dw/repl_worker.py, command handlers in dw/repl_commands.py.
Critical: Uses multiprocessing.set_start_method("spawn") for CUDA/MPS compatibility.
dw.serve can hold several workspaces under one root: the root's own
workflows/assets/outputs are the default workspace, a named one is a
subdirectory beside them (named_workspace, create_workspace in
dw/workspace.py), and prompts/ at the root is shared by all of them - there
is one prompt library, because prompt: is shared by reference. Routes take an
optional workspace; omitting it means the default, so pre-workspace calls are
unchanged. A job carries its own output_dir, asset_dir and workflow_dir
(JobManager.submit), so it stays in its workspace whatever the manager serves
next; the worker activates the asset root per job (activate_asset_dir), which
is the one root that could not stay process-wide. jobs.sqlite has a
workspace column, backfilled to default. common/assets at the root is
the one asset library every workspace shares - assets are otherwise per
workspace, which is wrong for a recurring cast a later workspace still has to
reach. It sits on every workspace's asset search path behind that workspace's
own library (so a workspace name shadows a shared one), is tagged origin: common by GET /api/assets, and is written to only when a call says so
(?shared=true on uploads, "shared": true on keep, shared=True over MCP).
Reserved names: workflows, prompts, assets, outputs, exports,
common.
The web UI has a page for it: ui/src/lib/pages/AssetsPage.svelte (#165)
reads GET /api/assets and shows the library the way the gallery shows
outputs, tagged by origin so a shadowed or read-only entry is visible
before a 403 explains it.
dw/workflow_sources.py is the server's workflow search path: the writable
directory first (the workspace's workflows/), then any --examples-dir, each
read-only. Reads (listing, find_workflow) span every root front-to-back so an
earlier name shadows a later one; PUT /api/workflows always resolves through
writable_source, so saving something opened from a read-only root writes a copy
rather than overwriting it, and DELETE on a read-only root answers 403. A job
carries the root it is confined to (JobManager.submit(workflow_dir=...)), so an
examples workflow runs confined to the examples directory rather than to the
writable one. Packaged dw/workflows/ is off the path - it is what builtin:
sub-workflow steps name, resolved in dw/workflow.py.
The prompt and asset libraries have the same shape: each --examples-dir
brings the prompts/ and assets/ beside it (example_libraries in
dw/workspace.py), pinned into DW_PROMPT_PATH / DW_ASSET_PATH by
dw.serve so the spawned worker resolves as the API does. prompt_search_path
/ asset_search_path put the workspace's own library first, so a workspace
name shadows an example's; GET /api/prompts and GET /api/assets span the
path and tag each entry with its origin; writes (PUT /api/prompts, uploads,
keep-as-asset) only ever land in the workspace, and deleting a read-only prompt
answers 403.
dw/workspace.py resolves the one directory a run's content belongs to -
workflows/, prompts/, assets/, outputs/. Order: --workspace >
DW_WORKSPACE > the workspace setting > the working directory when it holds
any of workflows/, prompts/ or outputs/ > ~/diffusers-workspace. A
checkout satisfies rule four, so every default lands where it did before
workspaces existed. Resolution creates nothing; an entry point about to write
calls ensure() (or creates the one folder it needs). set_workspace pins the
root and how it was chosen into the environment, so a spawned worker does not
read an inferred workspace back as one the user named - get_prompt_dir yields
to its older discovery (./prompts, then the walk up from the workflow file)
for an inferred workspace but not for an explicit one. --workflow-dir,
--output-dir and --prompt-dir each still override one folder. See
docs/WORKSPACES.md, and docs/proposals/server-workspaces-complete.md for the later stages
(workflow search path, run directories, asset:/output: references).
arguments.py + type_helpers.py handle dynamic type conversion during workflow loading:
- Keys ending in
_typeor_dtype, or nameddtype, are auto-converted:"FluxPipeline"→ loaded fromdiffusers,"torch.bfloat16"→torch.bfloat16 - Values wrapped in
{}are escaped (stay as strings):"{nf4}"→"nf4" - Dotted names use full module path:
"sdnq.SDNQConfig"→importlib.import_module("sdnq").SDNQConfig - Values prefixed with
constant:read a value declared in python rather than copying it into JSON:"constant:diffusers.pipelines.ltx2.utils.DISTILLED_SIGMA_VALUES". Resolved inrealize_args, validated byvalidate_constant_name(); anything callable is refused - Values prefixed with
asset:resolve to the path of a file in the asset library:"asset:iris.png"or"asset:gyre/frames/web.mp4". Resolved inrealize_argsbefore every other convention (dw/assets.py), rooted at the library rather than the workflow file, confined to it, and then loaded by whatever would have loaded a path written there. The library isDW_ASSET_DIR/--asset-dir, else the workspace'sassets/when a workspace was named, else./assetsif it exists, else found by walking up from the workflow file's directory - Values prefixed with
output:resolve to the path of a file an earlier run wrote:"output:ltx2/Gyre/latest/still.png". The name is<workflow identity>/<run id>/<file>under the output root, andlatestin the run-id position picks the newest run that holds the file (run ids sort by their UTC timestamp; a failed or fully-cached run holds only a manifest and is skipped). Resolved inrealize_argsbesideasset:(dw/runs.py), against the output rootWorkflow.runactivates, and confined to it - A generated file becomes a stable input with
POST /api/assets/keep(gallery "Keep as asset", MCPkeep_output): it is hard-linked, else copied, from the workspace's outputs into its assets under a chosen name, so later workflows referenceasset:namerather than a run id that pruning would break - Values prefixed with
prompt:load a stored prompt'stextfrom the prompt library:"prompt:name"or"prompt:folder/name". Resolved inrealize_args(dw/prompts.py), rooted at the library rather than the workflow file. The library isDW_PROMPT_DIR/--prompt-dir, else./promptsif it exists, else found by walking up from the workflow file's directory - A step's
result.subfoldernames a subfolder of the run directory for that step's files - by conventionfinalfor the deliverable andintermediatefor the rest; any relative path (shots/act-1);variable:/item:allowed; no default. Mechanics under Result subfolders in Critical Gotchas - A step carrying
for_each(a list, orvariable:naming one) is expanded byexpand_for_each(dw/for_each.py) into one ordinary step per entry, named<step>@<entry name or index>, immediately afterreplace_variablesinWorkflow.runand, with the caller's arguments folded, invalidation_errors. Inside a memberitem:/item:fieldis the entry (any type, spliced whole); a later step reads the group withgather:<step>(a list; splices inside a list); two groups over the same list pair by key (sliceinsideshot@xisslice@x).previous_result:naming a group is a directed error.@is reserved in step names; entry names are validated and unique; 32 entries max;release_pipeline/release_modelssurvive on the last member only. The realized workflow keepsfor_each; the manifest names the members. An entry of a list-valued variable may reference another variable ("from_file": "variable:character_a_voice");resolve_variable_values(dw/variables.py) replaces those once, beforerealize_args, refusing a cycle, andundeclared_variable_referenceswalks inside list/dict variable values too. The catalog deriveslists(list_fields,dw/for_each.py): the fields an entry takes are theitem:references the steps make,namefirst; an entry key no step reads is a validation warning (entry_field_warnings). Acostentry may carryper_entry({variable, minutes, entries}), measured, never derived. An emptyfor_eachlist is an error;expanded_definitionrealizes constants first. - Every run directory holds
workflow.jsonbeside its manifest: the realized workflow, with the run's arguments folded into the variable defaults, the seed it used, stored prompt text inlined andoutput:.../latest/...pinned to the run it resolved to. Written byrealize_workflow(dw/realize.py) at run start, best effort. Over MCP,get_job_workflowreads it back andsave_workflownames it;export_jobbundles the run
The same conventions, written for an agent composing a workflow over MCP, are
the Authoring a workflow from an agent section of docs/WORKFLOW_GUIDE.md;
change both when one changes.
templates/ltx2/generative-upscale was the only IC-LoRA use in the catalog;
three more conditioning templates join it (#151, #152), all through
LTX2InContextPipeline + LTX2ReferenceCondition, all at
reference_downscale_factor: 1 (the upscaler's is 2). reference-sheet
drives Ingredients — the family's only identity route, and the first two
templates here whose reference is a file the workflow did not make; the sheet
is a still, so a loop_frames step (dw/tasks/video_utils.py, the video
analogue of loop_audio) laps it into the static video the LoRA reads
through its 121-frame bucket. restore-deblur and restore-decompression
each invert one defect and no other. Every number in the three is the vendor
card's and is pinned by tests/test_ltx2_ic_loras.py; the trained caption
form is a different genre from a T2V shot caption, so those stored prompts
are tagged ic-lora and tests/test_ltx_prompt_library.py checks them
against their own convention rather than the 150-220-word paragraph rule.
The weights are gated: auto on Hugging Face — per repo, so a box that pulls
one can still 403 on another. A loras entry counts toward
plan.downloads_required (_collect_sources, dw/plan.py): it names its repo
under model_name directly rather than through from_pretrained_arguments, so
the walk used to miss it and a box holding every base weight but not the
IC-LoRA answered [] and then pulled it mid-run.
Quantization configs are defined per-component in workflow JSON and instantiated in config_objects.py. Supported frameworks: BitsAndBytes, TorchAO, GGUF, SDNQ, optimum-quanto. The config_type field is a free-form string — new quantization backends work automatically via dynamic import.
SDNQ pre-quantized models use a different pattern: pre_load_modules imports sdnq (registers with diffusers), then the entire pipeline loads from the pre-quantized repo. Optional sdnq_optimize applies quantized matmul post-load (CUDA/XPU only).
dw/__init__.py handles device detection (CUDA > MPS > CPU) and platform-specific optimizations:
- CUDA: TF32 matmul, cuDNN benchmark, deterministic mode (configurable via settings)
- MPS:
PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0(use all unified memory), autocast warnings suppressed, attention slicing enabled by default - CPU: Warning displayed
Detection is overridden by the DW_DEVICE environment variable (single run) or the device setting (standing), either of which can name a specific accelerator such as cuda:1. Device placement is explicit throughout — no default torch device is set, since that would build models directly in VRAM and defeat offloading. Compare backends with get_device_type() rather than == "cuda", which a device like cuda:1 would fail.
A step can override the device it runs on: device in a pipeline configuration (also the default for that pipeline's components), in a component configuration, or in a task's arguments.
Every device a workflow names passes through resolve_device(), which translates a backend this machine does not have into the one it does and warns — a cuda workflow runs on a Mac and an mps one runs on a CUDA box. Only the backend is translated: an index survives when the backend matches (cuda:1 on a single-GPU CUDA box stays a genuine error) and is dropped when it does not. cpu is never rewritten, since pinning a step to the CPU is how a GPU-specific problem gets ruled out. Translation happens before anything reads the backend, so the MPS accommodations (the sequential-offload downgrade, attention slicing, the compile skip) fire for a translated device too.
A components entry can additionally set residency: "on_demand", which rests the component on the CPU and wraps its forward/encode/decode to move it to the device around each call (apply_on_demand_placement in pipeline.py). The wrappers use functools.wraps because callers introspect the signature — MiniMax H3's denoiser picks its arguments from signature(transformer.forward). It is mutually exclusive with group_offload on the same component, and like group_offload it suppresses the wholesale pipeline.to(device) at load.
Settings in ~/.diffusers_helper/settings.json: device, enable_tf32, cudnn_benchmark, cudnn_deterministic, log_level, log_filename.
All entry points use dw/security.py. When adding features:
- Validate paths with
validate_path()/validate_workflow_path()/validate_output_path() - Validate variable names with
validate_variable_name()(pattern:^[a-zA-Z_][a-zA-Z0-9_-]*$) - Validate URLs with
validate_url()(http/https only) - Sanitize subprocess args with
sanitize_command_args() - Never use
eval(),exec(), orshell=True - Path traversal (
../) is blocked
CodeQL knows about these validators, which is why the scan is quiet: the local
query pack in .github/codeql/dw-security/ models them as sanitizers for
py/path-injection, because the built-in query recognizes a
normalize-then-check only as a local barrier guard and so cannot see one that
lives in another module and returns the safe value. This is what makes code
scanning useful here rather than 26 identical false positives - but it only
holds while new filesystem access goes through a validator. Reaching the disk
some other way is a real alert, so treat one as a finding rather than as more
of the old noise. validate_path(path, base) is modeled as a barrier only when
base is not None; with None it is normalization only, and the path stays
reportable. Scanning is advanced setup (.github/workflows/codeql.yml) for the
same reason - default setup cannot load a pack.
- Schema validation runs before variable substitution — variable defaults must match expected JSON types (use
25not"25"for numbers) previous_result:references are checked statically too — once the schema passes,previous_result_reference_errors(dw/previous_results.py) reports any literalprevious_result:orfrom_previous_resultnaming no earlier step, with the JSON path it sits at. References otherwise resolve lazily per step, so a step renamed in one place and not another failed only when the run reached it, after every step before it had generated. The definition is substituted before the check, so a reference spelled by a declared variable is checked by its value; one spelled by an undeclared variable is itself a validation error (below)for_eachexpands before the reference check —validation_errorssubstitutes (the caller'sargumentswhen they are all good, else the defaults) and expands first, sogather:anditem:errors carry the path of the template step (steps[0].for_each[1].name). Expansion records each expanded step's source index (expand_for_each(definition, source_indices)), so a reference error always carries a path in the file the author wrote, and one inside a member names the member in its message. An undeclaredvariable:is a validation error at the path it sits at, not a warning and not a complaint about thefor_eachlist that did substitute: once avariablesblock exists,replace_variablesrefuses an undeclared reference, so it is a run that cannot start- The two MiniMax cut templates take one
shotslist — since the stage-2 rewrite (2026-09-11)templates/minimax/dialogue-shortandmusic-videohave noshot_N_*variables; a scripted caller passesshots(entries{name, prompt, references, num_frames}and{name, prompt, start_frame}). The members areshot@<name>in the manifest and the gallery. This is the breaking change the next release note should name. The CLI and REPL only takename=valuestrings, and a string handed to a list variable is comma-split - soshotscan only be supplied over the API/MCP (a JSON body);python -m dw.runruns the templates' default list - A reference name is checked for its shape before the queue, and
@is part of it — afor_eachmember is<group>@<entry>and the files it writes carry that@in their base name, whichOUTPUT_REFERENCE_PATTERNandASSET_REFERENCE_PATTERNrefused: a whole class of files the server itself named could not be named back to it, sooutput:on a shot a list-driven template produced forced a re-render or anupload_assetround trip (#162).@is safe in a path — not a separator, not.., and containment is stillvalidate_path's — and a name still may not start with one. The other half is that the refusal arrived at run time, after the queue, from a message that described a valid name and never said which character it objected to:reference_name_errors(dw/reference_names.py) now checks the shape of everyasset:/prompt:/output:reference in the definition invalidation_errors, and_name_fault(dw/security.py) names the offending character and position. Shape only — existence depends on the workspace and on what pruning has taken, so it stays where it was: the validate route resolves the caller'sargumentsagainst the workspace, and the definition's own references resolve at run time - Cartesian product explosion — multiple
previous_resultreferences multiply: 4 images × 3 masks = 12 iterations - Component sharing requires exact key matching between
shared_componentsandreused_components - Built-in workflows need explicit argument mapping:
"prompt": "variable:prompt" - MPS differences from CUDA: no autocast, no bitsandbytes, no flash_attn, no triton, no torch.compile. Model offloading has less benefit on unified memory, and
"offload": "sequential"is downgraded to"model"with a warning there (place_component) — per-submodule streaming hands back no residency when the CPU and the accelerator share one pool.exclude_from_cpu_offloadis sequential-only and does not survive the downgrade. {}-escaped strings in JSON arguments:"{nf4}"stays as string"nf4", without braces it would try to load as a type- A stored prompt's
textmay not begin with a reference prefix (variable:,previous_result:,constant:,asset:,output:,prompt:) — the engine rejects it to prevent double resolution or iteration expansion - Audio+video muxing: pipelines that generate audio alongside video (LTX-2) have the two muxed into one
video/mp4file with PyAV inresult.py - A caller's
argumentsare checked before anything is queued -argument_errors(dw/variables.py) folds them into the declared variables exactly asset_variablesdoes at the top of a run, so an undeclared name or a value that will not coerce is a 400 fromPOST /api/jobsrather than a job that fails on its first step, andPOST /api/validatetakes the samearguments(plus anasset:/prompt:/output:existence check against the workspace) so the free pre-flight covers the part the caller wrote. A workflow that declares no variables takes no arguments at all - those were dropped in silence, sinceWorkflow.runonly substitutes when avariablesblock exists. A validPOST /api/validateanswer also carriesplan(dw/plan.py): the fingerprint of the work, step and list counts,downloads_requiredand a costestimatewith itsbasis- the number an agent quotes, withbasissaying whether it was measured for this list (catalog/per_entry) or extrapolated over one the caller resized (derived);plan: nullwhen it could not be built, never a changed verdict.acknowledged_costonPOST /api/jobs/reruntakestrue(recorded) or the plan's{fingerprint, minutes, downloads}(checked - 409 with the current plan when the fingerprint or the required downloads changed;minutesnever compared), and the job recordsacknowledged: none | boolean | bound.cached_stepsis the worker's answer to aprobe_cachecommand (Workflow.cache_hits, which shares_prepare_definition/_cache_lookupwithrunso the two cannot drift). The web UI reads the fields only: the editor lists the plan under a valid verdict (describePlan,ui/src/lib/plan.ts), and a job queuedboundsays so on the job page and in the jobs list; the UI itself sends no acknowledgement - A failed run still reports what it wrote — the worker carries its partial
manifest on the error and cancelled messages as well as on success, and the
"Previous result not found" error names the steps that ran even after
release_unreferenced_resultshas dropped their results - Run directories: each execution writes
<output_dir>/<workflow identity>/<run id>/with amanifest.jsonbeside its files (dw/runs.py,Workflow.effective_output_dir). Identity is the workflow's path under aworkflows/tree, else its file name, else itsid; the run id is<UTC timestamp>-<8 hex of the spec>, with a-Ncounter if taken. A sub-workflow inherits the parent's run directory and writes no manifest of its own.--output-layout flat/DW_OUTPUT_LAYOUT/ theoutput_layoutsetting restores the old layout. The gallery groups a workflow's runs under one folder by stripping the run id (strip_run_id). The realized workflow is written into the same directory asworkflow.json(dw/realize.py,write_realized_workflow), and the manifest'sworkflowblock carriesrealized,prompts(the stored prompts inlined) andsub_workflows(path -> SHA-256). A job records the run it was (run_id/run_dironJoband injobs.sqlite), which is howJobManager.realizedfinds the file.exportsis a reserved workspace name:POST /api/jobs/{id}/exportgathers one finished job into<workspace>/exports/<job id>/andGET /exports/<job id>.zipstreams it. - Result subfolders: a step's
result.subfolder(dw/subfolders.py) puts its files in a subfolder of the run directory -<run>/final/x.mp4- by conventionfinalorintermediate; the engine treats no name specially and there is no default.Workflow.step_output_dircomputes the directory once and hands it to bothResult.saveand the pipeline wrapper, so a chain'ssave_segmentsspill follows it. Shape isSUBFOLDER_PATTERN(theoutput:segment rule, so a subfolder isoutput:-addressable up toOUTPUT_REFERENCE_PATTERN's seven-segment ceiling), checked bysubfolder_errorsinvalidation_errorsafterfor_eachexpansion and again at run time; containment isvalidate_output_pathagainst the run directory. Manifest entries andstep_endcarrysubfolder.split_run_pathfinds the run id anywhere in a path, sostrip_run_idstill groups a workflow's runs. Gallery entries carry it too;GET /api/gallery?subfolder=and MCPlist_gallery(subfolder=)filter on it. The web UI reads the field only: the gallery page offers a subfolder pick once any entry has one, and the job page sections results underfinal//intermediate/headings (or whatever the step named) (sectionBySubfolder,ui/src/lib/results.ts), unchanged for a run that chose none.file_base_namemay not contain a separator - it is a name, not a path - and it replaces the derived<workflow id>-<step name>.<index>base rather than prefixing it (#100), so two steps in one subfolder that set the same one collide ontooutput_file_path's-2counter. Everyworkflows/templates/**file with two or more saving steps marks each onefinal/intermediate(tests/test_template_subfolders.pypins the rule;dw/workflows/builtins stay unmarked - a role is the parent's to assign). That moved the templates' outputs into<run>/final/and<run>/intermediate/: anoutput:<template>/latest/xreference keeps resolving but stops advancing past the last pre-change run (keep_outputis the stable form), and a seeded template's first run after the change regenerates rather than hitting the step cache (the key includesresult). Those two, and a straysubfolderkey becoming live, are the release-note items beside theshotslist change. Gallery names for a template's runs now read<template>/<run id>/final/<file>, so anoutput:reference built from one carries thefinal/segment - A task argument's numeric domain is declared, not inferred — a task
command's argument schema is its implementation's signature, which says
nothing about range, so
dw/task_domains.pydeclares the domains that are not a judgement call (a count or a rate above zero, an offset zero or above) andvalidation_errorsreports a literal outside one at its JSON path. The commands check the same table at run time (check_arguments), which is the only layer that sees a value arriving from avariable:or an earlier step. Both defects it closed were silent successes rather than failures:slice_audio(num_frames=-10)reached Python's slice semantics and returned the track minus its last ten frames (#139), andresample_audio(target_sample_rate=0)left the samples alone and then hitDEFAULT_AUDIO_SAMPLE_RATEat save, writing a 44100 Hz header over a 32 kHz waveform (#140) — which is why_as_tracknow refuses a non-positive rate outright: relabelling a waveform changes its speed and pitch, and the save default makes a missing rate look like a valid one. Adding a domain means one entry in the table;tests/test_task_domains.pypins every entry to a real parameter of a real command so a rename cannot leave one checking nothing costis curated,observedis derived, and they are different fields —dw/workflow_schema.jsondefinescostas "Never derived", so nothing writes one;dw/server/observed_cost.pyreports a sibling built from this box's ownjobs.sqliterows (#93). Four rules, each a way the naive median would lie: runs are bucketed by the workflow's declaredcost_drivers(a list driver on its length, so two four-shot runs are comparable however different their prompts) and the bucket reported is the one the defaults give, keeping it comparable to a curated figure;cold_minutesandwarm_minutesare separate, each with its own run count, and only the cold one is comparable tocost(wall clock including model load); a run whose every manifest entry isreusedwrote nothing and is excluded; and a run whose persisted events hitMAX_PERSISTED_EVENTSwithout aloadingphase isunclassified_runsrather than assumed warm. Everything comes off the job row in one query, so a figure survives a pruned run directory, and the aggregate caches againstJobHistory.watermark()rather than a file mtime — a job landing changes every figure and changes no file. The compact listing carries onlyobserved_minutes/observed_runs(#101 budget); the full block is in the full listing andGET /api/workflows/{name}/variables. The rawGET /api/workflows/{name}is left verbatim, since the editor saves what it reads back. Acost_driversentry naming no declared variable is dropped, andtests/test_observed_cost.pysweeps the catalog for one.plan.estimatequotes the observed figure ahead of the curated one (basis: "observed", withruns) —basis: "unknown"has to mean nobody has a number, not nobody curated one (#154). Only the cold median, only when the history is this backend's, and only for the bucket the caller's own arguments fall in (ObservedCosts.observed(name, definition, arguments)); a resized list finds no bucket and falls back to the curated figure. Nothing is added for a composed child, since an observed run already ran it. An inline definition has no catalog name, so no history- An H3 adapter is checked against the partition its step denoises on —
ref2valoadstransformer_refalone, so diffusers puts whateverlora_weight_namenames straight onto it: an FL2VA turbo LoRA on a reference step runs, succeeds, and only retains identity worse (#149, #155).dw/adapter_compatibility.pyrefuses the mispairing invalidation_errors(soPOST /api/validateand the pre-queue check both catch it, atarguments.<name>when the caller supplied it) and warns on a file name carrying neitherref2vnorfl2v— the name of a future reference-trained checkpoint cannot be predicted, so the escape hatch stays open while the one documented mistake is closed. The workflow names and the partition each denoises against are diffusers' (MiniMaxH3Blocks._workflow_map, pinned bytests/test_h3_adapters.py); the file-name convention is MiniMax's and is swept against the catalog's own defaults - An elided step says whether anyone decided it —
warn_elidedused to tell every caller their reference was probably misspelled, including the one who deliberately passedsinger_referenceand so bought the elisionmusic-videoadvertises (#146, #157).overriding_variables(dw/elision.py) compares the definition as written against the substituted steps: a step reached only through a variable whose value no longer names it was replaced on purpose, and its record carriesoverridden_byand drops the diagnosis. A variable no step reads is not how the step was reached, so that case keeps the old wording - A deliverable with no audio headroom warns — a track at or above
−0.5 dBFS is written anyway and said out loud (
warn_without_headroom,dw/result.py, kindaudio_no_headroom), for both a saved audio file and a muxed video: a clipped file succeeds, and a consumer that cannot listen hadpeak_dbfswith no rule to read it against —get_gallery_metadata's hint taught the near-silent end of the range only (#158). A warning, not a gain change: what level a deliverable sits at is the workflow's to decide, andnormalize_audiois the step that decides it. The two Music 3 templates decide it now (#159) -musicandmusic-videopeak-normalize to -1 dBFS, the levelassemble-and-scorehas always used, because the warning was firing on their own defaults every run.music-videonormalizes only the track going into the mux, not the slices that condition the shots, so the picture is unchanged;music's deliverable moves to the newbalancedstep, which renames the file anoutput:reference names - A deliverable is measured as written, not as handed to the writer —
warn_without_headroomreads the waveform, and the encoder sits downstream of it: a song normalized to exactly -1.0 dBFS came back out ofmusic-video's AAC mux at +0.94, so a clean default run shipped a clipped file and nothing warned (#161).warn_if_written_above_full_scale(dw/result.py, kindaudio_clipped) probes the file it just wrote and warns when it decodes at or above 0 dBFS — whatever the encoder did, that is the number a consumer's decoder sees. Only for a file that can carry a soundtrack, and silent whenwarn_without_headroomalready spoke for that file, since two warnings would be two answers to one mistake. The encode's overshoot is material-dependent — about 0.1 dB on an mp3 and about 1.9 dB on the AAC mux of the same song — so no target chosen up front can be known to be enough, which is why reading the file back is the half that stops the next instance. The half that fixes this one: every template whose deliverable ends in apair_audiomux (music-video,assemble-and-score,dissolve-between-shots) normalizes to -3 dBFS;music, an mp3, keeps -1 - A variable's bound is declared by the author, checked three times — a
model's own rule about a value (H3's
num_framesis17 * n + 5from 124 to 345) is a property of the model, so it lives in the workflow rather than in engine code, as avariable_constraintsentry (dw/variable_constraints.py). One shape, not two: it takes a chain step'sframe_snapfield names, and a chain writes"frame_snap": "constraint:num_frames"rather than repeating the numbers.snap: "up"rounds an off-grid value to the next legal one and warns (at validation and throughemit_warning, so it reaches the job'swarnings); withoutsnapan off-grid value is refused. The bounds hold for the value the run will use, matching diffusers' ownalign_num_frames, which snaps before it range-checks — so 108 is accepted (it becomes 124) and 346 refused (it would become 362). LTX-2.5's templates declare the8 * n + 1grid with nosnap, because those pipelines floor an off-grid count rather than raising: rounding up here would be a second silent change to the length. Checked invalidation_errors(soPOST /api/validate,validate_workflowand the pre-queue check all refuse it atarguments.<name>/variables.<name>), at run time inapply_constraintsbefore anything loads, and reported beside the default bylist_workflows(terse) /get_workflow(variables_only=true)— that last part is what stops the next consumer picking 61 (#96).tests/test_variable_constraints.pysweeps the whole catalog and pins every declared number to the diffusers symbol it derives from. A constraint key is a plain variable name and is matched wherever a value by that name sits - top-level variable or a field of afor_eachentry (#145), the latter only where a step consumes that field asitem:<name>(entry_constraint_fields), so the bound follows the value into the pipeline argument rather than the name into the JSON. An entry violation is reported atarguments.shots[0].num_frames, and the rule is reported beside the field in the catalog'slistsblock as well as inconstraints - Step cache: a process-wide singleton (
dw/step_cache.py) consulted by everyWorkflow.run, including server jobs; entries are keyed by(workflow id, step name)and validated against the output root, never the per-run directory - a run directory is new every execution and would defeat the cache; disabled entirely when the workflow sets noseed; a hit reports the earlier run's files withreused: trueand writes nothing new;memory cleardrops it. This is why "Run again" on a seeded workflow finishes instantly and generates nothing - the job page says so when every step was reused, andPOST /api/jobs/{id}/rerunwith{"new_seed": true}(MCPrerun_job(new_seed=True)) draws a fresh seed into the workflow's seed variable, which is the way to get a different image
The workflow schema is at dw/workflow_schema.json — read it for the full structure.
File paths in workflows are relative to the workflow file. Built-in workflows use "builtin:filename.json" (resolves to the packaged dw/workflows/ — distinct from the top-level workflows/ folder of runnable examples).