diffusers-workflow 0.8.0__py3-none-any.whl → 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffusers-workflow
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Declarative workflow engine and web UI for Hugging Face Diffusers
5
5
  Author: dkackman
6
6
  License: Apache-2.0
@@ -1,4 +1,4 @@
1
- diffusers_workflow-0.8.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
1
+ diffusers_workflow-0.9.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
2
2
  dw/__init__.py,sha256=fWaCvN4c5PtD97ZufrMz0okq33fxTWHmJp9UCuluouw,19183
3
3
  dw/adapter_compatibility.py,sha256=OAJdCDsiU29DX-vW5DIGN3kUcapbq3Bz6vU6GCgV_6w,11316
4
4
  dw/argument_media.py,sha256=yeXBDAoFnLhMKb-YFxtqmqgXhwqmj3Eh-sJblSxsa_4,12816
@@ -33,7 +33,7 @@ dw/media_types.py,sha256=to2bGLpfF__EJnXxis-t0SHlzgx_WzlK6t0roqLf24g,8045
33
33
  dw/output_extraction.py,sha256=Q4OB2Lnz8yF9sK6H2svBDNuf4G8shEuQRExEaVieUXU,9610
34
34
  dw/path_redaction.py,sha256=Zv8sGmZQ0meQYinMhqo10IJKt5egqkjekbiTSPoBVJg,2949
35
35
  dw/pipeline_ownership.py,sha256=sY90vQVLBXzAeyR6jVygGm4i_FqfSeGp7up_0SvNSYE,12944
36
- dw/plan.py,sha256=xIFilQAcNwqFQh6Dmgq6l5qns1dcBGtA8ruHErSNP5M,38998
36
+ dw/plan.py,sha256=7yif6_PKWYOHROLWSPtswoSnnN_-mwgoduZ4hB9_gXg,41470
37
37
  dw/previous_results.py,sha256=Ano0nl0714eEgJd76e4Xu-1EC3vvaE4MsdvHVTIBMoU,16313
38
38
  dw/probe_paths.py,sha256=iJgy4G256zELyzi4Aol4_qifejjZsUtdeEEDWryAB0o,2683
39
39
  dw/prompt_schema.json,sha256=5e7NWgce28cf-YWEWwyDjQQ90ki0BcI4tczTyr_d8lQ,1934
@@ -86,12 +86,12 @@ dw/docs/ARCHITECTURE.md,sha256=RFIW1eSQ6l25uA5QKJWDns1i8iJgGoqXxwq-PrHZzuw,35102
86
86
  dw/docs/DEPENDENCIES.md,sha256=avxinFm52qDToYi2_C0M39OTXNo1ERqxZj6t_0HIUjE,4142
87
87
  dw/docs/IP_ADAPTER.md,sha256=AAHaUIfbnOojHfT0noIeypbhdYPluR7sbZeVn5FpbjU,3730
88
88
  dw/docs/LORAS.md,sha256=lwug-7yQp7O1AHJfzv5jzvH8Vta4KMAt5G8m6khyMH8,6895
89
- dw/docs/MCP.md,sha256=7xnNsSBCK3ge6ceOVN6pgWyQHcdwSF2ei3A7V5XzTtQ,56009
89
+ dw/docs/MCP.md,sha256=L7vXp9j8VmzupKJlMkmUi7ZMlXR1QRHij9xJxil-URE,56114
90
90
  dw/docs/PROMPT_WEIGHTING.md,sha256=Pvr25d3yvS8SlnYwX9qc61VvTa8yFMxK_1zMLvrEigI,3031
91
91
  dw/docs/QUANTIZATION.md,sha256=NZCL-09bDM-JhzV1WqK2JGkL1la8VoH9fie1X39arsE,7678
92
92
  dw/docs/RECIPES_24GB.md,sha256=j7CCtMCNg830o6xD7C1DPz7dXRHvBQkbJaViRnH8AR4,16035
93
- dw/docs/RELEASING.md,sha256=Ft0BH0ZGpsyqBYd7Ev_jCK61fiWtvCfpZUokF1Y0tXY,38520
94
- dw/docs/REMOTE.md,sha256=1TVGxTbLjIg5RpkoxpASSncZW_E7_llDu6zPLarkDbg,6389
93
+ dw/docs/RELEASING.md,sha256=LUISTzZYjxcy1ND0MXE-1rYnGaEJ6Jei0DaSZf4-5DY,41460
94
+ dw/docs/REMOTE.md,sha256=quum2Y54g2bp2rgXg9WkSPY3zhVGHEpMkEAiL6oAQDU,6650
95
95
  dw/docs/SECURITY.md,sha256=HYkBnAGlX83moyCryHylYdyWFodyZzzF7J38elebYGA,17900
96
96
  dw/docs/SECURITY_QUICKREF.md,sha256=-xZNIV6j1oWcoy5B-oHKqh3ko2zJgB2fuNMiDV4f1zY,3537
97
97
  dw/docs/SERVER.md,sha256=XAMoVdlCAjTR0AMtupWv5nuDUGClEz31YBfaOObSBlc,52418
@@ -134,7 +134,7 @@ dw/server/routes/__init__.py,sha256=Z6hnzGs1jdXuY_1C74t09_e1QDYWOxWbCnyjo6_qKB0,
134
134
  dw/server/routes/assets.py,sha256=D3VSCSo_xKJeBZ-5ZMWWRtMlr1db7iiDPDFIUAPg8_I,17517
135
135
  dw/server/routes/files.py,sha256=6c7ySIPe7o8HSc8Z7M2b1fKWonQdkC8S_TPAVppCxOo,7125
136
136
  dw/server/routes/gallery.py,sha256=pwE1qsizXpqL-kMieM6UbXAgAHOpdQ1V3MR53510PsU,15107
137
- dw/server/routes/jobs.py,sha256=gb1bUHepdDxMzJaFYnI7a5dUy0ZFR0WLhzaQuUcUtps,34308
137
+ dw/server/routes/jobs.py,sha256=lrtGkk7_CzgVsmO0MhXWQAleOm0--LwKqahG5WBHNo4,34451
138
138
  dw/server/routes/library.py,sha256=GiUFqYuin8nHx1pU28gJsr8m6YvgBNAWoUZjUbT9PYM,29738
139
139
  dw/server/routes/loras.py,sha256=OVxlXutJs1ThSsTC3R31pg44sUaV9iH8PhY0Xy9UyVQ,9289
140
140
  dw/server/routes/media.py,sha256=vRhH52iZ8BwmPeNtN0wS_zM6A3qj92p2V8E2DwIifN8,26005
@@ -259,7 +259,7 @@ dw/tasks/image_to_text.py,sha256=iNza67G5h24y6GIs22mCyLOFgqsAOfz9hf88jBn16iY,161
259
259
  dw/tasks/image_utils.py,sha256=4mAHIob7Yh8JEdekI5X5yI_dJSKia1PTrRDGjljAOfc,25397
260
260
  dw/tasks/interpolate_frames.py,sha256=Jxp0GcETheu1SBwAP6QrCCRKRs9XfoF5SHzwfUkIwCk,9272
261
261
  dw/tasks/join_into_song.py,sha256=54nr2-Q5MOUV4rTtBeWyJV-oK1misJ86LV2eRRV_BTo,17825
262
- dw/tasks/joins.py,sha256=5Mc6DFItML_asJQexSmcGalL66bPb4qIvcqtVIivFSw,25386
262
+ dw/tasks/joins.py,sha256=BbzoTOfc6WJDXHWxdm0dVjA-awa634U3SpQT83RoNDc,25861
263
263
  dw/tasks/judge.py,sha256=fdT6a20x66tCCP37d2NnVFrZAPiuhduuBvg_bsAdtSg,2312
264
264
  dw/tasks/loop_bed.py,sha256=JQ-Zx8qpNiobBtdmRPU4nwYiLJyrje6hAsT8QauXkz0,35563
265
265
  dw/tasks/model_cache.py,sha256=Bh97x5mFFFzT8M8_LiHo0a1izsaIqvsQyIURPkjKxTk,1879
@@ -289,7 +289,7 @@ dw_mcp/catalog.py,sha256=RMPqJ_Vtgoc3wFlRp0xd4GedbQVICaI0rkXwiqbvzus,15219
289
289
  dw_mcp/client.py,sha256=YhIJY6Nlf2B1MbBfdcMZySsQ5PFvdEVF41dFL8MRhm4,22166
290
290
  dw_mcp/confine.py,sha256=bAitZXmyoNMFkmzNa7WwzYNgKhVLT5nUsMOAGe5t-bI,2766
291
291
  dw_mcp/diagnose.py,sha256=V9-8gNf1eMVjzRDilj9FiD8dcnHu5AZ9nRVm9AcXIYk,17392
292
- dw_mcp/exports.py,sha256=Txly4-pD_GCcKR0CxvfWAWmhbRrcbUsOcvAdO7dMTps,4137
292
+ dw_mcp/exports.py,sha256=U37jOe40K1HVM4ic_CpYFvkZiupDVWPCzWiOJYqvc8g,4388
293
293
  dw_mcp/guides.py,sha256=QDvofzCO5FOXTNO5DW82UKqDn2IdvwNEr7rB91fHmxE,1762
294
294
  dw_mcp/loras.py,sha256=dp0fUZ9YfECYnDemJNvq62ZbvXjJJaEAEPdkRKIDVGM,875
295
295
  dw_mcp/media.py,sha256=1MzdoyO9p2B1PKAm8Neu9gCSHYmECEQ_X4boMBHuTvo,20937
@@ -298,11 +298,11 @@ dw_mcp/prompts.py,sha256=ZgbcATJbWUxHlebt8ssH2PlUc3nBD6rrVLdFLJSo8TM,3944
298
298
  dw_mcp/server.py,sha256=dEVWxyyN5yh-lIkc4Yub3S9TwcFOV5TSYNikqyiVXXg,7738
299
299
  dw_mcp/tools_authoring.py,sha256=FBelAhWf1OWRQX290rF2Eoy2fY1fpMoG5lZXytBFgpY,12313
300
300
  dw_mcp/tools_catalog.py,sha256=cZf32oDGgc-VOeKT4Z8guFDSFzQwPR5HG1oTCW0TR7M,22086
301
- dw_mcp/tools_jobs.py,sha256=IhzItLsok4QbwztgeQw5qr3z2KcELIhb954vEOVWAj4,12112
301
+ dw_mcp/tools_jobs.py,sha256=e4DY1f6xqlHtK_M4K2gg7OyjjVu5_iw4mn-2laNuB78,11988
302
302
  dw_mcp/tools_media.py,sha256=HsMQ5ihUmkUcZ1NELtJMNbxfpSjdfFLQA6-Q9jOhe0w,16848
303
303
  dw_mcp/workspaces.py,sha256=OF6oVMQx_pkTuRMLvbOYkpVWwcA6GQwaEf5k0oxqBnI,8449
304
- diffusers_workflow-0.8.0.dist-info/METADATA,sha256=b4SVA0uisJE416y0YSXwqIn3E2pfA8qqs1SDNTGwOr4,14938
305
- diffusers_workflow-0.8.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
306
- diffusers_workflow-0.8.0.dist-info/entry_points.txt,sha256=Dem_AOzV-Kcpr9AASlSvJ-S5sn3f6TQu6ABb6BbApho,148
307
- diffusers_workflow-0.8.0.dist-info/top_level.txt,sha256=o-uFAN_oUOrhXfMKO39HsMs2r19L9LykxoVKj2S9q7U,10
308
- diffusers_workflow-0.8.0.dist-info/RECORD,,
304
+ diffusers_workflow-0.9.0.dist-info/METADATA,sha256=lZa1AWCNfxpdjcpmfEXQ-GqVnIoMyU4bGLukSVnct80,14938
305
+ diffusers_workflow-0.9.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
306
+ diffusers_workflow-0.9.0.dist-info/entry_points.txt,sha256=Dem_AOzV-Kcpr9AASlSvJ-S5sn3f6TQu6ABb6BbApho,148
307
+ diffusers_workflow-0.9.0.dist-info/top_level.txt,sha256=o-uFAN_oUOrhXfMKO39HsMs2r19L9LykxoVKj2S9q7U,10
308
+ diffusers_workflow-0.9.0.dist-info/RECORD,,
dw/docs/MCP.md CHANGED
@@ -308,7 +308,7 @@ references written in the same session.
308
308
  | `run_workflow(workflow_path=None, inline_workflow=None, arguments=None, acknowledged_cost=False, workspace=None, wait_seconds=0)` | exactly one of `workflow_path` (a catalog name from `list_workflows`, with or without `.json`, or a path to a workflow file on the server) or `inline_workflow`, optional `arguments`, `acknowledged_cost`, `workspace`, `wait_seconds` | Queue a workflow for generation. Returns as soon as the job is queued - unless `wait_seconds` is above 0, in which case the call then waits on the queued job exactly as `wait_for_job(job_id, timeout_seconds=wait_seconds)` would (same per-call cap, clamped not honoured) and the result carries the queued-job fields plus that wait's (`status`, `still_running`, `waited_seconds`, `timeout_requested_seconds`, `timeout_applied_seconds`, `timeout_capped`, the slim `job`); when the cap covers the job's runtime one call is the run and the wait, and a `still_running: true` result is followed with `wait_for_job` as before. `workspace` names the workspace for this one call without switching the session to it - use it to pin a job whose `output:` or `asset:` references live in a workspace other than the session's - `acknowledged_cost` is `true` or the bound `{fingerprint, minutes, downloads}` from the validate plan; a 409 means the plan changed and the message carries the new estimate, and nothing is waited on |
309
309
  | `get_job(job_id)` | `job_id` | Get a job's status, warnings, output manifest, error and traceback; each manifest entry's `subfolder` is the in-run subfolder the step declared - by convention `final` for the deliverable, `intermediate` for scratch, `''` for none. A running job also carries `progress` (below) |
310
310
  | `get_job_workflow(job_id)` | `job_id` | The REST equivalent is `GET /api/jobs/{id}/workflow` (see [SERVER.md](SERVER.md#jobs-api)). The workflow the job actually ran. `realized: true` means every mutable input is pinned (arguments, seed, prompts, `output:latest`); `false` means the job predates run tracking and this is the definition as submitted. Pass it to `save_workflow` to keep it under a name |
311
- | `export_job(job_id, overwrite=False)` | `job_id`, `overwrite` | Gather one finished job into `<workspace>/exports/<job id>/` on the server: the realized workflow, the run's manifest, the job row, a README, and copies of the assets, earlier-run inputs and outputs. Returns the directory, a zip URL, the file list with sizes and the total. The three JSON files are in the zip, not repeated here - get_job_workflow and get_job serve them individually. **The directory is on the machine running the server**, like `download_output`'s destination. `auth_required` says whether opening the zip needs this server's bearer token, which this agent cannot attach to someone else's fetch (#353): when false, fetch `open_url` yourself and unpack it into `exports/` under the session's working directory (a deliverable, not a temp file) - the archive already unpacks into one folder named after the job id, so do not create that folder first; when true, hand `open_url` to the person instead of fetching it |
311
+ | `export_job(job_id, overwrite=False)` | `job_id`, `overwrite` | Gather one finished job into `<workspace>/exports/<job id>/` on the server: the realized workflow, the run's manifest, the job row, a README, and copies of the assets, earlier-run inputs and outputs. Returns the directory, a zip URL, the file list with sizes and the total. The three JSON files are in the zip, not repeated here - get_job_workflow and get_job serve them individually. **The directory is on the machine running the server**, like `download_output`'s destination. The zip needs no token (`/exports/*.zip` is ungated like `/outputs`, #592), so `auth_required` is false and the agent fetches `open_url` itself (prefixing a relative one with the server address) and unpacks it into `exports/` under the session's working directory (a deliverable, not a temp file) - the archive already unpacks into one folder named after the job id, so do not create that folder first; only an agent without HTTP gives the user `open_url`. `true` is kept as a forward guard for a gated zip, when `open_url` is handed to the person instead |
312
312
  | `get_job_events(job_id, after=-1, limit=200)` | `job_id`, `after`, `limit` | Get a page of a job's progress events |
313
313
  | `wait_for_job(job_id, timeout_seconds=20)` | `job_id`, `timeout_seconds` | Block until a job reaches a terminal status, or `timeout_seconds` elapses. **One call blocks for at most the server's cap** — 55 seconds unless the deployment sets `DW_MCP_MAX_WAIT_SECONDS` higher, and the tool's description states the live value; a larger `timeout_seconds` is clamped, not honoured. Ask for the job's `plan.estimate` plus a margin: under the cap that is one call for the whole job, over it one call per cap. A deployment raises the cap only as far as its clients (and anything between them and the server) hold one silent HTTP request open - Claude Code's limit is `MCP_TOOL_TIMEOUT`. Every reply carries `waited_seconds`, `timeout_requested_seconds`, `timeout_applied_seconds` and `timeout_capped`, so a capped return is distinguishable from an elapsed one. Use instead of hand-polling `get_job`/`get_job_events` in a loop; if it returns `still_running: true`, call it again. Returns a slim job - status, warnings, error, `run_id` and `run_version` (the run's `v5`, as the gallery labels it), and the manifest once finished - without the arguments; `get_job` has those. A running job also carries `progress` (below) |
314
314
  | `cancel_job(job_id)` | `job_id` | Ask a queued or running job to stop |
dw/docs/RELEASING.md CHANGED
@@ -7,6 +7,63 @@ notes from commits at tag time (see below). This section is a scratch pad
7
7
  for items a branch's author wants the next release note to name; clear it
8
8
  when a release ships.
9
9
 
10
+ ### 0.9.0
11
+
12
+ <!-- Drafted from v0.8.0..dd74e17b (develop). Paste into the GitHub release body once the tag has published: gh release edit v0.9.0 --notes-file ... -->
13
+
14
+ A smaller range than 0.8.0: the LoRA catalog grows to cover three more
15
+ bases, the cost planner is corrected for list-driven and `for_each` steps,
16
+ and `export_job` reports its zip's real auth gating. No upgrade-order
17
+ changes.
18
+
19
+ **LoRA catalog** (docs/LORAS.md)
20
+
21
+ - 11 LTX-2.5 entries added: IC-LoRAs for alpha generation, clean plate,
22
+ colorization, day-to-night, layout-to-render, detail refinement,
23
+ restoration, SDR-to-HDR and water simulation, plus cinemagraph and
24
+ slow-motion control.
25
+ - MiniMax-H3 goes to 13 trial and 3 rejected entries beyond the 0.8.0 set:
26
+ styles, speech, orbits, action and motion, and a diffusers-native 4-step
27
+ turbo. FastVideo FastH3 (`.diff` keys), RAVEN (unrecognised prefix, loads
28
+ nothing silently) and TaoMate (lowercase `lora_a`/`lora_b`) are rejected.
29
+ - New bases: Qwen-Image-2.1 (10 trial, including three few-step distills
30
+ that need their scheduler and sigma overrides, plus edit LoRAs; Fun-Acc
31
+ rejected) and Z-Image-Turbo (13 trial). The Z-Image entries match
32
+ `Tongyi-MAI/Z-Image-Turbo` only; whether they apply to the SDNQ
33
+ checkpoint is untested.
34
+ - Every new entry pins the repo's current sha, and its header was read
35
+ against the installed diffusers converter.
36
+
37
+ **Cost planning**
38
+
39
+ - `other_device` figures are re-priced for `for_each` counts and shifted
40
+ list drivers (#589, #590).
41
+ - A shifted per-entry field in a list driver resets to unknown, and a
42
+ summed child figure takes the children's basis (#593). A numeric string
43
+ in a list-driver entry compares as its number (#593).
44
+ - Measured MPS cost entries for `templates/ltx2/text-to-video` and Music 3
45
+ (#590).
46
+
47
+ **Fixes**
48
+
49
+ - `assemble-and-score` threads `sample_rate` into its edit join, and mixed-rate
50
+ shots resampled to a pinned `sample_rate` no longer draw a warning advising
51
+ you to pass it (#594).
52
+ - `export_job` reports the zip's real gating: `/exports/*.zip` is ungated
53
+ like `/outputs`, so `auth_required` is false whether or not the server has
54
+ a token. The MCP `next` text and the skills now say to fetch `open_url` and
55
+ unpack into `exports/` to bring a project home (#595, #592).
56
+
57
+ **Plugin**
58
+
59
+ - The `ltx-2.5`, `minimax-h3` and `minimax-music3` skills moved
60
+ request-specific detail (the LTX caption spec, H3 checkpoint and LoRA
61
+ combinations and cuts recipes, Music 3 loudness) into `references/`
62
+ beside each `SKILL.md`, which are read when the skill points there. The
63
+ 12 KiB cap applies to `SKILL.md` alone; a new test fails on an unlinked
64
+ reference or a dead link.
65
+ - The H3 and LTX skills point at `list_loras`.
66
+
10
67
  ### 0.8.0
11
68
 
12
69
  <!-- Drafted from v0.7.0..a9e1e72b (develop). Paste into the GitHub release body once the tag has published: gh release edit v0.8.0 --notes-file ... -->
@@ -38,6 +95,9 @@ upscaler and two security fixes.
38
95
  - GHSA-crqf-hw9p-r739: `create_workspace`'s MCP result is `name`,
39
96
  `default`, `current` and `next` only; `list_workspaces(detail=true)` is
40
97
  the opt-in for folder paths. `POST /api/workspaces` is unchanged.
98
+ - `GET /api/loras/recommend` and `recommend_loras`: `hub_error` names the
99
+ exception type, or the HTTP status, never the exception's text, which
100
+ could name the server's HF cache directory. The log keeps the full error.
41
101
  - UI lockfile bumps for open Dependabot alerts (devalue, dompurify,
42
102
  brace-expansion, undici).
43
103
 
dw/docs/REMOTE.md CHANGED
@@ -48,6 +48,11 @@ responses add an `absolute_url` / `absolute_zip_url` built from it; nothing
48
48
  guesses this from request headers, so an unconfigured server omits the
49
49
  field rather than composing a wrong origin.
50
50
 
51
+ The export zip, like `/outputs` and `/inputs` files, needs no token (#592), so
52
+ an agent with HTTP fetches `export_job`'s `open_url` itself, prefixing a
53
+ relative one with the address it reaches the server at; `DW_PUBLIC_URL` is for
54
+ the URLs handed to a person.
55
+
51
56
  ## Browser
52
57
 
53
58
  Open `http://<box>:8765`. Click the key icon next to the theme toggle,
dw/plan.py CHANGED
@@ -17,6 +17,7 @@ import copy
17
17
  import hashlib
18
18
  import json
19
19
  import logging
20
+ import math
20
21
  import os
21
22
 
22
23
  from huggingface_hub import model_info
@@ -340,6 +341,36 @@ def _driver_comparable(value):
340
341
  return json.dumps(value, sort_keys=True, default=str)
341
342
 
342
343
 
344
+ def _numeric_fields(entries):
345
+ """{field: {values}} over the numeric fields of a list's dict entries. A
346
+ numeric string ("243") counts as its number, as `_driver_comparable` does
347
+ for a scalar driver."""
348
+ fields = {}
349
+ if not isinstance(entries, list):
350
+ return fields
351
+ for entry in entries:
352
+ if not isinstance(entry, dict):
353
+ continue
354
+ for key, value in entry.items():
355
+ number = _driver_comparable(value)
356
+ if isinstance(number, float) and math.isfinite(number):
357
+ fields.setdefault(key, set()).add(number)
358
+ return fields
359
+
360
+
361
+ def _list_entry_field_shifted(default_entries, effective_entries):
362
+ """Whether a list driver's entries carry a numeric field (a per-shot
363
+ `num_frames`, say) with a value none of the default entries had. The
364
+ curated figure was measured over the default entries' values, so a value
365
+ outside them has no matching bucket - the #267 rule applied inside a
366
+ list driver (#593)."""
367
+ measured = _numeric_fields(default_entries)
368
+ for field, values in _numeric_fields(effective_entries).items():
369
+ if field in measured and not values <= measured[field]:
370
+ return True
371
+ return False
372
+
373
+
343
374
  def _scalar_driver_shifted(definition, expanded, list_entries):
344
375
  """Whether a declared, non-list `cost_driver` was overridden away from
345
376
  the default value the curated `cost` was measured against (#267).
@@ -352,10 +383,12 @@ def _scalar_driver_shifted(definition, expanded, list_entries):
352
383
  defaults = definition.get("variables") or {}
353
384
  effective = expanded.get("variables") or {}
354
385
  for name in _declared_drivers(definition):
355
- if name in list_entries:
356
- continue
357
386
  default_value = defaults.get(name)
358
387
  if isinstance(default_value, list):
388
+ if _list_entry_field_shifted(default_value, effective.get(name)):
389
+ return True
390
+ continue
391
+ if name in list_entries:
359
392
  continue
360
393
  if _driver_comparable(effective.get(name)) != _driver_comparable(default_value):
361
394
  return True
@@ -365,7 +398,7 @@ def _scalar_driver_shifted(definition, expanded, list_entries):
365
398
  def _own_price(definition, expanded, list_entries, device, measured_entries):
366
399
  """The workflow's own price, reset to unknown when a scalar driver moved."""
367
400
  own = _price(definition.get("cost"), device, list_entries, measured_entries or {})
368
- if own["basis"] == CATALOG and _scalar_driver_shifted(
401
+ if own["basis"] in (CATALOG, OTHER_DEVICE) and _scalar_driver_shifted(
369
402
  definition, expanded, list_entries
370
403
  ):
371
404
  # A scalar cost_driver (H3's num_frames, say) moved away from the
@@ -434,7 +467,7 @@ def _child_catalog_price(child_definition, child_cost, step_arguments, device):
434
467
  child_list_entries = _list_entries(child_definition, child_expanded)
435
468
  child = _price(child_cost, device, child_list_entries, child_measured_entries)
436
469
  if (
437
- child["basis"] == CATALOG
470
+ child["basis"] in (CATALOG, OTHER_DEVICE)
438
471
  and child_definition is not None
439
472
  and _scalar_driver_shifted(child_definition, child_expanded, child_list_entries)
440
473
  ):
@@ -458,6 +491,7 @@ class _ChildTotals:
458
491
  self.all_observed = True
459
492
  self.runs = []
460
493
  self.measured_on = set()
494
+ self.bases = []
461
495
 
462
496
  def add(self, path, child, observed):
463
497
  self.had_child = True
@@ -469,7 +503,9 @@ class _ChildTotals:
469
503
  if child["minutes"] is None:
470
504
  self.partial = True
471
505
  self.unpriced.append(path)
472
- elif self.minutes is not None:
506
+ return
507
+ self.bases.append((child["basis"], child.get("measured_on")))
508
+ if self.minutes is not None:
473
509
  self.minutes += child["minutes"]
474
510
  else:
475
511
  self.minutes = child["minutes"]
@@ -509,6 +545,20 @@ def _rolled_up_estimate(own, totals, device, cached_steps, total_steps):
509
545
  top_measured_on = (
510
546
  next(iter(totals.measured_on)) if len(totals.measured_on) == 1 else None
511
547
  )
548
+ if top_basis == UNKNOWN and minutes is not None and totals.bases:
549
+ # A number must not carry the basis a caller reads as "no number":
550
+ # the parent has no cost of its own, but its priced children do, so
551
+ # the sum takes their basis (#593)
552
+ kinds = {basis for basis, _ in totals.bases}
553
+ if OTHER_DEVICE in kinds:
554
+ top_basis = OTHER_DEVICE
555
+ elif kinds == {CATALOG}:
556
+ top_basis = CATALOG
557
+ else:
558
+ top_basis = DERIVED
559
+ devices = {on for _, on in totals.bases}
560
+ if len(devices) == 1:
561
+ top_measured_on = next(iter(devices))
512
562
  rounded = round(minutes, 1) if minutes is not None else None
513
563
  result = {
514
564
  "minutes": rounded,
@@ -696,18 +746,22 @@ def _price(cost, device, list_entries, measured_entries):
696
746
  basis = OTHER_DEVICE
697
747
  minutes = float(chosen.get("minutes", 0))
698
748
  per = chosen.get("per_entry")
699
- if (
700
- basis == CATALOG
701
- and isinstance(per, dict)
702
- and per.get("variable") in list_entries
703
- ):
749
+ # Another device's figure is re-priced for the list length too (#589): it
750
+ # stays `other_device` (still not a measurement here), but a 32-entry
751
+ # for_each must not be quoted at the one-clip figure
752
+ if isinstance(per, dict) and per.get("variable") in list_entries:
704
753
  count = list_entries[per["variable"]]
705
754
  each = float(per.get("minutes", 0))
706
755
  measured_with = int(per.get("entries", 0))
707
756
  minutes = max(0.0, (minutes - each * measured_with) + each * count)
708
- basis = PER_ENTRY
709
- elif basis == CATALOG:
710
- minutes, basis = _repriced(minutes, list_entries, measured_entries)
757
+ if basis == CATALOG:
758
+ basis = PER_ENTRY
759
+ else:
760
+ minutes, repriced_basis = _repriced(minutes, list_entries, measured_entries)
761
+ if repriced_basis == UNKNOWN:
762
+ return {"minutes": None, "basis": UNKNOWN, "measured_on": None}
763
+ if basis == CATALOG:
764
+ basis = repriced_basis
711
765
  return {"minutes": minutes, "basis": basis, "measured_on": chosen.get("name")}
712
766
 
713
767
 
dw/server/routes/jobs.py CHANGED
@@ -455,11 +455,13 @@ def export_job_route(
455
455
  absolute_zip_url = absolute_served_url(zip_path, ws)
456
456
  if absolute_zip_url is not None:
457
457
  body["absolute_zip_url"] = absolute_zip_url
458
- # Same rule get_server_info's field states (#353): whether the zip
459
- # URL above needs a bearer token an MCP-only agent has no way to
460
- # attach itself, which is what tells the caller whether to fetch it
461
- # or hand it to the person.
462
- body["auth_required"] = bool(state.api_token)
458
+ # Whether the zip URL above needs a bearer token (#353, #592) - a
459
+ # property of the route, not of whether this server has a token. The
460
+ # auth middleware covers /api/ and /mcp only, and /exports/*.zip sits
461
+ # with /outputs and /inputs on dw/server/routes/files.py's ungated
462
+ # list, so it is false. export_job's `next` keeps its hand-it-over
463
+ # branch for the day /exports is gated.
464
+ body["auth_required"] = False
463
465
  for key, name in (
464
466
  ("workflow", "workflow.json"),
465
467
  ("manifest", "manifest.json"),
dw/tasks/joins.py CHANGED
@@ -94,6 +94,7 @@ def reconcile_sample_rates(command, videos, names, waveforms, sample_rate=None):
94
94
  for video, waveform in zip(videos, waveforms)
95
95
  if waveform is not None
96
96
  ]
97
+ pinned = bool(sample_rate)
97
98
  sample_rate = sample_rate or (max(set(rates)) if rates else None)
98
99
  if rates and len(set(rates)) == 1 and rates[0] != sample_rate:
99
100
  # The inputs agree and the caller pinned another rate: converting
@@ -104,6 +105,15 @@ def reconcile_sample_rates(command, videos, names, waveforms, sample_rate=None):
104
105
  command=command,
105
106
  sample_rate=sample_rate,
106
107
  )
108
+ elif rates and pinned and any(rate != sample_rate for rate in rates):
109
+ # Mixed inputs converted to the rate the caller (or the template)
110
+ # pinned: expected, and 'pass sample_rate' advice would be wrong (#594)
111
+ emit_log(
112
+ f"{command}: resampling tracks at {sorted(set(rates))} Hz to the "
113
+ f"requested {sample_rate} Hz",
114
+ command=command,
115
+ sample_rate=sample_rate,
116
+ )
107
117
  elif rates and any(rate != sample_rate for rate in rates):
108
118
  # emit_warning rather than logger.warning: resampling every track is
109
119
  # an audio decision made on the caller's behalf, and a caller reading
dw_mcp/exports.py CHANGED
@@ -3,9 +3,9 @@
3
3
  The one thing this module has to keep saying: the directory it makes is on
4
4
  the machine running dw.serve, which over a `dw.serve --mcp` endpoint is the
5
5
  GPU box and not where the agent is. The zip URL is the way to it from
6
- anywhere else - but when the server requires a bearer token (#353), that URL
7
- is for the person to open, not for this agent to fetch on their behalf; see
8
- `export_job`'s `auth_required` / `open_url`.
6
+ anywhere else, and it needs no token: /exports/*.zip is ungated like
7
+ /outputs (#592), so `auth_required` is false and the agent fetches it itself.
8
+ The hand-it-to-the-person branch (#353) stays for the day it is gated.
9
9
  """
10
10
 
11
11
  from dw_mcp.client import api_path
@@ -22,10 +22,10 @@ def export_job(client, job_id, overwrite=False):
22
22
  here. The directory is on the server machine, not this one.
23
23
 
24
24
  `auth_required` says whether the zip needs this server's bearer token
25
- to open - a token this agent has no way to attach to a browser or hand
26
- to someone else's tooling. When it is true, `open_url` is for the
27
- *person* to open, not for this agent to fetch: hand it to them (see
28
- `next`). When it is false, `open_url` may be fetched directly. It is
25
+ to open. The server reports false - the zip route is ungated, token or
26
+ not (#592) - and `next` says to fetch `open_url` and unpack it. True
27
+ is a forward guard: a gated zip is for the *person* to open, since
28
+ this agent cannot attach the token to their browser. `open_url` is
29
29
  `absolute_zip_url` when the server has one configured (`DW_PUBLIC_URL`
30
30
  / the `public_url` setting), else the relative `zip_url`."""
31
31
  body = client.post_json(
@@ -58,11 +58,14 @@ def export_job(client, job_id, overwrite=False):
58
58
  open_url = absolute_zip_url or zip_url
59
59
  next_text = (
60
60
  "The directory is on the server. To give the user the files, "
61
- "fetch open_url and unpack it into exports/ under the session's "
62
- "working directory - it is the user's deliverable, not a "
63
- "temporary file, so not a scratch or temp directory. The archive "
64
- "already unpacks into one folder named after the job id; do not "
65
- "create that folder first or the id is doubled in the path. "
61
+ "fetch open_url with whatever HTTP you have - it needs no token; "
62
+ "prefix a relative one with the address you reach this server at "
63
+ "- and unpack it into exports/ under the session's working "
64
+ "directory - it is the user's deliverable, not a temporary file, "
65
+ "so not a scratch or temp directory. The archive already unpacks "
66
+ "into one folder named after the job id; do not create that "
67
+ "folder first or the id is doubled in the path. Only if you "
68
+ "cannot make HTTP requests, give the user open_url to open. "
66
69
  "workflow.json, manifest.json and job.json are inside it - they "
67
70
  "are not repeated here; get_job_workflow and get_job serve them "
68
71
  "individually."
dw_mcp/tools_jobs.py CHANGED
@@ -216,16 +216,15 @@ class JobTools:
216
216
  repeated here - get_job_workflow and get_job serve them individually.
217
217
  THE DIRECTORY IS ON THE MACHINE RUNNING THE SERVER, not on yours.
218
218
 
219
- `auth_required` says whether opening the zip needs this server's
220
- bearer token, a token you cannot attach to someone else's browser
221
- or tooling. When it is false, fetch open_url yourself and unpack
222
- it into exports/ under the session's working directory - it is
223
- the user's deliverable, not a temp file; the archive already
224
- unpacks into one folder named after the job id, so do not create that folder first.
225
- When it is true, do NOT fetch it: hand open_url to the person and let them open it
226
- (`next` says whether it is already absolute or needs the server's
227
- address told to them). Individual results stay reachable inline
228
- via get_output_image/get_output_audio/get_output_frames either
229
- way. Refuses a job that is still running; refuses an existing
219
+ open_url is the zip and needs no token: fetch it with any HTTP
220
+ you have (prefix a relative one with the server's address) and
221
+ unpack it into exports/ under the session's working directory -
222
+ the user's deliverable, not a temp file; it already unpacks into
223
+ one folder named after the job id, so do not create that folder first.
224
+ Only without HTTP, give the user open_url to open. If `auth_required`
225
+ is ever true, the zip is gated: hand it over (see `next`).
226
+ Individual results stay reachable inline via
227
+ get_output_image/get_output_audio/get_output_frames
228
+ without the zip. Refuses a job that is still running; refuses an existing
230
229
  export unless overwrite=true."""
231
230
  return exports.export_job(self.client, job_id, overwrite=overwrite)