diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..c249ae7a --- /dev/null +++ b/.env.example @@ -0,0 +1,132 @@ +# diffusers-workflow environment knobs. +# +# Copy to .env and uncomment what you need. dw loads .env from the directory +# a process starts in (load_dotenv() in dw/__init__.py), before diffusers or +# torch is imported, so every line here reaches dw.serve, dw.run and each +# spawned worker. A variable already set in the environment wins over this file. +# Everything below is commented out, so copying this file as is changes nothing. +# Workers read .env when they start, so restart dw.serve (or its idle workers) +# after editing it. +# +# Never commit .env: it is git-ignored because tokens go in it. + +# --- dw: server and clients -------------------------------------------------- + +# Bearer token for dw.serve. Set, every API, UI and MCP request must carry it. +# dw.run and the stdio MCP server read the same variable, so one value +# configures both ends. Required before binding to anything but localhost. +# DW_API_TOKEN=some-long-random-string + +# This server's public origin, for links handed to someone not at the box +# (docs/REMOTE.md). Overrides the 'public_url' setting. +# DW_PUBLIC_URL=http://lem:8765 + +# Where dw.run and the stdio MCP server find dw.serve. +# DW_MCP_URL=http://127.0.0.1:8765 + +# The workspace the stdio MCP session starts in - a name on the server. +# DW_MCP_WORKSPACE=default + +# The cap on one wait_for_job call, in seconds (default 55). Raise it only if +# your MCP client holds a tool call open longer than that. +# DW_MCP_MAX_WAIT_SECONDS=55 + +# --- dw: engine ---------------------------------------------------------------- + +# The device for a CLI run: cuda, cuda:1, mps or cpu (default: detected). +# dw.serve picks cards with --devices instead. +# DW_DEVICE=cuda + +# The workspace directory: workflows, assets, outputs, prompts +# (default ~/diffusers-workspace; dw.serve --workspace wins over it). +# DW_WORKSPACE=~/diffusers-workspace + +# 'flat' restores the pre-run-directory output layout for scripts that glob +# the output folder (default: one directory per run). +# DW_OUTPUT_LAYOUT=flat + +# Where settings.json, the job database and the log live +# (default ~/.diffusers_helper). +# DIFFUSERS_HELPER_ROOT=~/.diffusers_helper + +# --- Defaults dw sets itself (shown so you know they exist) ----------------- +# Each is set at import only when absent; set it here to override. + +# Lets the CUDA allocator grow segments instead of fragmenting fixed ones. +# PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True + +# Loads sharded checkpoints on several threads (default true; false on macOS, +# where it segfaulted an SDNQ load on MPS). +# HF_ENABLE_PARALLEL_LOADING=true + +# MPS only: let PyTorch use all unified memory. +# PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0 + +# torch.compile's cache, and with it SDNQ's Triton autotune results - the +# first-step stall of a new size or frame count is paid once per box while +# this survives reboots (default $XDG_CACHE_HOME/dw/torchinductor, or +# ~/.cache/dw/torchinductor; PyTorch's own default under /tmp does not). +# TORCHINDUCTOR_CACHE_DIR=~/.cache/dw/torchinductor + +# --- Hugging Face Hub (all of diffusers and transformers download through it) -- + +# Token for gated models such as FLUX.1-dev (or run `huggingface-cli login` once). +# HF_TOKEN=hf_... + +# Where models are cached (default ~/.cache/huggingface). Point HF_HUB_CACHE +# at a bigger disk without moving the token and settings in HF_HOME. +# HF_HOME=~/.cache/huggingface +# HF_HUB_CACHE=/mnt/models/hub + +# Never touch the network: every model must already be cached. Also stops +# diffusers checking Hub kernel publishers (see DIFFUSERS_TRUST_REMOTE_KERNELS). +# HF_HUB_OFFLINE=1 + +# Faster large downloads through the Xet backend, at the cost of more CPU +# and memory while downloading. +# HF_XET_HIGH_PERFORMANCE=1 + +# Seconds before a slow download is abandoned (default 10). +# HF_HUB_DOWNLOAD_TIMEOUT=60 + +# HF_HUB_DISABLE_TELEMETRY=1 + +# --- diffusers ----------------------------------------------------------------- + +# Allow attention kernels from Hub publishers diffusers does not list as +# trusted. Needed for "attention_backend": "sage_hub" (SageAttention 2 from +# SageAttention/sage-attention) - about 30% faster per MiniMax-H3 step on an +# RTX 3090. It downloads and runs remote code from any such publisher, not +# only that one, so enable it deliberately. CUDA only; no use on MPS. +# DIFFUSERS_TRUST_REMOTE_KERNELS=true + +# The attention backend for every model that sets none (default native). A +# workflow's per-component "attention_backend" overrides it. Same values as +# that setting: native, flash_hub, sage_hub, _native_cudnn, ... +# DIFFUSERS_ATTN_BACKEND=native + +# Refuse any pipeline or component that would load custom code from the Hub. +# dw's own trust gate (--trust-workflows) already covers workflow files; this +# closes the same door at the diffusers layer. +# DIFFUSERS_DISABLE_REMOTE_CODE=true + +# --- transformers -------------------------------------------------------------- + +# Log level for transformers (debug, info, warning, error, critical). +# TRANSFORMERS_VERBOSITY=error + +# Silence the tokenizers fork-safety warning after a tokenizer has been used. +# TOKENIZERS_PARALLELISM=false + +# --- SDNQ quantization (CUDA) ----------------------------------------------------- + +# SDNQ's int matmuls run on autotuned Triton kernels by default. 0 falls back +# to PyTorch's kernels: no first-step autotune stall at a new shape, but +# slower steps. Measure before turning it off. +# SDNQ_USE_TRITON_MM=0 + +# Narrow the autotune search to shorten that stall, at the risk of a slower +# kernel. Comma lists; the defaults are shown. +# SDNQ_TRITON_MM_BLOCK_SIZE_M_LIST=64,128,256 +# SDNQ_TRITON_MM_BLOCK_SIZE_N_LIST=64,128,256 +# SDNQ_TRITON_MM_BLOCK_SIZE_K_LIST=32,64 diff --git a/.github/codeql/dw-security/DwPathSanitizers.qll b/.github/codeql/dw-security/DwPathSanitizers.qll index acd4648b..830454b9 100644 --- a/.github/codeql/dw-security/DwPathSanitizers.qll +++ b/.github/codeql/dw-security/DwPathSanitizers.qll @@ -127,14 +127,22 @@ private class UnconfinedPath extends Path::PathNormalization::Range { * own `os.path.realpath` / `os.path.exists` of the value it is in the * middle of checking. This is the "trusted rather than checked" half of * the trade described at the top of this file, and it is scoped to the - * definitions in `dw/security.py`. + * definitions in `dw/security.py`, plus `validate_media_path` in + * `dw/locations.py`: it confines against several roots at once (the + * workflow directory, the asset libraries, the output root - so even with + * no base directory it confines), a check the query cannot see, and its + * own existence test of the confined path was reported as an access. + * tests/test_locations.py is where that containment is established. */ private class ValidatorParameter extends PathInjection::Sanitizer { ValidatorParameter() { exists(Function validator | - pathValidatorName(validator.getName()) or nameValidatorName(validator.getName()) + (pathValidatorName(validator.getName()) or nameValidatorName(validator.getName())) and + validator.getLocation().getFile().getRelativePath() = "dw/security.py" + or + validator.getName() = "validate_media_path" and + validator.getLocation().getFile().getRelativePath() = "dw/locations.py" | - validator.getLocation().getFile().getRelativePath() = "dw/security.py" and this.asExpr() = validator.getArg(_) ) } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01717496..be275c89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: - name: Install torch (CPU) run: pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu - name: Install dependencies + id: install # requirements.txt resolves pyproject.toml's dependencies + server # extra; requirements-test.txt the dev extra. diffusers from git # goes on afterwards so the resolver doesn't replace it with a @@ -44,16 +45,23 @@ jobs: run: | pip install -c constraints-openapi.txt -r requirements.txt -r requirements-test.txt pip install git+https://github.com/huggingface/diffusers + # Each check below runs whenever the install succeeded, so one failing + # check never hides another: the ratchet was red for three days behind + # a failing test (#790) + # Guards the architecture metrics against drifting past the committed + # baseline (modules, size ceiling, cycles); see docs/stabilization/ + - name: Architecture ratchet + if: ${{ !cancelled() && steps.install.outcome == 'success' }} + run: python scripts/arch_metrics.py --check docs/stabilization/baseline.json - name: Format check + if: ${{ !cancelled() && steps.install.outcome == 'success' }} run: ruff format --check dw dw_mcp tests scripts - name: Lint + if: ${{ !cancelled() && steps.install.outcome == 'success' }} run: ruff check dw dw_mcp tests scripts - name: Tests + if: ${{ !cancelled() && steps.install.outcome == 'success' }} run: pytest -q - # Guards the architecture metrics against drifting past the committed - # baseline (modules, size ceiling, cycles); see docs/stabilization/ - - name: Architecture ratchet - run: python scripts/arch_metrics.py --check docs/stabilization/baseline.json ui: runs-on: ubuntu-latest diff --git a/docs/ACCELERATION.md b/docs/ACCELERATION.md index 82a3b220..8791f4d7 100644 --- a/docs/ACCELERATION.md +++ b/docs/ACCELERATION.md @@ -134,7 +134,7 @@ Select the attention implementation diffusers uses for the duration of each pipe } ``` -Common values: `"flash"`, `"flash_hub"`, `"sage"`, `"sage_hub"`, `"native"`, `"flex"`. The full set is diffusers' `AttentionBackendName` enum - availability depends on what's installed (`flash-attn`, `sageattention`, etc.) and the platform. `_hub`-suffixed backends are fetched from the Hugging Face Hub kernel registry on first use, which needs the `kernels` package installed (`pip install kernels`) - it is not a dw dependency, and no bundled workflow sets a backend, so each runs on a plain install. +Common values: `"flash"`, `"flash_hub"`, `"sage"`, `"sage_hub"`, `"native"`, `"flex"`. The full set is diffusers' `AttentionBackendName` enum - availability depends on what's installed (`flash-attn`, `sageattention`, etc.) and the platform. `_hub`-suffixed backends are fetched from the Hugging Face Hub kernel registry on first use, which needs the `kernels` package installed (`pip install kernels`) - it is not a dw dependency, and no bundled workflow sets a backend, so each runs on a plain install. diffusers also refuses a Hub kernel whose publisher it does not list as trusted - `sage_hub` among them, since SageAttention 2 comes from `SageAttention/sage-attention` - until `DIFFUSERS_TRUST_REMOTE_KERNELS=true`, which allows remote code from any such publisher. On an RTX 3090 `sage_hub` measured about 30% faster per MiniMax-H3 step (13.8 s against 19.2 s at 960x544x124, 54.7 s against 81.2 s at 345 frames); the local `sage` backend needs `sageattention>=2.1.1`, which PyPI does not carry. A component can also pin its backend persistently instead, via `set_attention_backend`: @@ -186,7 +186,7 @@ Compile a component once it is fully configured - the graph captures final dtype Typical gains are 1.3-1.5x on diffusion transformers, and compilation stacks with the caches above. Notes: -- **First run pays the compile cost.** The [server](SERVER.md)'s persistent worker keeps compiled pipelines loaded between runs, so the cost is paid once per session rather than once per generation. +- **First run pays the compile cost.** The [server](SERVER.md)'s persistent worker keeps compiled pipelines loaded between runs, so the cost is paid once per session rather than once per generation. A new session still retraces, but the kernels it built come back from the on-disk cache, which dw keeps across reboots (`TORCHINDUCTOR_CACHE_DIR`, see [Environment Defaults](#environment-defaults)). - **Pin the attention backend** on a compiled component (`"attention_backend"` in the same `components` entry) rather than using the pipeline-level per-call context manager, which forces recompiles. - **Composes with offloading**: apply `group_offload` and `compile` on the same component and the offload hooks are installed first, as required. Skipped with a warning on MPS. - **Don't combine `fullgraph` with a `cache`**: the cache hooks decide skip-or-compute per step, a data-dependent branch diffusers wraps in `torch.compiler.disable` - it needs the graph break that `fullgraph: true` forbids. Compile with the default (partial) graph mode when a cache is active. @@ -272,13 +272,14 @@ Device-level settings, read once at startup from `~/.diffusers_helper/settings.j ## Environment Defaults -Set automatically at import unless already present in the environment (export your own value to override): +Set automatically at import unless already present in the environment (export your own value to override, or put it in a `.env` file in the directory dw starts from - [.env.example](../.env.example) lists these and the other knobs worth knowing: the Hub, diffusers, transformers and SDNQ): | Variable | Default | Effect | | -------- | ------- | ------ | | `PYTORCH_CUDA_ALLOC_CONF` | `expandable_segments:True` | Lets the CUDA allocator grow segments instead of fragmenting fixed-size ones. Multi-step workflows churn differently-shaped allocations (generate, upscale, interpolate); fragmentation is what OOMs a card that nominally has room. | | `HF_ENABLE_PARALLEL_LOADING` | `true` | Loads sharded checkpoints in parallel - faster cold starts. | | `PYTORCH_MPS_HIGH_WATERMARK_RATIO` | `0.0` | MPS only - use all available unified memory. | +| `TORCHINDUCTOR_CACHE_DIR` | `$XDG_CACHE_HOME/dw/torchinductor` (`~/.cache/dw/torchinductor`) | Keeps `torch.compile`'s on-disk cache across reboots - Inductor's own default is under `/tmp`. A new worker still retraces, but a cache hit skips codegen and kernel builds, the bulk of a cold compile. Entries are per shape, so a new size or frame count compiles once more. | For faster model downloads, optionally `pip install hf_transfer` and set `HF_HUB_ENABLE_HF_TRANSFER=1`. Not enabled automatically - it bypasses the Python HTTP stack and breaks some proxy setups. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b8593b08..06e3bae0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,11 +31,12 @@ one to open. | LoRAs | `dw/pipeline_processors/adapters.py`: `active_loras`, `load_loras` | A `loras` entry whose `model_name` is null is switched off. | `tests/test_lora_disable.py::TestLoadLoras::test_an_all_null_entry_loads_nothing` | | Step progress | `dw/pipeline_processors/progress.py`: `reported_progress_bars` | A pipeline with no step callback reports its denoise steps through its progress bars. | `tests/test_modular_progress.py::test_every_advance_of_the_bar_is_reported` | | Community pipelines | `dw/community_pipelines/` (e.g. `pipeline_ltx2_refine.py`: `LTX2RefinePipeline`), `dw/introspection.py`: `community_pipelines`, `load_allowed_class` | A pipeline dw ships is named by its dotted path (`dw.community_pipelines..`), loads untrusted because it is under `dw`, and is listed by `list_pipelines` and resolved by `get_pipeline_signature` - only the modules in that directory, never a caller-chosen import. `LTX2RefinePipeline` VAE-encodes `video` at `width` x `height` and passes it unnormalized as `latents`, because `LTX2Pipeline.prepare_latents` normalizes 5-D latents itself. | `tests/test_ltx2_refine_pipeline.py::TestIntrospection::test_only_the_shipped_modules_resolve`, `tests/test_ltx2_refine_pipeline.py::TestEncode::test_the_latents_are_left_unnormalized_for_the_parent` | -| Adding a task | `dw/tasks/registry.py`: `register_command`, `RegistryTable` | A task is a function registered with `@register_command`, and the signature of its implementation is its argument schema. Its validate-time rules are declared on the same decorator - `domains`, `choices`, `static_check`, `whole_numbers` and `media_arguments` - and `TASK_ARGUMENT_DOMAINS`, `TASK_ARGUMENT_CHOICES`, `TASK_STATIC_CHECKS`, `TASK_WHOLE_NUMBER_ARGUMENTS` and `TASK_MEDIA_ARGUMENTS` are read-only views derived from them, so there is no second table to forget (#692). The registry lives in `dw/tasks/registry.py`; `dw/tasks/task.py` re-exports it, and a task module may register its own handler, as `dw/tasks/beats.py` does, provided `dw/tasks/task.py` imports it (`docs/TASKS.md` *Adding a task*). | `tests/test_task_discovery.py::TestDescribeTask::test_signature_becomes_the_schema`, `tests/test_task_registry_rules.py::test_every_derived_table_is_the_registrys_view`, `tests/test_task_registry_rules.py::test_every_static_check_is_registered_to_exactly_one_command`, `tests/test_task_registry_rules.py::test_every_media_argument_is_a_parameter_of_its_command` | -| Task argument domains | `dw/task_domains.py`: `check_arguments`, `task_argument_errors` | A numeric domain that a signature cannot express is declared on the command's `@register_command(domains=...)`, and it is checked at validation and again at run time. A numeric argument is read by `whole_number` or `real_number`, the only numeric coercion in `dw/tasks/`, and validation applies the same rule (`number_problem`), so a value is refused at both or at neither: `"3.0"` is a whole number, `"3.5"` for one of `whole_numbers` is not, and `true` is no number (#774). `Task.run` coerces every declared argument through `coerce_arguments` before dispatch, so a handler sees a number, and an argument that has no range besides being a number declares `finite`. A command's cross-argument rules are its registered `static_check`, which `task_argument_errors` runs at validation (`attribute_voices`' `voices_argument_errors`, kept in `dw/tasks/voice_attribution.py`, among them). A literal enum choice (`choice_errors`) and the `ingredients_grid` image count against `max_images` (`ingredients_grid_errors`) are refused at validation by the same module. So are `crop_face_track`'s cross-argument rules (`face_track_problems`, `face_track_errors`: crop size a multiple of the step's `multiple` (32 by default), `remainder` below `modulus`, `gate_zero` above `gate_full`, padding and confidence limits), which the task checks again at run time, as `analyze_beats`' are too (`beats_problems`, `beats_errors`: `min_bpm` below `max_bpm`, and anchors that are one kind per list, ascending, with whole beat indexes; `tests/test_analyze_beats.py`), and `plan_cuts`' (`cuts_problems`, `cuts_errors`: a bare-string transcript (`transcript_problem`, which `plan_cuts` also calls at run time), `min_scene_s` above `max_scene_s`, `segment_by: "beat"` without beats), and `select`'s index (`select_index_problem`, `select_rule_problems`: a whole number, at least 0, and below the candidate count when the count is known, i.e. at run time or when `candidates` is a written list), and `apply_lut`'s (`lut_source_problem`, `palette_problem`, `lut_errors`: exactly one of `lut` or `palette`, and a palette of 2..16 `#rrggbb` colours, re-checked at run time by `check_lut_source`), and `check_face_detector_source` vets its Hub repo and `.onnx` file before any download. | `tests/test_task_domains.py::TestTheStaticPass::test_a_negative_frame_count_is_refused_at_its_path`, `tests/test_numeric_coercion.py::TestGradeExposure::test_a_string_number_grades_like_the_number`, `tests/test_task_domains.py::TestAtRunTime::test_slice_audio_refuses_a_negative_count`, `tests/test_ingredients_grid.py::TestStaticValidation::test_bad_layout_and_fit`, `tests/test_face_track.py::TestStaticValidation::test_bad_literal_is_an_error_at_its_argument`, `tests/test_face_track.py::TestCropFaceTrack::test_bad_arguments_are_refused_at_run_time`, `tests/test_face_track.py::TestStaticValidation::test_a_declared_grid_is_accepted`, `tests/test_face_track.py::TestDetectorSource::test_traversing_repo_is_refused`, `tests/test_select_validation.py::TestSelectErrors::test_an_out_of_range_literal_index_names_both_ends`, `tests/test_lut.py::TestPaletteAtValidate::test_both_refuse_naming_the_two`, `tests/test_lut.py::TestPalette::test_a_bad_palette_refuses_naming_the_entry` | +| Adding a task | `dw/tasks/registry.py`: `register_command`, `RegistryTable` | A task is a function registered with `@register_command`, and the signature of its implementation is its argument schema. Its validate-time rules are declared on the same decorator - `domains`, `choices`, `static_check`, `whole_numbers` and `media_arguments` - and `TASK_ARGUMENT_DOMAINS`, `TASK_ARGUMENT_CHOICES`, `TASK_STATIC_CHECKS`, `TASK_WHOLE_NUMBER_ARGUMENTS` and `TASK_MEDIA_ARGUMENTS` are read-only views derived from them, so there is no second table to forget (#692). The registry lives in `dw/tasks/registry.py`; `dw/tasks/task.py` re-exports it, and a task module may register its own handler, as `dw/tasks/beats.py` does, provided `dw/tasks/task.py` imports it (`docs/TASKS.md` *Adding a task*). The built-in handlers are grouped by family the same way - `dw/tasks/audio_handlers.py`, `dw/tasks/video_handlers.py`, `dw/tasks/finish_handlers.py` and `dw/tasks/model_handlers.py` (#790) - and `dw/tasks/task.py` keeps the general-purpose commands, the processor dispatch and `Task`. | `tests/test_task_discovery.py::TestDescribeTask::test_signature_becomes_the_schema`, `tests/test_task_registry_rules.py::test_every_derived_table_is_the_registrys_view`, `tests/test_task_registry_rules.py::test_every_static_check_is_registered_to_exactly_one_command`, `tests/test_task_registry_rules.py::test_every_media_argument_is_a_parameter_of_its_command` | +| Task argument domains | `dw/task_domains.py`: `check_arguments`, `task_argument_errors`; `dw/task_problems.py`: `face_track_errors`, `beats_errors`, `cuts_errors`, `lut_errors` | A numeric domain that a signature cannot express is declared on the command's `@register_command(domains=...)`, and it is checked at validation and again at run time. A numeric argument is read by `whole_number` or `real_number`, the only numeric coercion in `dw/tasks/`, and validation applies the same rule (`number_problem`), so a value is refused at both or at neither: `"3.0"` is a whole number, `"3.5"` for one of `whole_numbers` is not, and `true` is no number (#774). `Task.run` coerces every declared argument through `coerce_arguments` before dispatch, so a handler sees a number, and an argument that has no range besides being a number declares `finite`. A command's cross-argument rules are its registered `static_check`, which `task_argument_errors` runs at validation (`attribute_voices`' `voices_argument_errors`, kept in `dw/tasks/voice_attribution.py`, among them). A literal enum choice (`choice_errors`) is refused at validation by the same module. The per-family rules past a single domain live in `dw/task_problems.py`, which reads `task_domains`' helpers and is never imported by it (`task_argument_errors` reaches them only through the registered `static_check`, so `import_cycles` stays 0; #790): the `ingredients_grid` image count against `max_images` (`ingredients_grid_errors`), and `crop_face_track`'s cross-argument rules (`face_track_problems`, `face_track_errors`: crop size a multiple of the step's `multiple` (32 by default), `remainder` below `modulus`, `gate_zero` above `gate_full`, padding and confidence limits), which the task checks again at run time, as `analyze_beats`' are too (`beats_problems`, `beats_errors`: `min_bpm` below `max_bpm`, and anchors that are one kind per list, ascending, with whole beat indexes; `tests/test_analyze_beats.py`), and `plan_cuts`' (`cuts_problems`, `cuts_errors`: a bare-string transcript (`transcript_problem`, which `plan_cuts` also calls at run time), `min_scene_s` above `max_scene_s`, `segment_by: "beat"` without beats), and `apply_lut`'s (`lut_source_problem`, `palette_problem`, `lut_errors`: exactly one of `lut` or `palette`, and a palette of 2..16 `#rrggbb` colours, re-checked at run time by `check_lut_source`), and `check_face_detector_source` vets its Hub repo and `.onnx` file before any download. `select`'s index stays in `task_domains` with the other media-timing rules (`select_index_problem`, `select_rule_problems`: a whole number, at least 0, and below the candidate count when the count is known, i.e. at run time or when `candidates` is a written list). | `tests/test_task_domains.py::TestTheStaticPass::test_a_negative_frame_count_is_refused_at_its_path`, `tests/test_numeric_coercion.py::TestGradeExposure::test_a_string_number_grades_like_the_number`, `tests/test_task_domains.py::TestAtRunTime::test_slice_audio_refuses_a_negative_count`, `tests/test_ingredients_grid.py::TestStaticValidation::test_bad_layout_and_fit`, `tests/test_face_track.py::TestStaticValidation::test_bad_literal_is_an_error_at_its_argument`, `tests/test_face_track.py::TestCropFaceTrack::test_bad_arguments_are_refused_at_run_time`, `tests/test_face_track.py::TestStaticValidation::test_a_declared_grid_is_accepted`, `tests/test_face_track.py::TestDetectorSource::test_traversing_repo_is_refused`, `tests/test_select_validation.py::TestSelectErrors::test_an_out_of_range_literal_index_names_both_ends`, `tests/test_lut.py::TestPaletteAtValidate::test_both_refuse_naming_the_two`, `tests/test_lut.py::TestPalette::test_a_bad_palette_refuses_naming_the_entry` | | Variables | `dw/variables.py`: `set_variables`, `argument_errors`, `resolve_variable_values` | `argument_errors` folds a caller's arguments in exactly as `set_variables` does when a run starts, so a bad name or value is refused before anything is queued. | `tests/test_variables.py::test_argument_errors_reports_a_dict_passed_for_a_string_variable` | | List variables from the CLI | `dw/run.py`, `dw/variables.py`: `get_value` | A `name=value` string given for a list variable is split on commas, so a list of objects can only be passed as JSON over the API or MCP. | `tests/test_variables.py::test_set_variables_list_default_splits_on_comma` | | `for_each` expansion | `dw/for_each.py`: `expand_for_each` | Each entry becomes an ordinary step named `@`, after substitution and before the reference check. | `tests/test_for_each.py::TestNaming::test_member_name_joins_with_at` | +| Optional entry field | `dw/for_each.py`: `_optional_item`, `_item_fields` | `{"item": "field", "default": X}` reads an entry field with a fallback; without `default` it is the plain required read, and `list_fields` / `entry_field_warnings` count the field as one entries take. The editor's string scan needs no twin code, only the shared case file. | `tests/test_for_each.py::TestOptionalItem::test_a_present_field_wins_and_an_absent_one_takes_the_default`, `tests/test_ui_twins.py::test_the_engine_decides_each_shared_reference_case` | | Previous results | `dw/previous_results.py`: `get_iterations`, `previous_result_reference_errors` | Several `previous_result:` references in one step multiply into a cartesian product. | `tests/test_previous_results.py::TestGetIterations::test_multiple_references_create_cartesian_product` | | Previous-result check | `dw/previous_results.py`: `previous_result_reference_errors` | A literal reference to a step that is not earlier in the workflow is a validation error. | `tests/test_previous_results.py::TestStaticReferenceChecking::test_a_step_cannot_reference_itself_or_a_later_one` | | Type conversion | `dw/arguments.py`: `realize_args`, `dw/type_helpers.py`: `get_type` | A key ending `_type` or `_dtype` loads a Python object at realize time. The other spellings, and `{}` escaping, are in `docs/WORKFLOW_GUIDE.md`. | `tests/test_type_helpers.py::TestGetType::test_get_type_from_diffusers`, `tests/test_arguments.py::TestRealizeArgs::test_realize_escaped_type_reference` | @@ -76,6 +77,7 @@ one to open. | Fit to a model and restore | `dw/tasks/fit.py`: `fit_to_model`, `restore_to_source` | fit_to_model's record is the whole of what restore_to_source needs: the scale is inferred from the output's size and must be uniform, and restore returns the source's size times that scale and at most its frame count. | `tests/test_fit_to_model.py` | | Shared image ops | `dw/tasks/image_ops.py`: `LUMA_WEIGHTS`, `luma`, `split_alpha`, `join_alpha`, `per_frame` | `grade`, `finish` and `lut` take luma (float64 Rec. 709 weights, cast to the input's dtype) and alpha handling from `image_ops`, never from each other, so an RGBA input comes back RGBA with its alpha untouched; `lut.LUMA_WEIGHTS` is a re-export. An image command runs over a video or frame list only through `per_frame`. A media file is read by `task._load_media` with `fetch_image(keep_alpha=True)`, so a file with transparency arrives RGBA; a pipeline input still flattens to RGB. | `tests/test_image_ops.py::TestLuma::test_lut_re_exports_the_one_float64_weights`, `tests/test_image_ops.py::TestAlpha::test_rgba_round_trip_keeps_the_alpha_bytes`, `tests/test_image_ops.py::TestOutputsAreByteIdentical::test_every_command_matches_the_pre_refactor_bytes`, `tests/test_image_ops.py::TestPerFrame::test_a_video_comes_back_as_a_video_with_the_same_frames_and_audio`, `tests/test_finish.py::TestRgbaFileKeepsAlpha::test_a_file_path_comes_back_rgba_with_its_alpha` | | Audio level of a deliverable | `dw/audio_qc.py`: `warn_without_headroom`, `warn_if_written_above_full_scale` | A muxed video keeps the pre-encode headroom prediction and falls back to it only when the post-write probe cannot measure the file. A result a later step resets the level of (`consumed_by_normalizer`, `dw/step_cache.py`) draws neither the post-write warning nor the fallback. | `tests/test_result.py::TestNoHeadroom::test_a_clean_video_mux_drops_the_stale_prediction`, `tests/test_result.py::TestNoHeadroom::test_an_unprobeable_video_mux_falls_back_to_the_prediction`, `tests/test_result.py::TestNoHeadroom::test_a_video_a_join_level_matches_draws_no_headroom_warning`, `tests/test_result.py::TestTheWrittenLevel::test_a_file_that_decodes_above_full_scale_warns` | +| Level-spread warning on a replaced track | `dw/step_cache.py`: `audio_replaced_downstream`, `dw/tasks/joins.py`: `warn_on_level_spread` | A join whose track a later `pair_audio` takes as its `video` draws no `level_spread` warning; being that step's `audio` argument doesn't count. | `tests/test_step_cache.py::test_level_spread_warning_suppressed_when_audio_replaced`, `tests/test_step_cache.py::test_audio_replaced_downstream_false_when_it_is_the_audio_or_unread` | | Template audio-level placement | `workflows/templates/minimax/music-video.json`, `workflows/templates/minimax/music.json` | `normalize_audio` (-3 dBFS) acts only on the track that goes into the final file, so the slices that condition the shots are untouched. `music-video` slices each shot `lead_frames` early and `trim_video` drops the run-up before the join. | — | ## Validation, plan and cost @@ -94,9 +96,10 @@ one to open. | Raw workflow GET | `dw/server/routes/library.py`: `get_workflow` | The raw workflow GET is served verbatim, with no `observed`, because the editor saves what it reads. | — | | VRAM projection | `dw/vram_estimate.py`, `dw/guides.py`: `guide_lengths` | A template's `vram_estimate` is projected per step after `for_each` expansion, and only the largest step over the ceiling is reported. With `bytes_per_guide_voxel` declared, each guide the step lays in (its own non-null `guides` plus a guide chain's) adds its snapped frames times the canvas; an unprobeable clip is charged at `num_frames`, and validate, admission and run project the same number. | `tests/test_vram_estimate.py::test_only_the_largest_over_ceiling_step_is_reported_once_per_cost_entry`, `tests/test_vram_guides.py::test_validate_apply_and_required_agree_on_a_guided_definition` | | H3 Ref2VA VRAM numbers | `workflows/templates/minimax/` | Every Ref2VA template declares `base_gb` 16.0, `bytes_per_voxel` 28.71 and `gb_per_reference` 1.0. | `tests/test_h3_vram_ceiling.py::test_every_ref2va_minimax_template_declares_gb_per_reference` | -| Inherited VRAM ceiling | `dw/vram_inheritance.py`, `dw/server/deps.py`: `ceiling_index` | A workflow with no `vram_estimate` is matched against the catalog by pipeline identity, and is warned, never refused. | `tests/test_vram_inheritance.py::test_every_template_declaring_one_identity_declares_the_same_numbers` | +| Inherited VRAM ceiling | `dw/vram_inheritance.py`, `dw/server/deps.py`: `ceiling_index` (the catalog parse is `_catalog_listing`, shared with the cost lookup below) | A workflow with no `vram_estimate` is matched against the catalog by pipeline identity, and is warned, never refused. | `tests/test_vram_inheritance.py::test_every_template_declaring_one_identity_declares_the_same_numbers` | +| Inherited cost | `dw/server/deps.py`: `inherited_observed`, `dw/vram_inheritance.py`: `inherited_differences`, `dw/plan.py`: `_observed` (basis `inherited`), `dw/server/routes/jobs.py`: `_inherited_cost_warnings` | An inline workflow (no catalog name) is priced from the observed runs of the catalog template with the same `component_type` + `model_name` + `workflow` key as `vram_inheritance`; the template with the most cold runs wins, then name. `differs` lists offload, quantization and frame count set differently from the template. It is warned, never refused. | `tests/test_inherited_cost.py::TestPricedByPipelineIdentity::test_an_inline_copy_inherits_the_templates_runs`, `tests/test_inherited_cost.py::TestDifferences::test_offload_quantization_and_frames_are_named`, `tests/test_inherited_cost.py::TestThePlanReportsIt::test_the_estimate_is_inherited_and_the_validate_answer_warns` | | H3 adapter partition | `dw/adapter_compatibility.py` | An FL2VA LoRA on a reference step is refused, and a LoRA whose file name says neither `ref2v` nor `fl2v` is warned. | `tests/test_h3_adapters.py::TestWhatIsRefused::test_a_keyframe_adapter_on_the_reference_path`, `tests/test_h3_adapters.py::TestWhatIsRefused::test_an_unrecognised_name_is_a_warning_not_a_refusal` | -| H3 audio hold, refine and guide layout | Three modules, imported one way: `dw/pipeline_processors/h3_rules.py` holds the names and rules, torch- and diffusers-free (`refine_problems`, the per-argument refine rules asked before validation and before the call; `not_h3`, the step rule; `snap_guide_length`, `guide_frame_problem`, `guide_chain_problems`, `guide_end_problem`; `CHAIN_CONTINUITY_MODES`; the chunk constants), `dw/pipeline_processors/h3_hold.py` the hold and refine blocks (`insert_audio_hold`, `core_denoise_sequences`, `refines`, `encode_audio_span`), and `dw/pipeline_processors/h3_guides.py` the guide layout (`LAYOUT_ANCHORS`, `splice_guide_rows`, `insert_guides`), which imports `h3_hold` and never the reverse (`_with_guides` in `dw/pipeline_processors/pipeline.py` adds its blocks for a `guides` call), `dw/guides.py`: the `guides` validation check and the chain `continuity: "guide"` check, `guide_chain_errors`, which asks the `guides` check's own step rule (`_takes_guides_problem`, built on `h3_rules.not_h3` and `REFERENCE_WORKFLOWS`) and reads the mode names from `h3_rules.CHAIN_CONTINUITY_MODES`, the names `chain.py`'s `CONTINUITY_MODES` is zipped against (both registered in `dw/validation.py`; a `previous_result:` guide is checked at run time), `dw/hold_audio.py`: `hold_audio_errors`, `refine_strength_errors` | The one place dw modifies a diffusers modular pipeline's block graph: three blocks go in, before `set_timesteps`, `denoise` and `after_denoise`, in every H3 core-denoise sequence (the whole graph and the `workflow=`-pruned flat graph), and they are no-ops without `hold_audio` / `refine_strength`. A `hold_audio` path is opened only after `locations.validate_media_path` against the step's workflow directory. `dw/introspection.py` builds the same graph without weights, hold included, so `get_pipeline_signature` lists a modular pipeline's block inputs. The copies of diffusers behaviour are pinned to the installed diffusers: `shifted_sigma_grid` (the spacing `refine_sigmas` rests on) to stock `set_timesteps`, `GUIDE_FRAMES_PER_CHUNK`/`GUIDE_LATENTS_PER_CHUNK` to the H3 video VAE's `clip_length` and latents per clip, and every name in `LAYOUT_ANCHORS` (`_fill_audio_positions` among them) to `before_denoise`. | `tests/test_h3_hold_audio.py::TestAnchors::test_inserted_next_to_the_anchors_in_every_shape`, `tests/test_h3_hold_audio.py::TestHoldAudioLocation::test_a_path_outside_the_roots_is_refused_at_the_call`, `tests/test_h3_hold_audio.py::TestRefusals::test_a_non_h3_step_is_refused`, `tests/test_h3_hold_audio.py::TestSignature::test_the_h3_signature_shows_hold_audio`, `tests/test_h3_refine.py::TestAnchors::test_inserted_before_denoise_in_every_shape`, `tests/test_h3_refine.py::TestSigmaGridDrift::test_the_full_grid_is_stock_set_timesteps`, `tests/test_h3_guides.py::TestChunkDrift::test_frames_per_chunk_is_the_vae_clip_length`, `tests/test_h3_guides.py::TestChunkDrift::test_latents_per_chunk_is_the_vae_tokens_per_clip`, `tests/test_h3_guides.py::TestAnchors::test_installed_diffusers_has_every_anchor`, `tests/test_guide_chain_validation.py::TestRefused::test_the_step_rule_is_the_guides_rule`, `tests/test_guide_chain_validation.py::TestRefused::test_every_run_mode_is_known_to_validate`, `tests/test_h3_rules.py::test_h3_rules_imports_neither_torch_nor_diffusers` | +| H3 audio hold, refine and guide layout | Five modules, imported one way: `dw/pipeline_processors/h3_rules.py` holds the names and rules, torch- and diffusers-free (`refine_problems`, the per-argument refine rules asked before validation and before the call; `not_h3`, the step rule; `snap_guide_length`, `guide_frame_problem`, `guide_chain_problems`, `guide_end_problem`; `CHAIN_CONTINUITY_MODES`; the chunk constants), `dw/pipeline_processors/h3_hold.py` puts the hold and refine blocks in (`insert_audio_hold`, `core_denoise_sequences`, `refines`), and `dw/pipeline_processors/h3_guides.py` the guide layout (`LAYOUT_ANCHORS`, `insert_guides`), which imports `h3_hold` and never the reverse; the blocks themselves, and the arithmetic they run, are `dw/pipeline_processors/h3_hold_steps.py` (`encode_audio_span`, `refine_sigmas`) and `dw/pipeline_processors/h3_guide_steps.py` (`splice_guide_rows`), which import diffusers when they load, so the two owners import them only inside `blocks()` and `guide_blocks()` (#790), and `h3_guide_steps` imports `h3_hold_steps` and never the reverse (`_with_guides` in `dw/pipeline_processors/pipeline.py` adds its blocks for a `guides` call), `dw/guides.py`: the `guides` validation check and the chain `continuity: "guide"` check, `guide_chain_errors`, which asks the `guides` check's own step rule (`_takes_guides_problem`, built on `h3_rules.not_h3` and `REFERENCE_WORKFLOWS`) and reads the mode names from `h3_rules.CHAIN_CONTINUITY_MODES`, the names `chain.py`'s `CONTINUITY_MODES` is zipped against (both registered in `dw/validation.py`; a `previous_result:` guide is checked at run time), `dw/hold_audio.py`: `hold_audio_errors`, `refine_strength_errors` | The one place dw modifies a diffusers modular pipeline's block graph: three blocks go in, before `set_timesteps`, `denoise` and `after_denoise`, in every H3 core-denoise sequence (the whole graph and the `workflow=`-pruned flat graph), and they are no-ops without `hold_audio` / `refine_strength`. A `hold_audio` path is opened only after `locations.validate_media_path` against the step's workflow directory. `dw/introspection.py` builds the same graph without weights, hold included, so `get_pipeline_signature` lists a modular pipeline's block inputs. The copies of diffusers behaviour are pinned to the installed diffusers: `shifted_sigma_grid` (the spacing `refine_sigmas` rests on) to stock `set_timesteps`, `GUIDE_FRAMES_PER_CHUNK`/`GUIDE_LATENTS_PER_CHUNK` to the H3 video VAE's `clip_length` and latents per clip, and every name in `LAYOUT_ANCHORS` (`_fill_audio_positions` among them) to `before_denoise`. | `tests/test_h3_hold_audio.py::TestAnchors::test_inserted_next_to_the_anchors_in_every_shape`, `tests/test_h3_hold_audio.py::TestHoldAudioLocation::test_a_path_outside_the_roots_is_refused_at_the_call`, `tests/test_h3_hold_audio.py::TestRefusals::test_a_non_h3_step_is_refused`, `tests/test_h3_hold_audio.py::TestSignature::test_the_h3_signature_shows_hold_audio`, `tests/test_h3_refine.py::TestAnchors::test_inserted_before_denoise_in_every_shape`, `tests/test_h3_refine.py::TestSigmaGridDrift::test_the_full_grid_is_stock_set_timesteps`, `tests/test_h3_guides.py::TestChunkDrift::test_frames_per_chunk_is_the_vae_clip_length`, `tests/test_h3_guides.py::TestChunkDrift::test_latents_per_chunk_is_the_vae_tokens_per_clip`, `tests/test_h3_guides.py::TestAnchors::test_installed_diffusers_has_every_anchor`, `tests/test_guide_chain_validation.py::TestRefused::test_the_step_rule_is_the_guides_rule`, `tests/test_guide_chain_validation.py::TestRefused::test_every_run_mode_is_known_to_validate`, `tests/test_h3_rules.py::test_h3_rules_imports_neither_torch_nor_diffusers`, `tests/test_h3_rules.py::test_the_block_owners_import_no_diffusers` | | IC-LoRA reference scale | `workflows/templates/ltx2/` | The three conditioning templates run at `reference_downscale_factor: 1`, and the two upscalers at 2. | `tests/test_ltx2_ic_loras.py::TestEachTemplateMatchesItsCard::test_the_reference_is_encoded_at_the_output_resolution`, `tests/test_ltx2_ic_loras.py::TestTheGenerativeUpscaleMatchesTheSameCard::test_it_loads_the_upscaler_at_factor_two` | | IC-LoRA numbers | `workflows/templates/ltx2/` | Every number in the IC-LoRA templates comes from the vendor's model card. | `tests/test_ltx2_ic_loras.py::TestEachTemplateMatchesItsCard::test_the_strength_is_the_cards_default`, `tests/test_ltx2_ic_loras.py::TestEachTemplateMatchesItsCard::test_the_defaults_are_the_trained_bucket` | | IC-LoRA prompt genre | `prompts/ltx2/` | A stored prompt tagged `ic-lora` is checked against its trained caption form, not against the T2V paragraph rule. | `tests/test_ltx_prompt_library.py::test_an_ic_lora_prompt_is_in_its_trained_form` | @@ -122,7 +125,8 @@ one to open. | Opening media | `dw/media.py` | This is the only module that calls `av.open`. It imports no torch and no step types. | `tests/test_media_layering.py::test_av_open_appears_only_in_media`, `tests/test_media_layering.py::test_media_imports_no_step_types` (no torch: —) | | Signal processing | `dw/dsp.py` | It measures and transforms waveforms, decides nothing, and imports nothing from `dw`. | `tests/test_media_layering.py::test_dsp_imports_nothing_from_dw` | | Assessment probes | `dw/tasks/assess.py`, `dw/assessment_rules.py` | Exactly three registered commands are assessment probes. Each measures a finished file, and every rule names a real probe field. | `tests/test_assessment_rules.py::TestRuleProbesAreRealCommands::test_assessment_is_exactly_the_three_probes`, `tests/test_assessment_rules.py::TestRuleProbesAreRealCommands::test_every_rule_names_a_registered_json_command` | -| Script check | `dw/tasks/script_check.py`: `check`; `dw/script_lines.py`: `parse_lines`, `parse_shots`, `shot_names_error` | `check_script` aligns a take's heard words to its expected lines and guards out words heard over silence and repetition loops (Whisper's hallucination over music). It builds its findings with `assessment_rules.finding()` and reads the guard's floor and window (`DEAD_AIR_FLOOR_DBFS`, `DEAD_AIR_WINDOW`) rather than copying them. It is not an assessment probe and adds nothing to `RULES`. Its literal `lines` is refused at validation through `task_domains` (`script_lines_errors`), which calls the same `parse_lines` the task runs, and a literal `shots` through the same `parse_shots` and `shot_names_error`; the parsers live in `dw/script_lines.py`, not the task, so that `task_domains` reaches them without importing `script_check` (the `import_cycles` ratchet in `scripts/arch_metrics.py`). Its shot map comes from `assess.resolve_shots`, the one the probes use, never a copy; it places each shot on the soundtrack with `assess.sample_span`, decides repeated shot names with `shots.duplicate_shot_names`, and asks `locations.is_http_url` whether the take is a URL. | `tests/test_script_check.py::TestRegistration::test_registered_as_json_command_without_assessment_flag`, `tests/test_script_check.py::TestEmptyLines::test_words_only_over_silence_are_discarded_not_found`, `tests/test_script_check.py::TestLinesErrors::test_literal_with_a_colon_is_not_a_reference` | +| Script check | `dw/tasks/script_check.py`: `check`; `dw/script_lines.py`: `parse_lines`, `parse_shots`, `shot_names_error` | `check_script` aligns a take's heard words to its expected lines and guards out words heard over silence and repetition loops (Whisper's hallucination over music). It builds its findings with `assessment_rules.finding()` and reads the guard's floor and window (`DEAD_AIR_FLOOR_DBFS`, `DEAD_AIR_WINDOW`) rather than copying them. It is not an assessment probe and adds nothing to `RULES`. Its literal `lines` is refused at validation through `task_problems` (`script_lines_errors`), which calls the same `parse_lines` the task runs, and a literal `shots` through the same `parse_shots` and `shot_names_error`; the parsers live in `dw/script_lines.py`, not the task, so that `task_problems` reaches them without importing `script_check` (the `import_cycles` ratchet in `scripts/arch_metrics.py`). Its shot map comes from `assess.resolve_shots`, the one the probes use, never a copy; it places each shot on the soundtrack with `assess.sample_span`, decides repeated shot names with `shots.duplicate_shot_names`, and asks `locations.is_http_url` whether the take is a URL. | `tests/test_script_check.py::TestRegistration::test_registered_as_json_command_without_assessment_flag`, `tests/test_script_check.py::TestEmptyLines::test_words_only_over_silence_are_discarded_not_found`, `tests/test_script_check.py::TestLinesErrors::test_literal_with_a_colon_is_not_a_reference` | +| Audio sync against a source slice | `dw/tasks/measure_sync.py`: `measure_sync`; `dw/dsp.py`: `envelope_lag` | `measure_sync` cross-correlates the onset envelopes of a shot's audio and its source slice and answers the lag and a 0-1 confidence. A positive lag means the audio is LATE (its events fall after the reference's), negative early; `lag_seconds` is None for a silent or flat track. It builds its findings (`sync_unmeasurable`, `sync_low_confidence`, `audio_out_of_sync`) with `assessment_rules.finding()` over module-local rule dicts and adds nothing to `RULES`; it reads the silence floor from `beats.SILENT_DBFS`. `envelope_lag` imports nothing from `dw`. | `tests/test_measure_sync.py::TestLag::test_late_audio_is_a_positive_lag`, `tests/test_measure_sync.py::TestLag::test_early_audio_is_a_negative_lag`, `tests/test_measure_sync.py::TestUnmeasurable::test_a_silent_reference_has_no_lag` | ## Security and architecture guardrails @@ -133,7 +137,7 @@ one to open. | Locations from a workflow | `dw/locations.py` | A local path in a workflow's media arguments (named like their media, or declared by the task's `@register_command(media_arguments=...)`, read through `TASK_MEDIA_ARGUMENTS`) is confined to a known root, a location written as a URL is one `security.validate_url` accepts, and a URL must not name a host inside the deployment. A host that does not resolve is refused. The requests themselves are `dw/outbound.py`'s. | `tests/test_locations.py::TestMediaPathContainment::test_absolute_path_outside_every_root_is_refused`, `tests/test_locations.py::TestMediaPathContainment::test_a_url_that_is_not_http_is_refused`, `tests/test_locations.py::TestMediaPathContainment::test_an_upper_case_https_url_is_not_refused_as_another_scheme`, `tests/test_security_ssrf.py::TestInternalAddressSpellings::test_a_name_that_resolves_inside`, `tests/test_locations.py::TestTaskMediaArguments::test_an_unreadable_source_is_an_error_at_its_path` | | Outbound requests | `dw/outbound.py`: `safe_get`, `safe_post` | Every outbound request is validated per hop against `dw/locations.py`'s policy, resolved once and dialed at the address the policy checked, capped and timed. No other module under `dw/` makes an HTTP request or builds a session or adapter. | `tests/test_outbound.py::TestSafeRequest::test_one_lookup_per_hop_and_the_dial_is_the_checked_address` | | Trust gate | `dw/trust.py`: `require_trusted_dotted_name` | In an untrusted workflow (the default), a dotted type outside the allowlist is a validation error before anything is imported. | `tests/test_security_trust_gate.py::TestValidationRefusesBeforeImport::test_a_dotted_type_is_a_validation_error` | -| CodeQL path-injection model | `.github/codeql/dw-security/DwPathSanitizers.qll`, `.github/workflows/codeql.yml` | The local pack models the `dw/security.py` validators as sanitizers, so a validator that moves is re-modelled in the same commit. | `.github/codeql/dw-security/DwPathInjection.ql` | +| CodeQL path-injection model | `.github/codeql/dw-security/DwPathSanitizers.qll`, `.github/workflows/codeql.yml` | The local pack models the `dw/security.py` validators (and `dw/locations.py`'s `validate_media_path`) as sanitizers, so a validator that moves is re-modelled in the same commit. | `.github/codeql/dw-security/DwPathInjection.ql` | | Archives never follow links | `dw/server/outputs.py`: `zip_download` | A gallery listing drops a symlink that leaves its root, and an archive skips one. | `tests/test_security_symlinks.py::TestOutputs::test_the_archive_route_does_not_follow_the_link`, `tests/test_security_symlinks.py::TestOutputs::test_the_gallery_listing_does_not_enumerate_the_link` | | Engine/server import direction | `dw/workspace.py`: `EXPORTS_SUBDIR` | No engine module imports `dw.server` except the entry point `dw/serve.py`, and a constant both sides need lives engine-side. | `docs/stabilization/baseline.json`: `import_cycles` ratchet (indirect: it fails only on an import that closes a cycle) | | Module and function size | `scripts/arch_metrics.py`: `SIZE_WARNING`, `SIZE_CEILING` | A module stays at or under 1,100 lines, and it warns above 1,000. A function stays at or under 150 lines. | `docs/stabilization/baseline.json`: `modules_over_size_ceiling`, `functions_over_150_lines` | diff --git a/docs/MCP.md b/docs/MCP.md index f1aa1945..6126927e 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -279,7 +279,7 @@ tools) rather than relying on the session pin. | `use_workspace(name)` | `name` | Work in that workspace for the rest of the session - every later call reads and writes there. This is how to keep your work out of another agent's namespace rather than sharing the default one. Checked against the server, so a typo fails here rather than scoping every later call to nothing. On a `dw.serve --mcp` endpoint the pin is shared by every connected client (#298, see the note above the table) - a `warning` field appears when this call overwrote a pin already pointed away from `default` | | `create_workspace(name, use=False)` | `name`, `use` | Create a workspace. Pass use=true to switch this session to it as well; otherwise the session stays where it was and the result says so. The result names the workspace (`name`, `default`, `current`, `next`), not its folders - `list_workspaces(detail=true)` is the opt-in for those. `use=true` carries the same mounted-server caveat and `warning` field as `use_workspace` | | `delete_workspace(name, acknowledged_cost=False)` | `name`, `acknowledged_cost` | Permanently delete a workspace and everything in it. Refuses without the acknowledgement, reporting what it would remove | -| `list_assets()` | — | The input media on the server, each with the `asset:` reference a workflow argument carries. Look here before asking for a file - what a workflow needs may already be there. `libraries` names the roots searched and which are writable; `shadowed` lists names a nearer library hides | +| `list_assets(limit=50, prefix)` | `limit`, `prefix` | The input media on the server, each with the `asset:` reference a workflow argument carries. Look here before asking for a file - what a workflow needs may already be there. `libraries` names the roots searched and which are writable; `shadowed` lists names a nearer library hides. Newest first; `prefix` keeps names starting with it (e.g. `cast/`), `limit` bounds the answer, `total` is the count matched before the cut | | `keep_output(name, asset_name=None, overwrite=False, shared=False, workspace=None)` | `name`, optional `asset_name`, `overwrite`, `shared`, `workspace` | Keep a generated file as an input asset under a stable `asset:` name, so a later workflow can rely on it. The copy happens on the server: nothing is downloaded or re-uploaded. `asset_name` may name a folder and takes the kept file's extension when it has none; `shared=true` keeps it in the library every workspace shares, which is where a recurring cast belongs. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it | | `upload_asset(file_path=None, content=None, asset_name=None, shared=False)` | exactly one of `file_path` or `content` (base64), optional `asset_name`, `shared` | Push an image, video or audio file, or a `.cube` 3D LUT for `apply_lut`, into the server's asset library and get back its `asset:` reference. `file_path` is read from the machine the MCP server runs on, so this is how an input reaches a dw.serve running somewhere else - but only when the caller shares a filesystem with that machine. `content` is the alternative for an agent that does not: the bytes travel inline, base64-encoded, in the tool call itself, capped at 4MB (well under `file_path`'s 200MB) because inline bytes compete with the calling agent's own context budget rather than being a bulk-transfer path (#203). `asset_name` is required with `content` (there is no file name to infer one from) and, either way, stores it under a readable name (`cast/priya-voice.wav`) instead of a random one; `shared=true` puts it in the library every workspace shares | | `delete_asset(name)` | `name` | Permanently remove one file from the asset library, by the name `list_assets` reports. Deletes from whichever library holds it - this workspace's own before the shared one; one from a read-only examples library is refused. Any workflow still carrying that `asset:` reference stops loading | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 6105d4ea..a6d47862 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -7,6 +7,82 @@ notes from commits at tag time (see below). This section is a scratch pad for items a branch's author wants the next release note to name; clear it when a release ships. +### 0.11.0 + + + +A short range. MiniMax H3's keyframe templates move to a 4-step turbo +LoRA that halves a long take's render time, music videos hold each shot's +audio slice by default and can give each entry its own singer, cost +estimates reach inline workflows, and `torch.compile`'s kernel cache now +survives a reboot. Under the hood, the #790 refactor brought every module +and function back under the architecture ratchet. + +**MiniMax H3** (plugins/dw/skills/minimax-h3, workflows/templates) + +- The nine T2VA/FL2VA templates default to lightx2v's 4-step v1.2 turbo + (`num_inference_steps` 5, shift 6/3) at 960x544 and 1344x768: on a + 345-frame take, ~7.3 min against 14.6 min for the 8-step file it + replaces. Ref2VA templates stay on 8 steps; there is no tested 4-step + Ref2VA file. + +**Music video and cuts** (docs/TASKS.md) + +- `music-video` holds each shot's audio slice by default; measured 0.00 s + sync offset on 8 of 8 shots against up to 1.37 s with the reference + alone (#808). `hold_audio` is declared (null) on it and the + `match_audio` chains (#795). +- `for_each` items take an optional field with a default, so a + music-video entry may carry its own singer (#801, #809). +- `plan_cuts` divides a split sung scene's lyric across its pieces by + aligned word times (#805). +- New `measure_sync` task cross-correlates a shot's audio against its + source slice (#802); new `relevel-clip` utility template, named by the + clip warning (#794). +- `assess_output` runs the whole-file level rules (`full_scale`, + `near_silent`) (#807); no `level_spread` warning when a later + `pair_audio` replaces the track (#800). + +**Planning and validation** + +- An inline workflow is priced by pipeline identity, inheriting the + catalog basis and warning where offload, quantization or frames differ + (#797); the field-shift check covers derived and per-entry estimates + (#796). +- Validation refuses an `image`/`last_image` argument fed + `previous_result:` of an earlier video step (#813). + +**MCP and server** + +- `list_assets` takes `limit` (newest first), `prefix`, and reports + `total` (#798). +- `save_prompt` accepts a plain string (#806); `wait_for_job` falls back + cleanly on a client timeout and a running job reports `finished_steps` + (#792, #799). + +**Acceleration** (docs/ACCELERATION.md) + +- `TORCHINDUCTOR_CACHE_DIR` defaults to `~/.cache/dw/torchinductor`, so + compiled kernels survive a reboot instead of living in `/tmp`. +- `.env.example` documents the environment knobs dw reads; copy it to `.env` + (loaded at import, before torch) and restart the server after editing. + +**Web UI** + +- Per-card VRAM bars in the header, status popover and server page. +- Step and job durations on the job page, the log and the jobs list. +- The sidebar marks the open workspace; each workspace has its own hue. + +**Maintenance** + +- #790: `task.py` split by family, the H3 block classes moved to module + level, task argument rules moved to `dw/task_problems.py`, and one + `iter_steps` walk for every validation pass. CI runs the ratchet first + so a test failure can no longer hide it. +- UI dependencies: vitest 5, monaco-editor 0.57; the lockfile update + clears the `source-map-js` and `postcss-selector-parser` Dependabot + alerts. + ### 0.10.0 diff --git a/docs/SERVER.md b/docs/SERVER.md index 90c83c8f..b6e52603 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -402,7 +402,13 @@ The editor's forms come from these; they are just as usable from scripts: changed - an estimate, not a measurement), `other_device` (no entry for the serving backend; the first entry's figure, which is a warning rather than a quote) or `unknown` (no cost block, or more than one list changed - so there is nothing honest to extrapolate along); a composed child's + so there is nothing honest to extrapolate along) or `inherited` (an + inline workflow has no catalog name, so its price is this server's + `observed` runs of the catalog template that loads the same pipeline - + `component_type` + `model_name` + `workflow`, as the VRAM ceiling is + inherited - with `inherited_from` naming it and `differs` listing what the + workflow sets differently among offload, quantization and frame count; + validate warns when it does, and never refuses); a composed child's cost is added to a curated figure and `partial` is true when a child has none - an `observed` figure already measured the whole run, children included, so nothing is added to it and `partial` is false. `plan` is `null` when diff --git a/docs/TASKS.md b/docs/TASKS.md index c1911661..c88c724c 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -62,7 +62,8 @@ decorator, so a new task cannot be half-registered (#692): ```python from .registry import register_command -from ..task_domains import NON_NEGATIVE, POSITIVE, my_task_errors +from ..task_domains import NON_NEGATIVE, POSITIVE +from ..task_problems import my_task_errors @register_command( @@ -82,7 +83,8 @@ def my_task(source, num_frames, start_frame=0, mode="fast"): ... - `choices` - each argument's literal choices; `get_task` lists them. - `static_check` - a function of the step's `arguments` that returns `(argument, message)` pairs for rules across arguments. It lives in - `dw/task_domains.py`, named `*_errors`, and is registered to exactly one + `dw/task_problems.py` (or, for the media-timing rules - window, slice, fit - + `dw/task_domains.py`), named `*_errors`, and is registered to exactly one command - or, when it needs the task's own parser, in the task's module behind an import on use, as `attribute_voices`' does. - `whole_numbers` - the numeric arguments that take only a whole number; the @@ -1858,6 +1860,36 @@ scale as `rms_dbfs` (their powers sum to it), so the loudest band sits near `threshold_dbfs`. A silent track, or a band with no content at the track's sample rate, reads as `null` rather than `-inf`. +### measure_sync + +Measure how far a shot's audio sits from the source slice it should match. `analyze_sync_drift` counts samples against frames, so it cannot see audio whose samples line up but whose content is shifted - a lip-sync model that re-sings the reference late. This cross-correlates the onset envelopes of the two. One call measures one shot; the result is JSON and nothing is built: + +```json +{ + "name": "sync", + "task": { + "command": "measure_sync", + "arguments": { + "audio": "previous_result:shot", + "reference": "asset:song-slice.wav", + "max_lag_seconds": 3 + } + }, + "result": { "content_type": "application/json" } +} +``` + +| Argument | Required | Description | +| -------- | -------- | ----------- | +| `audio` | Yes | The shot's audio: a path, `asset:`/`output:` reference of an audio or video file, a waveform, or an earlier step's generated audio or video | +| `reference` | Yes | The slice of the source it should match, in the same forms. A shot with no slice has nothing to measure and is refused | +| `max_lag_seconds` | No | The largest lag searched, either way (default `2`). A lag at this edge may be larger still, and warns | +| `threshold_seconds` | No | A lag longer than this, either way, is an `audio_out_of_sync` finding (default `0.1`) | +| `min_confidence` | No | A correlation under this is a `sync_low_confidence` finding and the lag is not to be trusted (default `0.3`) | +| `sample_rate` | With a waveform | Rate of a directly passed waveform (files carry their own) | + +Returns `{lag_seconds, confidence, max_lag_seconds, audio_seconds, reference_seconds, findings, warnings}`. **`lag_seconds` is positive when the audio is late** - its events fall that many seconds after the reference's - and negative when early. `confidence` is the normalised correlation at that lag, 0 to 1. A silent or flat audio or reference gives `lag_seconds: null` and a `sync_unmeasurable` finding. Each finding is `{rule, severity, at, value, threshold, says}`. It is not part of `assess_output`. + ### analyze_beats Find where a song's beats fall, to cut picture to them - a Music 3 song @@ -2013,11 +2045,14 @@ How the plan is made: last. - A shot over `max_scene_s` splits evenly, each cut on its nearest beat when there are beats, and a sung shot split this way is warned about by its - lyric, which every piece carries; one under `min_scene_s` merges into its + lyric. Given `lyrics`, each piece holds only the words heard inside it + (the lyric's words aligned to the transcript's); a piece where no word is + heard has no lyric and is warned about. When no word of the lyrics was + heard, or with no `lyrics`, every piece carries the whole lyric. One under `min_scene_s` merges into its shorter neighbour. A shot still outside the range is warned about by name. - A shot whose render would pass `max_frames` is split, on a beat when - there is one, and warned about. + there is one, and warned about; its lyric is divided as above. - `snap_to_beats` moves every cut to its nearest beat; with no beats it warns and leaves the cuts where the lines put them. - Every boundary is rounded once, from its absolute time, so the frame counts @@ -2895,7 +2930,7 @@ The result needs no `sample_rate`. A generated track carries the rate its model ### Generating a voice to condition on -The role this earns its place in is voice *timbre reference*, not the track a mouth follows. A `MiniMaxH3AudioReference` is audio H3 reads, not audio it plays: it takes a few seconds of a voice to fix timbre, pitch and delivery while H3 still generates the line, and a mouth asked to follow a whole track through a reference follows it loosely. That reference is still the better lip sync: `music-video` and the `match_audio` chain templates pass each shot's slice as one. Holding the track with `hold_audio` (the `workflows` guide, "H3: generating to a held soundtrack") keeps it exact, but on a sung track the mouth followed it less well than the reference (#619's A/B, measured in that section), so hold is opt-in. Build the reference with `from_previous_result` and the clip's own sample rate comes across with it: +The role this earns its place in is voice *timbre reference*, not the track a mouth follows. A `MiniMaxH3AudioReference` is audio H3 reads, not audio it plays: it takes a few seconds of a voice to fix timbre, pitch and delivery while H3 still generates the line, and a mouth asked to follow a whole track through a reference follows it loosely. `music-video` and the `match_audio` chain templates pass each shot's slice as one, and a reference alone can re-sing the track shifted by whole beats (#795). Holding the track with `hold_audio` (the `workflows` guide, "H3: generating to a held soundtrack") keeps it exact: `music-video` holds each slice by default, the chains leave it null. #619 found hold with image-only references worse than the reference alone on a sung track; #808 measured hold plus the reference at 0.00 s offset on every shot against up to 1.37 s for the reference alone, audio only, mouth onsets not counted. Build the reference with `from_previous_result` and the clip's own sample rate comes across with it: ```json "references": [ @@ -3225,6 +3260,7 @@ Canny edge detection followed by ControlNet generation: - [upscale-spandrel.json](../workflows/templates/upscale-spandrel.json) — Spandrel upscale of an existing image - [upscale-diffusion.json](../workflows/templates/upscale-diffusion.json) — Diffusion upscale of an existing image - [audio-trim-fade.json](../workflows/templates/audio-trim-fade.json) — Trim a generated track and fade its tail +- [relevel-clip.json](../workflows/templates/relevel-clip.json) — Fix a clipping soundtrack on a rendered clip without re-rendering - [generate-speech.json](../workflows/templates/generate-speech.json) — Speak a line with a local text-to-speech model - [voice-timbre-reference.json](../workflows/templates/minimax/voice-timbre-reference.json) — Generate a voice and condition H3's `