From 1f3c8f1e187c25ffa749116c40c824d7af3c325d Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Thu, 17 Sep 2026 10:46:27 -0500 Subject: [PATCH 01/77] fix(tasks): #185 #187 - stop advertising device on non-consuming tasks; add gain_audio #185: describe_task() lists device on every task's schema since it is a universal, always-safe-to-pass argument, but a non-model command forwarded its arguments straight to an implementation whose signature never took one, so passing device raised a TypeError (e.g. resample_audio). register_command now takes consumes_device (default False); a non-consuming handler pops device from arguments before dispatch, while the 8 model-backed handlers that call task.device_for(arguments) themselves are marked consumes_device=True and left unwrapped. The pop only fires when arguments is a dict, since a command like gather_inputs receives a plain list. #187: adds gain_audio (dw/tasks/audio_utils.py), registered in task.py and declared in task_domains.py, to apply a gain to a time- or frame-aligned region of a track in one step instead of the five-step slice_audio -> gain -> mix_audio -> rejoin -> pair_audio chain. Reuses slice_audio's dual seconds/frames addressing and carries the same sample_rate reinterpretation caveat (#180). Unlike slice_audio, a region reaching past the end of the track is clipped rather than zero-padded, since there is no real material there to gain. Documented in docs/TASKS.md. Full suite: 4945 passed, 16 skipped. Model sonnet via provider anthropic. Co-Authored-By: Claude Sonnet 5 --- docs/TASKS.md | 41 +++++++++++++++- dw/task_domains.py | 8 ++++ dw/tasks/audio_utils.py | 104 ++++++++++++++++++++++++++++++++++++++++ dw/tasks/task.py | 59 +++++++++++++++++++---- 4 files changed, 203 insertions(+), 9 deletions(-) diff --git a/docs/TASKS.md b/docs/TASKS.md index cc3c3e0b..64b63f04 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -15,7 +15,9 @@ Tasks are utility operations that run outside of pipeline inference. Use them fo Any task that runs a model accepts a `"device"` argument to pin where it runs - useful for keeping a helper model (a captioner, an upscaler) off the accelerator a -loaded pipeline is using, or on a second one. +loaded pipeline is using, or on a second one. `"device"` is listed on every task's +schema, since it is always safe to pass: a task that runs no model - `slice_audio`, +`compose_text`, and the like - just ignores it. Task argument schemas are discoverable: `GET /api/tasks/{command}` on the [server](SERVER.md) returns each command's arguments read from its registered @@ -482,6 +484,43 @@ a length still passes the whole track along: | `start_frame` / `num_frames` / `fps` | One pair | The slice in video frames; `fps` is required, start and count may be omitted | | `sample_rate` | With a waveform | Sample rate of a directly passed waveform (files carry their own) | +### gain_audio + +Apply a gain, in decibels, to a region of an audio track - the rest of the +track passes through unchanged. The region is addressed the same way +`slice_audio`'s is, in seconds or in video frames, so ducking a scene under +another (lowering a dialogue track between two timestamps) is one step +instead of the `slice_audio` → `gain` (a whole-track `normalize_audio` on the +slice) → `mix_audio` → `rejoin` → `pair_audio` chain that used to be the only +way to gain part of a track rather than all of it. Unlike `slice_audio`, a +region reaching past the end of the track is clipped to it rather than +zero-padded - there is no silence there to gain, only the end of the real +material: + +```json +{ + "task": { + "command": "gain_audio", + "arguments": { + "audio": "./dialogue.wav", + "gain_db": -12, + "start_frame": 124, + "num_frames": 48, + "fps": 24 + } + }, + "result": { "content_type": "audio/wav", "sample_rate": 44100 } +} +``` + +| Argument | Required | Description | +| -------- | -------- | ----------- | +| `audio` | Yes | Path or URL of an audio file (or of a video file, whose soundtrack is taken), a waveform from a previous step, or an earlier step's video generated with a soundtrack (which brings its sample rate along) | +| `gain_db` | Yes | Gain to apply within the region, in decibels - negative ducks it, positive boosts it | +| `start_seconds` / `duration_seconds` | One pair | The region in seconds; either may be omitted | +| `start_frame` / `num_frames` / `fps` | One pair | The region in video frames; `fps` is required, start and count may be omitted | +| `sample_rate` | With a waveform | Sample rate of a directly passed waveform (files carry their own) | + ### crossfade_audio Join audio tracks with an equal-power crossfade. Each seam overlaps the two diff --git a/dw/task_domains.py b/dw/task_domains.py index c1cb7ef3..9bbd36e9 100644 --- a/dw/task_domains.py +++ b/dw/task_domains.py @@ -56,6 +56,14 @@ "fps": POSITIVE, "sample_rate": POSITIVE, }, + "gain_audio": { + "start_seconds": NON_NEGATIVE, + "duration_seconds": POSITIVE, + "start_frame": NON_NEGATIVE, + "num_frames": POSITIVE, + "fps": POSITIVE, + "sample_rate": POSITIVE, + }, "resample_audio": { "target_sample_rate": POSITIVE, "sample_rate": POSITIVE, diff --git a/dw/tasks/audio_utils.py b/dw/tasks/audio_utils.py index e246a4d2..d15c8d41 100644 --- a/dw/tasks/audio_utils.py +++ b/dw/tasks/audio_utils.py @@ -376,6 +376,110 @@ def slice_audio( return _as_track(slice_samples(waveform, start, length), sample_rate, "slice_audio") +def gain_audio( + audio, + gain_db, + start_seconds=None, + duration_seconds=None, + start_frame=None, + num_frames=None, + fps=None, + sample_rate=None, +): + """Task command: apply a gain to a region of an audio track. + + The region is addressed the same way slice_audio's is - either in + seconds (start_seconds + duration_seconds) or in video frames + (start_frame + num_frames + fps). Everything outside the region is + passed through unchanged, so ducking a scene under another is one step + rather than the slice/gain/mix/rejoin/pair_audio chain that was + previously the only way to apply a gain to part of a track rather than + all of it (#187). + + Unlike slice_audio, a region reaching past the end of the track is + clipped to it rather than zero-padded: there is no silence there to + gain, only the end of the real material. + + A file's or video's own sample rate is read automatically; sample_rate + is for a waveform passed directly, or to override what a file carries - + which relabels the waveform at that rate rather than resampling it, the + same caveat slice_audio's sample_rate carries (#180). + + Args: + audio: Path or URL of an audio file (or of a video file, whose + soundtrack is taken), a generated video carrying its own + soundtrack, or a waveform (which needs sample_rate alongside it) + gain_db: Gain to apply within the region, in decibels - negative + ducks it, positive boosts it + start_seconds: Start of the region, in seconds + duration_seconds: Length of the region, in seconds + start_frame: Start of the region, in video frames + num_frames: Length of the region, in video frames + fps: Frame rate used to convert start_frame/num_frames to samples + sample_rate: Sample rate of a waveform passed directly; given for a + file or a video it overrides the rate they carry + + Returns: + An AudioTrack holding the whole track with the region's gain + applied, and the rate it is at + """ + start_seconds = _as_number(start_seconds, float, "start_seconds", command="gain_audio") + duration_seconds = _as_number( + duration_seconds, float, "duration_seconds", command="gain_audio" + ) + start_frame = _as_number(start_frame, int, "start_frame", command="gain_audio") + num_frames = _as_number(num_frames, int, "num_frames", command="gain_audio") + fps = _as_number(fps, Fraction, "fps", command="gain_audio") + gain_db = _as_number(gain_db, float, "gain_db", command="gain_audio") + + check_arguments( + "gain_audio", + start_seconds=start_seconds, + duration_seconds=duration_seconds, + start_frame=start_frame, + num_frames=num_frames, + fps=fps, + sample_rate=sample_rate, + ) + + waveform, sample_rate = _waveform_and_rate(audio, sample_rate, "gain_audio") + total = waveform.shape[1] + + if start_seconds is not None or duration_seconds is not None: + start = int(round((start_seconds or 0) * sample_rate)) + length = ( + max(total - start, 0) + if duration_seconds is None + else int(round(duration_seconds * sample_rate)) + ) + elif start_frame is not None or num_frames is not None: + if fps is None: + raise ValueError("gain_audio needs 'fps' to address a region in frames") + start = frames_to_samples(start_frame or 0, fps, sample_rate) + length = ( + max(total - start, 0) + if num_frames is None + else frames_to_samples(num_frames, fps, sample_rate) + ) + else: + raise ValueError( + "gain_audio needs either 'start_seconds'/'duration_seconds' or " + "'start_frame'/'num_frames'/'fps' to address the region to gain" + ) + + region_start = max(0, min(start, total)) + region_end = max(region_start, min(start + max(length, 0), total)) + + gained = waveform.copy() + if region_end > region_start: + gain = 10 ** (gain_db / 20) + gained[:, region_start:region_end] = ( + gained[:, region_start:region_end] * gain + ).astype(waveform.dtype) + + return _as_track(gained, sample_rate, "gain_audio") + + def _warn_on_slice_past_end(total, start, length, sample_rate): """Say when a slice asked for more material than its source holds. diff --git a/dw/tasks/task.py b/dw/tasks/task.py index 0bf218de..f06f092f 100644 --- a/dw/tasks/task.py +++ b/dw/tasks/task.py @@ -32,7 +32,9 @@ _COMMAND_INFO: Dict[str, dict] = {} -def register_command(command_name: str, implementation=None, provided=()): +def register_command( + command_name: str, implementation=None, provided=(), consumes_device=False +): """ Decorator to register a command handler function. @@ -42,13 +44,31 @@ def register_command(command_name: str, implementation=None, provided=()): the command's arguments (None for a command that consumes a free-form dict) provided: Parameter names the dispatch supplies itself + consumes_device: True for a handler that calls task.device_for(arguments) + itself, to pick the accelerator a model-backed task runs on. False + (the default) is for a task that runs no model and forwards + arguments straight to its implementation - device is introspected + as a universal, always-safe-to-pass argument (dw/introspection.py), + so a non-consuming handler must drop it itself rather than let it + reach the implementation as an unexpected keyword argument (#185). + Some commands (e.g. gather_inputs) receive a non-dict argument + value, which never carries a device to drop Returns: Decorator function """ def decorator(func: Callable) -> Callable: - _COMMAND_REGISTRY[command_name] = func + if consumes_device: + handler = func + else: + + def handler(task, arguments, previous_pipelines, _func=func): + if isinstance(arguments, dict): + arguments.pop("device", None) + return _func(task, arguments, previous_pipelines) + + _COMMAND_REGISTRY[command_name] = handler _COMMAND_INFO[command_name] = { "kind": "command", "implementation": implementation, @@ -158,6 +178,15 @@ def _handle_slice_audio(task, arguments, previous_pipelines): return slice_audio(**arguments) +@register_command("gain_audio", implementation="dw.tasks.audio_utils.gain_audio") +def _handle_gain_audio(task, arguments, previous_pipelines): + """Apply a gain to a time- or frame-aligned region of an audio track""" + logger.debug("Gaining audio region") + from .audio_utils import gain_audio + + return gain_audio(**arguments) + + @register_command( "resample_audio", implementation="dw.tasks.audio_utils.resample_audio" ) @@ -282,7 +311,9 @@ def _per_frame(image, process): return AudioVideo(frames, audio, sample_rate, fps=getattr(image, "fps", None)) -@register_command("upscale", implementation="dw.tasks.upscale.upscale_image") +@register_command( + "upscale", implementation="dw.tasks.upscale.upscale_image", consumes_device=True +) def _handle_upscale(task, arguments, previous_pipelines): """Upscale an image using a spandrel-compatible super-resolution model""" logger.debug("Upscaling image") @@ -298,7 +329,9 @@ def _handle_upscale(task, arguments, previous_pipelines): @register_command( - "diffusion_upscale", implementation="dw.tasks.diffusion_upscale.diffusion_upscale" + "diffusion_upscale", + implementation="dw.tasks.diffusion_upscale.diffusion_upscale", + consumes_device=True, ) def _handle_diffusion_upscale(task, arguments, previous_pipelines): """Upscale an image using a diffusion-based upscale pipeline""" @@ -313,7 +346,9 @@ def _handle_diffusion_upscale(task, arguments, previous_pipelines): @register_command( - "restore_faces", implementation="dw.tasks.restore_faces.restore_faces" + "restore_faces", + implementation="dw.tasks.restore_faces.restore_faces", + consumes_device=True, ) def _handle_restore_faces(task, arguments, previous_pipelines): """Restore faces in an image using a spandrel-compatible face restoration model""" @@ -329,7 +364,9 @@ def _handle_restore_faces(task, arguments, previous_pipelines): ) -@register_command("segment", implementation="dw.tasks.segment.segment_image") +@register_command( + "segment", implementation="dw.tasks.segment.segment_image", consumes_device=True +) def _handle_segment(task, arguments, previous_pipelines): """Segment objects in an image using text prompt""" logger.debug("Segmenting image") @@ -346,6 +383,7 @@ def _handle_segment(task, arguments, previous_pipelines): @register_command( "interpolate_frames", implementation="dw.tasks.interpolate_frames.interpolate_frames", + consumes_device=True, ) def _handle_interpolate_frames(task, arguments, previous_pipelines): """Interpolate video frames to increase frame rate""" @@ -357,7 +395,9 @@ def _handle_interpolate_frames(task, arguments, previous_pipelines): @register_command( - "image_to_text", implementation="dw.tasks.image_to_text.image_to_text" + "image_to_text", + implementation="dw.tasks.image_to_text.image_to_text", + consumes_device=True, ) def _handle_image_to_text(task, arguments, previous_pipelines): """Generate text caption from an image""" @@ -369,7 +409,9 @@ def _handle_image_to_text(task, arguments, previous_pipelines): @register_command( - "text_generation", implementation="dw.tasks.text_generation.generate_text" + "text_generation", + implementation="dw.tasks.text_generation.generate_text", + consumes_device=True, ) def _handle_text_generation(task, arguments, previous_pipelines): """Generate text from a prompt using a local LLM""" @@ -383,6 +425,7 @@ def _handle_text_generation(task, arguments, previous_pipelines): @register_command( "generate_speech", implementation="dw.tasks.speech_generation.generate_speech", + consumes_device=True, ) def _handle_speech_generation(task, arguments, previous_pipelines): """Speak a line of text with a local text-to-speech model""" From 993fe44f8e50db12ccb1f43bf349fcad8bc1ce42 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Thu, 17 Sep 2026 10:59:56 -0500 Subject: [PATCH 02/77] fix(plan): #186 - surface gate status in downloads_required pre-flight Each downloads_required entry now carries gated (the hub's own field: false/"auto"/"manual", or null when the lookup itself failed) and access_blocked (true when this box's Hugging Face token specifically lacks access, from GatedRepoError - what would otherwise be a 403 partway into a run). validate_workflow's warnings gains one line per blocked entry, pointing at the repo's license page. Co-Authored-By: Claude Sonnet 5 --- docs/SERVER.md | 15 ++++++++-- dw/plan.py | 51 ++++++++++++++++++++++++++------ dw/server/app.py | 4 ++- tests/test_plan.py | 70 ++++++++++++++++++++++++++++++++++++++++---- tests/test_server.py | 32 +++++++++++++++++++- 5 files changed, 153 insertions(+), 19 deletions(-) diff --git a/docs/SERVER.md b/docs/SERVER.md index 099d4187..69e8810c 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -303,9 +303,18 @@ The editor's forms come from these; they are just as usable from scripts: also gets a warning saying so, since `0` alone does not distinguish a disabled cache from an empty one); `downloads_required`, each `model_name` the hub cache does not hold as - `{repo, gb}` (`gb` from the hub, `null` when it could not be asked - - `?sizes=false` skips the hub) and each `from_single_file` URL as - `{repo: null, url, gb: null}`; and `estimate`, `{minutes, basis, + `{repo, gb, gated, access_blocked}` (`gb` from the hub, `null` when it + could not be asked - `?sizes=false` skips the hub, and then `gated` and + `access_blocked` are `null` too); `gated` is the hub's own field for the + repo (`false`, `"auto"` or `"manual"`) or `null` when the lookup itself + failed for a reason other than the gate; `access_blocked` is `true` + when this box's Hugging Face token specifically has not been granted + access to a gated repo (`GatedRepoError` from the hub, the pre-flight + signal for what would otherwise be a 403 partway into a run, #186) - + `validate_workflow`'s `warnings` carries one line per such entry. Each + `from_single_file` URL is `{repo: null, url, gb: null, gated: null, + access_blocked: null}`, since a direct file URL is never gated; and + `estimate`, `{minutes, basis, device, measured_on, partial, runs}` from this box's own history when it has one and otherwise from the workflow's `cost` block - `basis` is `observed` (the cold median of this server's own finished runs diff --git a/dw/plan.py b/dw/plan.py index ae7b5473..3a8d1a6e 100644 --- a/dw/plan.py +++ b/dw/plan.py @@ -19,7 +19,7 @@ import os from huggingface_hub import model_info -from huggingface_hub.utils import HFValidationError, validate_repo_id +from huggingface_hub.utils import GatedRepoError, HFValidationError, validate_repo_id from .elision import elide_definition from .hub_cache import scan_models @@ -459,9 +459,14 @@ def downloads_required(expanded, base_dir, workflow_dir, cache_dir, lookup_sizes # body, and a free pre-flight must not be a directory-existence oracle if name in present or not _is_repo_id(name): continue - required.append({"repo": name, "gb": _size_gb(name) if lookup_sizes else None}) + entry = {"repo": name, "gb": None, "gated": None, "access_blocked": None} + if lookup_sizes: + entry["gb"], entry["gated"], entry["access_blocked"] = _model_info(name) + required.append(entry) for url in urls: - required.append({"repo": None, "url": url, "gb": None}) + required.append( + {"repo": None, "url": url, "gb": None, "gated": None, "access_blocked": None} + ) return required @@ -495,6 +500,20 @@ def _collect_sources(tree, names, urls): _collect_sources(value, names, urls) +def gate_warnings(downloads_required): + """One line per required download this box's token is blocked from - + the pre-flight signal #186 asked for, so a gated repo is a + `validate_workflow`-visible condition rather than a 403 the run only + discovers after it has loaded everything ahead of that step.""" + return [ + f"{entry['repo']} is gated and not accessible with this box's " + "Hugging Face token - accept its license at " + f"https://huggingface.co/{entry['repo']} before running this workflow" + for entry in downloads_required + if entry.get("access_blocked") + ] + + def _is_repo_id(name): try: validate_repo_id(name) @@ -513,13 +532,27 @@ def _is_url(value): return False -def _size_gb(name): - """A repo's size in GiB to one decimal, or None when the hub does not - say - unreachable, gated without a token, or a file with no size.""" +def _model_info(name): + """A repo's size in GiB, gate status, and whether this box's token is + blocked from it - a `(gb, gated, access_blocked)` triple. + + `gated` is `model_info`'s own field (`False` / `"auto"` / `"manual"`), + readable only when the call succeeds - which it does even for a gated + repo this token has been granted. A `GatedRepoError` is the call failing + for exactly that reason: the repo is gated and this box's token has not + (or no longer) been granted access, distinct from every other lookup + failure (unreachable, a file with no size), which says nothing about the + gate either way (#186). + """ try: info = model_info(name, files_metadata=True, timeout=SIZE_LOOKUP_TIMEOUT) - total = sum(s.size for s in (info.siblings or []) if getattr(s, "size", None)) + except GatedRepoError as e: + logger.debug(f"Gate not accepted for {name}: {e}") + return None, True, True except Exception as e: logger.debug(f"No size for {name}: {e}") - return None - return round(total / GIB, 1) if total else None + return None, None, None + total = sum(s.size for s in (info.siblings or []) if getattr(s, "size", None)) + gb = round(total / GIB, 1) if total else None + gated = getattr(info, "gated", False) or False + return gb, gated, False diff --git a/dw/server/app.py b/dw/server/app.py index 31f2406c..843d745d 100644 --- a/dw/server/app.py +++ b/dw/server/app.py @@ -73,7 +73,7 @@ from ..result import read_embedded_metadata from ..media_info import probe_media from ..hub_cache import scan_models, delete_model, DownloadManager -from ..plan import build_plan, unseeded_cache_warnings +from ..plan import build_plan, gate_warnings, unseeded_cache_warnings from ..runs import ( MANIFEST_FILE_NAME, REALIZED_FILE_NAME, @@ -1748,6 +1748,8 @@ def validate_workflow( except Exception: logger.exception("Plan could not be built") answer["plan"] = None + if answer["plan"]: + answer["warnings"] += gate_warnings(answer["plan"]["downloads_required"]) return answer # ------------------------------------------------------------ workspaces diff --git a/tests/test_plan.py b/tests/test_plan.py index cd9f929e..39078e26 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -6,7 +6,7 @@ import pytest -from dw.plan import build_plan, unseeded_cache_warnings +from dw.plan import build_plan, gate_warnings, unseeded_cache_warnings from dw.runs import new_run_id from dw.workflow import workflow_from_definition @@ -490,7 +490,9 @@ def test_a_child_measured_on_another_device_is_still_added(self, plan, tmp_path) class TestDownloadsRequired: def test_a_repo_not_in_the_cache_is_required(self, plan): - assert plan()["downloads_required"] == [{"repo": "org/still-model", "gb": None}] + assert plan()["downloads_required"] == [ + {"repo": "org/still-model", "gb": None, "gated": None, "access_blocked": None} + ] def test_a_cached_repo_is_not(self, plan, monkeypatch): import dw.plan @@ -544,7 +546,13 @@ def test_a_single_file_url_is_listed_without_a_size(self, plan): "from_single_file": "https://example.test/x.safetensors" } assert plan(spec)["downloads_required"] == [ - {"repo": None, "url": "https://example.test/x.safetensors", "gb": None} + { + "repo": None, + "url": "https://example.test/x.safetensors", + "gb": None, + "gated": None, + "access_blocked": None, + } ] def test_a_single_file_local_path_is_not_listed(self, plan): @@ -590,6 +598,7 @@ def __init__(self, size): class Info: siblings = [Sibling(2 * 1024**3), Sibling(None), Sibling(512 * 1024**2)] + gated = False calls = [] @@ -599,11 +608,40 @@ def fake_model_info(name, **kwargs): monkeypatch.setattr(dw.plan, "model_info", fake_model_info) answer = plan(lookup_sizes=True)["downloads_required"] - assert answer == [{"repo": "org/still-model", "gb": 2.5}] + assert answer == [ + {"repo": "org/still-model", "gb": 2.5, "gated": False, "access_blocked": False} + ] assert calls[0][0] == "org/still-model" assert calls[0][1]["files_metadata"] is True assert calls[0][1]["timeout"] == 5.0 + def test_a_gated_repo_this_token_may_access_is_not_blocked(self, plan, monkeypatch): + import dw.plan + + class Info: + siblings = [] + gated = "manual" + + monkeypatch.setattr(dw.plan, "model_info", lambda name, **k: Info()) + assert plan(lookup_sizes=True)["downloads_required"] == [ + {"repo": "org/still-model", "gb": None, "gated": "manual", "access_blocked": False} + ] + + def test_a_gated_repo_this_token_lacks_access_to_is_blocked(self, plan, monkeypatch): + import httpx + import dw.plan + from huggingface_hub.utils import GatedRepoError + + response = httpx.Response(403, request=httpx.Request("GET", "https://hf.co/x")) + + def boom(*a, **k): + raise GatedRepoError("no access", response=response) + + monkeypatch.setattr(dw.plan, "model_info", boom) + assert plan(lookup_sizes=True)["downloads_required"] == [ + {"repo": "org/still-model", "gb": None, "gated": True, "access_blocked": True} + ] + def test_a_hub_failure_is_a_null_size(self, plan, monkeypatch): import dw.plan @@ -612,7 +650,7 @@ def boom(*a, **k): monkeypatch.setattr(dw.plan, "model_info", boom) assert plan(lookup_sizes=True)["downloads_required"] == [ - {"repo": "org/still-model", "gb": None} + {"repo": "org/still-model", "gb": None, "gated": None, "access_blocked": None} ] def test_lookup_sizes_false_never_calls_the_hub(self, plan, monkeypatch): @@ -625,6 +663,28 @@ def boom(*a, **k): plan(lookup_sizes=False) +class TestGateWarnings: + def test_a_blocked_repo_gets_a_warning(self): + required = [ + {"repo": "org/free-model", "gb": 1.0, "gated": False, "access_blocked": False}, + {"repo": "org/gated-model", "gb": None, "gated": True, "access_blocked": True}, + ] + warnings = gate_warnings(required) + assert len(warnings) == 1 + assert "org/gated-model" in warnings[0] + assert "huggingface.co/org/gated-model" in warnings[0] + + def test_an_accepted_gate_gets_no_warning(self): + required = [ + {"repo": "org/manual-gate", "gb": 1.0, "gated": "manual", "access_blocked": False} + ] + assert gate_warnings(required) == [] + + def test_unknown_gate_status_gets_no_warning(self): + required = [{"repo": "org/offline", "gb": None, "gated": None, "access_blocked": None}] + assert gate_warnings(required) == [] + + class TestCachedSteps: def test_no_probe_is_unknown(self, plan): assert plan()["cached_steps"] is None diff --git a/tests/test_server.py b/tests/test_server.py index 516cde17..0908a66e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -3817,7 +3817,37 @@ def test_a_valid_answer_carries_a_plan(self, server, monkeypatch): assert plan["steps"] == 1 assert plan["estimate"]["basis"] in {"catalog", "other_device"} assert plan["estimate"]["minutes"] == 2.0 - assert plan["downloads_required"] == [{"repo": "m", "gb": None}] + assert plan["downloads_required"] == [ + {"repo": "m", "gb": None, "gated": None, "access_blocked": None} + ] + + def test_a_blocked_gate_surfaces_as_a_warning(self, server, monkeypatch): + """#186: a repo this box's token cannot access is a 403 partway into + a run unless the pre-flight says so first.""" + import httpx + import dw.plan + from huggingface_hub.utils import GatedRepoError + + monkeypatch.setattr( + dw.plan, "scan_models", lambda cache_dir=None: {"repos": []} + ) + + response = httpx.Response(403, request=httpx.Request("GET", "https://hf.co/x")) + + def boom(*a, **k): + raise GatedRepoError("no access", response=response) + + monkeypatch.setattr(dw.plan, "model_info", boom) + with server(success_script) as client: + result = client.post( + "/api/validate", + json={"workflow": video_workflow("gated", with_cost=True)}, + ).json() + assert result["plan"]["downloads_required"] == [ + {"repo": "m", "gb": None, "gated": True, "access_blocked": True} + ] + gate_warnings = [w for w in result["warnings"] if "huggingface.co/m" in w] + assert len(gate_warnings) == 1 def test_the_estimate_quotes_this_box_s_own_history(self, server, monkeypatch): """#154: `basis: unknown` has to mean nobody has a number. A stored From ea272ce44e7b55b95a455840d0481e70bc6c4fe5 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Thu, 17 Sep 2026 11:23:33 -0500 Subject: [PATCH 03/77] feat(tasks): #188 - add transcribe_audio ASR task Adds a Whisper-class automatic-speech-recognition task so a spoken-word defect in a TTS deliverable (dropped line, mid-sentence truncation) can be checked against the text it was supposed to speak, instead of only inferred from duration/timing arithmetic. dw/tasks/audio_transcription.py is a dedicated module, like text_generation.py, with `from transformers import pipeline as hf_pipeline` at module level rather than lazily inside the function - audio_utils.py's other tasks import transformers lazily per-task to avoid forcing that heavy import on every one of them, but a lazy import inside a closure is not interceptable by unittest.mock.patch("transformers.pipeline") in this environment (transformers' lazy-module __getattr__ machinery), which was causing the task's tests to download a real model. Moving it to its own module with a module-level import, matching text_generation.py's already-proven-testable pattern, fixes that. Default model is openai/whisper-base, ungated and already within the transformers dependency. Co-Authored-By: Claude Sonnet 5 --- docs/TASKS.md | 26 ++++++ dw/tasks/audio_transcription.py | 86 ++++++++++++++++++ dw/tasks/audio_utils.py | 2 + dw/tasks/task.py | 14 +++ tests/test_catalog_structure.py | 1 + tests/test_transcribe_audio.py | 106 ++++++++++++++++++++++ workflows/templates/transcribe-audio.json | 23 +++++ 7 files changed, 258 insertions(+) create mode 100644 dw/tasks/audio_transcription.py create mode 100644 tests/test_transcribe_audio.py create mode 100644 workflows/templates/transcribe-audio.json diff --git a/docs/TASKS.md b/docs/TASKS.md index 64b63f04..e1109fb0 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -1198,6 +1198,32 @@ A speech model is worth releasing before a video model loads — set `release_mo - [generate-speech.json](../workflows/templates/generate-speech.json) — Speak a line and save it as a `.wav` - [voice-timbre-reference.json](../workflows/templates/minimax/voice-timbre-reference.json) — Generate a voice, then condition H3's `