From fe9f2c69c55de4b84f50c4c4370e56cdd3fa7c65 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Thu, 8 Oct 2026 20:34:01 -0500 Subject: [PATCH 01/46] chore: open 0.11.0-alpha.1 on develop --- plugins/dw/.claude-plugin/plugin.json | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/dw/.claude-plugin/plugin.json b/plugins/dw/.claude-plugin/plugin.json index 001d91a0..4d6bd1f5 100644 --- a/plugins/dw/.claude-plugin/plugin.json +++ b/plugins/dw/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "dw", "description": "Compose MiniMax H3 video, MiniMax Music 3, LTX-2.5 and Kandinsky 6.0 workflows over a dw MCP server, and cut a multi-episode series from them: which template fits which shape, the hard rules, cost, and how to judge the output. Prompt format comes from the vendors' own guides.", - "version": "0.10.0", + "version": "0.11.0-alpha.1", "author": { "name": "Don Kackman" }, diff --git a/pyproject.toml b/pyproject.toml index d2227023..3dfbfaaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ name = "diffusers-workflow" # runtime (static in TOML because importing dw at build time would drag # torch into the build environment). Release tags must match it - see # docs/RELEASING.md -version = "0.10.0" +version = "0.11.0-alpha.1" description = "Declarative workflow engine and web UI for Hugging Face Diffusers" readme = "README.md" requires-python = ">=3.10" From 51f74ffb6701e5f142cef1e9ac8f397eb418bb13 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 9 Oct 2026 07:18:47 -0500 Subject: [PATCH 02/46] chore: update dependencies in package.json - bump vitest from ^4.1.11 to ^5.0.3 - update monaco-editor from ^0.56.0 to ^0.57.0 - upgrade dompurify in monaco-editor overrides from ^3.4.13 to ^3.4.16 --- ui/package-lock.json | 1267 +++++++++++++++++++----------------------- ui/package.json | 8 +- 2 files changed, 586 insertions(+), 689 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 01ee5beb..85e755b3 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@lucide/svelte": "^1.39.0", "bits-ui": "^2.19.4", - "monaco-editor": "^0.56.0", + "monaco-editor": "^0.57.0", "svelte-sonner": "^1.2.1" }, "devDependencies": { @@ -34,7 +34,7 @@ "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", "vite": "^8.2.2", - "vitest": "^4.1.11" + "vitest": "^5.0.3" }, "engines": { "node": ">=24.20.0" @@ -48,36 +48,36 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", - "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.4.tgz", + "integrity": "sha512-KdNhWlAh2sbYB8Of0Yw84/a+LO/vsBOHJcIg8BpyXo2RGrFWVJvEMRviMON+ysQu0KpV7wxrWrq9jaX0P21GzA==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.3.0", - "@csstools/css-color-parser": "^4.1.10", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.5.2" + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" }, "engines": { - "node": "^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", - "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", "dev": true, "license": "MIT", "dependencies": { - "bidi-js": "^1.0.3", + "bidi-js": "^1.1.0", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2" + "lru-cache": "^11.5.3" }, "engines": { - "node": "^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/@babel/code-frame": { @@ -106,9 +106,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", "dev": true, "license": "MIT", "engines": { @@ -116,22 +116,46 @@ } }, "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", "dev": true, "license": "MIT", "dependencies": { - "css-tree": "^3.0.0" + "css-tree": "^3.2.1" }, "bin": { "specificity": "bin/cli.js" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@csstools/color-helpers": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", - "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", "dev": true, "funding": [ { @@ -149,9 +173,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", - "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", "dev": true, "funding": [ { @@ -168,14 +192,14 @@ "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-color-parser": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", - "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", "dev": true, "funding": [ { @@ -189,21 +213,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.1.1", - "@csstools/css-calc": "^3.3.0" + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" }, "engines": { "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", "dev": true, "funding": [ { @@ -220,13 +244,13 @@ "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", - "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", "dev": true, "funding": [ { @@ -249,9 +273,9 @@ } }, "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", "dev": true, "funding": [ { @@ -383,9 +407,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -397,9 +421,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", "dev": true, "license": "MIT", "engines": { @@ -560,33 +584,57 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@lucide/svelte": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.39.0.tgz", - "integrity": "sha512-jCgA/RqDv9uWHs4DiIUef4kpf5TfLl9y8OMGrh+W4aQTCOscT4X2BX14ta65J+5ooslrN1bXXiO9AaeAOHAcPA==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.54.0.tgz", + "integrity": "sha512-PbUrcmVPU71zmcs1Kc5RlgF83SsBsSNC+MeuttAHMmvPMuoIJfyDPjit5kpAJxJwsmN2Pk1Bx75AwZxP8vkDvw==", "license": "ISC", "peerDependencies": { "svelte": "^5" } }, "node_modules/@oxc-project/types": { - "version": "0.147.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", - "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@playwright/test": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", - "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.62.1" + "playwright": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -679,9 +727,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", - "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", "cpu": [ "arm" ], @@ -696,9 +744,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", - "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", "cpu": [ "arm64" ], @@ -713,9 +761,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", - "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", "cpu": [ "arm64" ], @@ -730,9 +778,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", - "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", "cpu": [ "x64" ], @@ -747,9 +795,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", - "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", "cpu": [ "x64" ], @@ -764,9 +812,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", - "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", "cpu": [ "arm" ], @@ -781,13 +829,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", - "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -798,13 +849,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", - "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -815,13 +869,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", - "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -832,13 +889,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", - "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -849,13 +909,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", - "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -866,13 +929,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", - "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -883,9 +949,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", - "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", "cpu": [ "arm64" ], @@ -900,9 +966,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", - "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", "cpu": [ "arm64" ], @@ -917,9 +983,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", - "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", "cpu": [ "x64" ], @@ -940,13 +1006,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", @@ -967,9 +1026,9 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz", - "integrity": "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", "dev": true, "license": "MIT", "dependencies": { @@ -997,9 +1056,9 @@ } }, "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1016,16 +1075,6 @@ "node": ">=18" } }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/@testing-library/jest-dom": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", @@ -1155,13 +1204,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/trusted-types": { @@ -1172,17 +1221,17 @@ "optional": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", - "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/type-utils": "8.69.0", - "@typescript-eslint/utils": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1195,15 +1244,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.69.0", + "@typescript-eslint/parser": "^8.71.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", - "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -1211,16 +1260,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", - "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3" }, "engines": { @@ -1236,14 +1285,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", - "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.69.0", - "@typescript-eslint/types": "^8.69.0", + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", "debug": "^4.4.3" }, "engines": { @@ -1258,14 +1307,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", - "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0" + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1276,9 +1325,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", - "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", "dev": true, "license": "MIT", "engines": { @@ -1293,15 +1342,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", - "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/utils": "8.69.0", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1318,9 +1367,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", - "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", "devOptional": true, "license": "MIT", "engines": { @@ -1332,16 +1381,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", - "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.69.0", - "@typescript-eslint/tsconfig-utils": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1360,16 +1409,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", - "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0" + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1384,13 +1433,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", - "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/types": "8.71.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1401,34 +1450,17 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1446,98 +1478,20 @@ } } }, - "node_modules/@vitest/mocker/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", "dev": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1624,12 +1578,13 @@ "license": "Python-2.0" }, "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" + "dependencies": { + "dequal": "^2.0.3" } }, "node_modules/assertion-error": { @@ -1662,9 +1617,9 @@ } }, "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", "dev": true, "license": "MIT", "dependencies": { @@ -1672,9 +1627,9 @@ } }, "node_modules/bits-ui": { - "version": "2.19.4", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.19.4.tgz", - "integrity": "sha512-HTnJAqqxYq6u30c9jrxMjsV4xZfJsUb/KEzmdiENLgC3LEAaSXdDscpx/EKyk48YKmzO2z1+GQ6r/UO1POA6Wg==", + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.19.5.tgz", + "integrity": "sha512-TEwkLSYElE5SE0fIdcItiFPfyg9vrR67N1Mt6VvZCqZo24dwTA1MpGVV3WeyIg7rfVAaZ0So8a0/dcnPngCoew==", "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.1", @@ -1695,30 +1650,6 @@ "svelte": "^5.33.0" } }, - "node_modules/bits-ui/node_modules/runed": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", - "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3", - "esm-env": "^1.0.0", - "lz-string": "^1.5.0" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.21.0", - "svelte": "^5.7.0" - }, - "peerDependenciesMeta": { - "@sveltejs/kit": { - "optional": true - } - } - }, "node_modules/brace-expansion": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -1732,10 +1663,24 @@ "node": "20 || >=22" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -1781,13 +1726,6 @@ "dev": true, "license": "MIT" }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1838,32 +1776,17 @@ } }, "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" + "whatwg-url": "^17.1.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/debug": { @@ -1950,9 +1873,9 @@ } }, "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -1983,9 +1906,9 @@ } }, "node_modules/eslint": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", - "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", "dev": true, "license": "MIT", "workspaces": [ @@ -1997,7 +1920,7 @@ "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", + "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -2012,7 +1935,7 @@ "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", @@ -2058,9 +1981,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.23.0.tgz", - "integrity": "sha512-n9jRklDqy0+W834568a4ZIZTK7DHXxvvHHxxGP8nNq7N//pOZMubttskHOquyGgfTR4Z05q2NdGNlsTpIEA48w==", + "version": "3.23.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.23.1.tgz", + "integrity": "sha512-+w86Jlx4IRUuuvtqD6j4njql+akx+81JnpvbN5de3AV5iq2SexvST/Jr51CiTlKeY0i+vhoKjPO/ywFKIP75sA==", "dev": true, "license": "MIT", "dependencies": { @@ -2174,9 +2097,9 @@ } }, "node_modules/esrap": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.6.tgz", - "integrity": "sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -2283,16 +2206,13 @@ } }, "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" + "flat-cache": "^6.1.23" } }, "node_modules/find-up": { @@ -2313,17 +2233,15 @@ } }, "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/flatted": { @@ -2362,9 +2280,9 @@ } }, "node_modules/globals": { - "version": "17.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", - "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "dev": true, "license": "MIT", "engines": { @@ -2374,17 +2292,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", "dev": true, "license": "MIT", "dependencies": { - "@exodus/bytes": "^1.6.0" + "@exodus/bytes": "^1.15.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" } }, "node_modules/https-proxy-agent": { @@ -2537,32 +2475,31 @@ } }, "node_modules/jsdom": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", - "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^6.0.5", - "@asamuzakjp/dom-selector": "^8.3.0", + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.7", - "@exodus/bytes": "^1.15.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", "css-tree": "^3.2.1", - "data-urls": "^7.0.0", + "data-urls": "^8.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", + "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2", + "lru-cache": "^11.5.3", "parse5": "^8.0.1", "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2", - "undici": "^8.9.0", - "w3c-xmlserializer": "^5.0.0", + "undici": "^8.11.2", + "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^17.1.0", + "whatwg-url": "^17.2.0", "xml-name-validator": "^5.0.0" }, "engines": { @@ -2577,13 +2514,6 @@ } } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2599,13 +2529,13 @@ "license": "MIT" }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/known-css-properties": { @@ -2772,6 +2702,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2793,6 +2726,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2814,6 +2750,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2835,6 +2774,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2923,9 +2865,9 @@ } }, "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -2942,13 +2884,13 @@ } }, "node_modules/magic-string": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", - "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/marked": { @@ -2997,12 +2939,12 @@ } }, "node_modules/monaco-editor": { - "version": "0.56.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", - "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.57.0.tgz", + "integrity": "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A==", "license": "MIT", "dependencies": { - "dompurify": "3.4.8", + "dompurify": "3.4.15", "marked": "14.0.0" } }, @@ -3024,9 +2966,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -3050,9 +2992,9 @@ "license": "MIT" }, "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -3185,13 +3127,6 @@ "node": ">=8" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3213,28 +3148,25 @@ } }, "node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.1" + "playwright-core": "1.64.0" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3244,21 +3176,6 @@ "node": ">=20" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -3270,9 +3187,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -3290,9 +3207,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -3339,9 +3256,9 @@ } }, "node_modules/postcss-safe-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", - "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz", + "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==", "dev": true, "funding": [ { @@ -3393,9 +3310,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", - "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3417,9 +3334,9 @@ } }, "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", "dev": true, "license": "MIT", "bin": { @@ -3471,6 +3388,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -3517,13 +3454,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", - "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.147.0", + "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -3533,37 +3470,45 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.6", - "@rolldown/binding-android-arm64": "1.2.6", - "@rolldown/binding-darwin-arm64": "1.2.6", - "@rolldown/binding-darwin-x64": "1.2.6", - "@rolldown/binding-freebsd-x64": "1.2.6", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", - "@rolldown/binding-linux-arm64-gnu": "1.2.6", - "@rolldown/binding-linux-arm64-musl": "1.2.6", - "@rolldown/binding-linux-ppc64-gnu": "1.2.6", - "@rolldown/binding-linux-s390x-gnu": "1.2.6", - "@rolldown/binding-linux-x64-gnu": "1.2.6", - "@rolldown/binding-linux-x64-musl": "1.2.6", - "@rolldown/binding-openharmony-arm64": "1.2.6", - "@rolldown/binding-win32-arm64-msvc": "1.2.6", - "@rolldown/binding-win32-x64-msvc": "1.2.6" + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, "node_modules/runed": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", - "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", "funding": [ "https://github.com/sponsors/huntabyte", "https://github.com/sponsors/tglide" ], "license": "MIT", "dependencies": { - "esm-env": "^1.0.0" + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" }, "peerDependencies": { + "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } } }, "node_modules/sade": { @@ -3628,34 +3573,20 @@ "node": ">=8" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -3695,22 +3626,22 @@ } }, "node_modules/svelte": { - "version": "5.57.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.0.tgz", - "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==", + "version": "5.57.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", + "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", - "@types/estree": "^1.0.5", - "acorn": "^8.12.1", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.8.1", + "devalue": "^5.9.2", "esm-env": "^1.2.1", - "esrap": "^2.2.12", + "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -3746,9 +3677,9 @@ } }, "node_modules/svelte-eslint-parser": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.1.tgz", - "integrity": "sha512-5zgKBqAf6V8Jyrmr1jViksyG4NKT8NheYwkdxRaMRDAqpGWi9wR8ktcGrAZbfMn/PHUp1BWzAp0cYQECkWuAMA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.9.0.tgz", + "integrity": "sha512-aKdKQAZP7MobfVcQNyy5Yk0QjlC9m5T1lTS0qA7+0EIsFzFX3WgbGHfb8267QK+4hI8Az9+2f+hk5sp39op8sg==", "dev": true, "license": "MIT", "dependencies": { @@ -3762,7 +3693,7 @@ }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0", - "pnpm": "10.34.5" + "pnpm": "10.34.6" }, "funding": { "url": "https://github.com/sponsors/ota-meshi" @@ -3836,6 +3767,22 @@ "svelte": "^5.0.0" } }, + "node_modules/svelte-sonner/node_modules/runed": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", + "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, "node_modules/svelte-toolbelt": { "version": "0.10.6", "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", @@ -3856,28 +3803,13 @@ "svelte": "^5.30.2" } }, - "node_modules/svelte-toolbelt/node_modules/runed": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", - "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3", - "esm-env": "^1.0.0", - "lz-string": "^1.5.0" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.21.0", - "svelte": "^5.7.0" - }, - "peerDependenciesMeta": { - "@sveltejs/kit": { - "optional": true - } + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" } }, "node_modules/svelte/node_modules/magic-string": { @@ -3889,13 +3821,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, "node_modules/tabbable": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", @@ -3903,16 +3828,19 @@ "license": "MIT" }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", + "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -3936,33 +3864,23 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/tldts": { - "version": "7.4.11", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", - "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.11" + "tldts-core": "^7.4.18" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.11", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", - "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", "dev": true, "license": "MIT" }, @@ -3980,16 +3898,16 @@ } }, "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, "engines": { - "node": ">=20" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/ts-api-utils": { @@ -4053,16 +3971,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", - "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", + "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.69.0", - "@typescript-eslint/parser": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/utils": "8.69.0" + "@typescript-eslint/eslint-plugin": "8.71.1", + "@typescript-eslint/parser": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4087,9 +4005,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -4118,16 +4036,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -4144,7 +4062,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -4216,38 +4134,31 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -4255,16 +4166,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -4305,27 +4216,17 @@ } } }, - "node_modules/vitest/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=18" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/webidl-conversions": { @@ -4349,14 +4250,14 @@ } }, "node_modules/whatwg-url": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", - "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", "dev": true, "license": "MIT", "dependencies": { - "@exodus/bytes": "^1.15.1", - "tr46": "^6.0.0", + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", "webidl-conversions": "^8.0.1" }, "engines": { @@ -4380,20 +4281,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/word-wrap": { @@ -4454,9 +4351,9 @@ } }, "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", "license": "MIT" } } diff --git a/ui/package.json b/ui/package.json index 7978214d..6a01bed8 100644 --- a/ui/package.json +++ b/ui/package.json @@ -40,12 +40,12 @@ "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", "vite": "^8.2.2", - "vitest": "^4.1.11" + "vitest": "^5.0.3" }, "dependencies": { "@lucide/svelte": "^1.39.0", "bits-ui": "^2.19.4", - "monaco-editor": "^0.56.0", + "monaco-editor": "^0.57.0", "svelte-sonner": "^1.2.1" }, "allowScripts": { @@ -54,10 +54,10 @@ }, "overrides": { "monaco-editor": { - "dompurify": "^3.4.13" + "dompurify": "^3.4.16" }, "openapi-typescript": { "typescript": "$typescript" } } -} +} \ No newline at end of file From 27e1c7916fdcf7593c5afe5023209e8a4f269ff4 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 9 Oct 2026 20:06:13 -0500 Subject: [PATCH 03/46] ci: run the architecture ratchet whatever the other checks do (#790) The ratchet ran after Tests, so a failing test skipped it and it stayed red on develop for three days unseen. It now runs first, and every check step runs whenever the install succeeded, so none hides another. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 From 3732a51b8c6b316230d8182c044f74e820ffe5a3 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 9 Oct 2026 20:10:39 -0500 Subject: [PATCH 04/46] refactor(cuts): split plan_cuts and _align_lyrics under the ratchet (#790) plan_cuts was 243 lines at complexity 16 and _align_lyrics was at 16. plan_cuts now parses and checks its arguments in _parsed_arguments, resolves the song's length (and its warning) in _song_length, builds the render grid in _render_grid and the shot records with their min/max-scene warnings in _shots. _align_lyrics takes the word-level alignment out as _heard_spans, the seam #801's word timestamps will come from. Behaviour, warnings, errors and the public signature and docstring are unchanged. Co-Authored-By: Claude Opus 5.5 --- dw/tasks/cuts.py | 311 ++++++++++++++++++++++++++++------------------- 1 file changed, 186 insertions(+), 125 deletions(-) diff --git a/dw/tasks/cuts.py b/dw/tasks/cuts.py index 4df519a6..00a82269 100644 --- a/dw/tasks/cuts.py +++ b/dw/tasks/cuts.py @@ -226,11 +226,12 @@ def _align_words(lyric_words, heard_words): return pairs -def _align_lyrics(lyric_lines, chunks, duration, warnings, min_line_s): - """[{start, end, text, stanza}] - the lyrics' lines in their order, timed - from the transcript. A line none of whose words were heard is placed - between its neighbours, and warned about; when they touch it takes - min_line_s from them, so it is still a shot of its own.""" +def _heard_spans(lyric_lines, chunks): + """[[start, end] or None] per lyric line - the span of its words the + transcript heard, aligned word by word, or None when it heard none of + them. A line's span runs no further than the next heard line's start. + The word-level alignment stays here, apart from placing the unheard + lines, so per-word timings (#801) have one place to come from.""" heard = _timed_words(chunks) lyric_words, owner = [], [] for index, (text, _) in enumerate(lyric_lines): @@ -249,11 +250,19 @@ def _align_lyrics(lyric_lines, chunks, duration, warnings, min_line_s): timed[line][0] = min(timed[line][0], start) timed[line][1] = max(timed[line][1], end) - # A line's span runs no further than the next timed line's start known = [index for index, span in enumerate(timed) if span is not None] for a, b in zip(known, known[1:]): timed[a][1] = min(timed[a][1], timed[b][0]) + return timed + +def _align_lyrics(lyric_lines, chunks, duration, warnings, min_line_s): + """[{start, end, text, stanza}] - the lyrics' lines in their order, timed + from the transcript. A line none of whose words were heard is placed + between its neighbours, and warned about; when they touch it takes + min_line_s from them, so it is still a shot of its own.""" + timed = _heard_spans(lyric_lines, chunks) + known = [index for index, span in enumerate(timed) if span is not None] if not known: warnings.append( f"{COMMAND}: none of the lyrics' words were heard in the transcript " @@ -592,103 +601,31 @@ def reach(start): return result -def plan_cuts( - transcript, - lyrics=None, - beats=None, - segment_by="line", - fps=24, - duration_s=None, - min_scene_s=1.0, - max_scene_s=None, - vocal_tail_s=0.0, - include_instrumental_gaps=True, - min_gap_seconds=2.0, - snap_to_beats=False, - modulus=None, - remainder=None, - min_frames=None, - max_frames=None, - lead_s=None, -): - """Plan a music video's cuts from a song's transcript, lyrics and beats. - - Args: - transcript: transcribe_audio's result with 'timestamps' set - a - {text, chunks} dict of {start, end, text} chunks in seconds. A - null 'end' runs to the next chunk's start, or the song's end - lyrics: The song's own lyrics - one line per line, a blank line or a - lone section tag ("[Chorus]") between stanzas - or a list of - lines. The shots carry these lines verbatim and in order; the - transcript only times them - beats: analyze_beats' result, or a list of beat times in seconds - segment_by: "line" (a shot per sung line), "stanza" (per stanza: - blank-line groups of the lyrics, or lines without a silence of - min_gap_seconds between them) or "beat" (a cut on the first beat - at least min_scene_s after the last; needs beats) - fps: Frames per second the shots are counted in - duration_s: The song's length; analyze_beats' duration_seconds when - omitted, else the transcript's last end - min_scene_s: The shortest a shot may be; a shorter one merges into - its shorter neighbour - max_scene_s: The longest a shot may be; a longer one splits evenly, - each cut on its nearest beat when there are beats - vocal_tail_s: Seconds a sung shot holds past its last word, for the - breath and the held note's tail - include_instrumental_gaps: Whether a silence between lines (or - before the first, or after the last) of at least min_gap_seconds - becomes its own instrumental shot, rather than the shot before - it holding over it - min_gap_seconds: The shortest silence that becomes its own shot - snap_to_beats: Whether every cut moves to its nearest beat - modulus: The render grid's step: a shot renders 'modulus * n + - remainder' frames. Omitted, any length renders - remainder: The grid's offset, a whole number from 0 below modulus - (0 when omitted); needs modulus - min_frames: The fewest frames a shot renders. A shot shorter than its - lead plus cut renders this many, then the grid raises it, so - num_frames is always on the grid even when min_frames is not - max_frames: The most frames a shot renders; a shot needing more - splits (on a beat when one is in reach) and is warned about. It - must allow at least the smallest grid length at or above - min_frames - lead_s: Seconds a shot starts before its cut, as a run-up the render - is trimmed of; the first shot has none, and no shot leads past - the song's start. Omitted, none - - Returns: - {shots, bpm, fps, duration_s, total_frames, render_frames, warnings}: - `shots` tile the song in order, each {name, start_frame, num_frames, - cut_frames, lead_frames, lyric, kind} - `start_frame` and - `cut_frames` the cut on the timeline, `lead_frames` the run-up - before it (min(round(lead_s * fps), start_frame)) and `num_frames` - the length to render: lead_frames + cut_frames, no less than - min_frames, raised to the grid. With no grid arguments that is - cut_frames and lead_frames is 0. `lyric` is the lines sung in it - joined by newlines or null, `kind` "vocal" or "instrumental". - `render_frames` is the sum of the shots' num_frames - """ +def _parsed_arguments(transcript, beats, segment_by, **raw): + """plan_cuts' arguments parsed into a namespace and checked: each + against its domain, segment_by against its choices, and the ones that + constrain each other together (cuts_problems). Refused here, before any + planning starts.""" import types - from ..events import emit_warning from ..task_domains import check_arguments, cuts_problems # An explicit 0 is a value (every silence counts); only an absent gap takes 2.0. - gap = _number(min_gap_seconds, "min_gap_seconds") + gap = _number(raw["min_gap_seconds"], "min_gap_seconds") gap = 2.0 if gap is None else gap args = types.SimpleNamespace( - fps=_number(fps, "fps", required=True), - duration_s=_number(duration_s, "duration_s"), - min_scene_s=_number(min_scene_s, "min_scene_s") or 0.0, - max_scene_s=_number(max_scene_s, "max_scene_s"), - vocal_tail_s=_number(vocal_tail_s, "vocal_tail_s") or 0.0, + fps=_number(raw["fps"], "fps", required=True), + duration_s=_number(raw["duration_s"], "duration_s"), + min_scene_s=_number(raw["min_scene_s"], "min_scene_s") or 0.0, + max_scene_s=_number(raw["max_scene_s"], "max_scene_s"), + vocal_tail_s=_number(raw["vocal_tail_s"], "vocal_tail_s") or 0.0, min_gap_seconds=gap, - include_instrumental_gaps=bool(include_instrumental_gaps), - modulus=whole_number(modulus, "modulus", COMMAND), - remainder=whole_number(remainder, "remainder", COMMAND), - min_frames=whole_number(min_frames, "min_frames", COMMAND), - max_frames=whole_number(max_frames, "max_frames", COMMAND), - lead_s=_number(lead_s, "lead_s"), + include_instrumental_gaps=bool(raw["include_instrumental_gaps"]), + modulus=whole_number(raw["modulus"], "modulus", COMMAND), + remainder=whole_number(raw["remainder"], "remainder", COMMAND), + min_frames=whole_number(raw["min_frames"], "min_frames", COMMAND), + max_frames=whole_number(raw["max_frames"], "max_frames", COMMAND), + lead_s=_number(raw["lead_s"], "lead_s"), ) check_arguments( COMMAND, @@ -722,11 +659,16 @@ def plan_cuts( ) if problems: raise ValueError("; ".join(message for _, message in problems)) + return args - beat_times, bpm, beats_duration = _beats(beats) + +def _song_length(args, transcript, beats, beats_duration, warnings): + """(duration, chunks) - the song's length in seconds and the + transcript's chunks. duration_s says how long, else the analyze_beats + result; failing both the transcript's last end does, and is warned + about.""" duration = args.duration_s or beats_duration chunks = _chunks(transcript, duration) - warnings = [] if duration is None: # Only the transcript says how long: to its last word, which is no # end at all when that word's end is null @@ -751,34 +693,13 @@ def plan_cuts( f"{COMMAND}: {why} - the plan ends with the transcript's last line, " f"at {duration:.2f} s" ) - beat_times = [beat for beat in beat_times if 0.0 < beat < duration] + return duration, chunks - if lyrics is not None and not (isinstance(lyrics, str) and not lyrics.strip()): - lines = _align_lyrics( - _lyric_lines(lyrics), chunks, duration, warnings, max(args.min_scene_s, 0.5) - ) - else: - lines = _transcript_lines(chunks, args.min_gap_seconds) - if not lines: - warnings.append( - f"{COMMAND}: the transcript has no sung lines - the song is " - "planned as instrumental" - ) - for line in lines: - line["start"] = min(max(line["start"], 0.0), duration) - line["end"] = min(max(line["end"], line["start"]), duration) - - if segment_by == "beat": - scenes = _scenes_by_beats(lines, beat_times, duration, args) - else: - scenes = _scenes_by_units(_units(lines, segment_by), duration, args, warnings) - scenes = _split_long(scenes, beat_times, args.max_scene_s, warnings) - if snap_to_beats: - scenes = _snap(scenes, beat_times, warnings) - scenes = _merge_short(scenes, args.min_scene_s) - total_frames = int(round(duration * args.fps)) - framed = _frames(scenes, args.fps, total_frames, warnings) +def _render_grid(args): + """The render grid _render_length and _split_over_max work to, with + lead_s in frames; refused when the lead alone fills max_frames and + leaves no shot room for its own frames.""" grid = { "modulus": args.modulus, "remainder": args.remainder or 0, @@ -794,8 +715,14 @@ def plan_cuts( f"frames) leaves no room under 'max_frames' ({args.max_frames}) " "for a shot's own frames" ) - beat_frames = [int(round(beat * args.fps)) for beat in beat_times] - framed = _split_over_max(framed, grid, beat_frames, warnings) + return grid + + +def _shots(framed, grid, args, warnings): + """The plan's shot records from the framed scenes, numbered in order, + each one still outside min_scene_s or max_scene_s warned about by + name. A shot record is what the agent writes a prompt for, so a + per-shot field (#801) is added here.""" width = max(2, len(str(len(framed)))) shots = [] for number, (scene, start, cut) in enumerate(framed, start=1): @@ -824,6 +751,140 @@ def plan_cuts( "kind": scene["kind"], } ) + return shots + + +def plan_cuts( + transcript, + lyrics=None, + beats=None, + segment_by="line", + fps=24, + duration_s=None, + min_scene_s=1.0, + max_scene_s=None, + vocal_tail_s=0.0, + include_instrumental_gaps=True, + min_gap_seconds=2.0, + snap_to_beats=False, + modulus=None, + remainder=None, + min_frames=None, + max_frames=None, + lead_s=None, +): + """Plan a music video's cuts from a song's transcript, lyrics and beats. + + Args: + transcript: transcribe_audio's result with 'timestamps' set - a + {text, chunks} dict of {start, end, text} chunks in seconds. A + null 'end' runs to the next chunk's start, or the song's end + lyrics: The song's own lyrics - one line per line, a blank line or a + lone section tag ("[Chorus]") between stanzas - or a list of + lines. The shots carry these lines verbatim and in order; the + transcript only times them + beats: analyze_beats' result, or a list of beat times in seconds + segment_by: "line" (a shot per sung line), "stanza" (per stanza: + blank-line groups of the lyrics, or lines without a silence of + min_gap_seconds between them) or "beat" (a cut on the first beat + at least min_scene_s after the last; needs beats) + fps: Frames per second the shots are counted in + duration_s: The song's length; analyze_beats' duration_seconds when + omitted, else the transcript's last end + min_scene_s: The shortest a shot may be; a shorter one merges into + its shorter neighbour + max_scene_s: The longest a shot may be; a longer one splits evenly, + each cut on its nearest beat when there are beats + vocal_tail_s: Seconds a sung shot holds past its last word, for the + breath and the held note's tail + include_instrumental_gaps: Whether a silence between lines (or + before the first, or after the last) of at least min_gap_seconds + becomes its own instrumental shot, rather than the shot before + it holding over it + min_gap_seconds: The shortest silence that becomes its own shot + snap_to_beats: Whether every cut moves to its nearest beat + modulus: The render grid's step: a shot renders 'modulus * n + + remainder' frames. Omitted, any length renders + remainder: The grid's offset, a whole number from 0 below modulus + (0 when omitted); needs modulus + min_frames: The fewest frames a shot renders. A shot shorter than its + lead plus cut renders this many, then the grid raises it, so + num_frames is always on the grid even when min_frames is not + max_frames: The most frames a shot renders; a shot needing more + splits (on a beat when one is in reach) and is warned about. It + must allow at least the smallest grid length at or above + min_frames + lead_s: Seconds a shot starts before its cut, as a run-up the render + is trimmed of; the first shot has none, and no shot leads past + the song's start. Omitted, none + + Returns: + {shots, bpm, fps, duration_s, total_frames, render_frames, warnings}: + `shots` tile the song in order, each {name, start_frame, num_frames, + cut_frames, lead_frames, lyric, kind} - `start_frame` and + `cut_frames` the cut on the timeline, `lead_frames` the run-up + before it (min(round(lead_s * fps), start_frame)) and `num_frames` + the length to render: lead_frames + cut_frames, no less than + min_frames, raised to the grid. With no grid arguments that is + cut_frames and lead_frames is 0. `lyric` is the lines sung in it + joined by newlines or null, `kind` "vocal" or "instrumental". + `render_frames` is the sum of the shots' num_frames + """ + from ..events import emit_warning + + args = _parsed_arguments( + transcript, + beats, + segment_by, + fps=fps, + duration_s=duration_s, + min_scene_s=min_scene_s, + max_scene_s=max_scene_s, + vocal_tail_s=vocal_tail_s, + include_instrumental_gaps=include_instrumental_gaps, + min_gap_seconds=min_gap_seconds, + modulus=modulus, + remainder=remainder, + min_frames=min_frames, + max_frames=max_frames, + lead_s=lead_s, + ) + + beat_times, bpm, beats_duration = _beats(beats) + warnings = [] + duration, chunks = _song_length(args, transcript, beats, beats_duration, warnings) + beat_times = [beat for beat in beat_times if 0.0 < beat < duration] + + if lyrics is not None and not (isinstance(lyrics, str) and not lyrics.strip()): + lines = _align_lyrics( + _lyric_lines(lyrics), chunks, duration, warnings, max(args.min_scene_s, 0.5) + ) + else: + lines = _transcript_lines(chunks, args.min_gap_seconds) + if not lines: + warnings.append( + f"{COMMAND}: the transcript has no sung lines - the song is " + "planned as instrumental" + ) + for line in lines: + line["start"] = min(max(line["start"], 0.0), duration) + line["end"] = min(max(line["end"], line["start"]), duration) + + if segment_by == "beat": + scenes = _scenes_by_beats(lines, beat_times, duration, args) + else: + scenes = _scenes_by_units(_units(lines, segment_by), duration, args, warnings) + scenes = _split_long(scenes, beat_times, args.max_scene_s, warnings) + if snap_to_beats: + scenes = _snap(scenes, beat_times, warnings) + scenes = _merge_short(scenes, args.min_scene_s) + + total_frames = int(round(duration * args.fps)) + framed = _frames(scenes, args.fps, total_frames, warnings) + grid = _render_grid(args) + beat_frames = [int(round(beat * args.fps)) for beat in beat_times] + framed = _split_over_max(framed, grid, beat_frames, warnings) + shots = _shots(framed, grid, args, warnings) for message in warnings: emit_warning(message, kind="plan_cuts", command=COMMAND) From efd8f3c1e3daacd6afc208d683b381b3492928b7 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 9 Oct 2026 20:11:40 -0500 Subject: [PATCH 05/46] refactor(tasks): split task.py's handlers by family (#790) dw/tasks/task.py was 1,471 lines, over the 1,100-line size ceiling. Its command handlers move, unchanged, into four sibling modules that register on import, the way beats/cuts/trim already do: - audio_handlers.py: the audio commands and the cut-assessment probes - video_handlers.py: joins, frame runs/grids, windows, fit/restore, stabilize, face-track crop and paste - finish_handlers.py: grade, sharpen, film_grain, apply_lut and _load_media - model_handlers.py: every model-backed (consumes_device) command task.py keeps Task, task_command_info, the registry re-exports, the general-purpose commands and the image/video processor dispatch (366 lines). Two tests import the moved private helpers from their new homes; no patch target changed. Ratchet: modules_over_size_ceiling 2 -> 1. modules rises 198 -> 202 - the four new modules are the split itself; the baseline is left to the #790 payback's own re-baseline commit. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 2 +- dw/tasks/audio_handlers.py | 291 ++++++++ dw/tasks/finish_handlers.py | 207 ++++++ dw/tasks/model_handlers.py | 318 ++++++++ dw/tasks/task.py | 1123 +---------------------------- dw/tasks/video_handlers.py | 381 ++++++++++ tests/test_finish.py | 4 +- tests/test_restore_faces_video.py | 2 +- tests/test_speech_generation.py | 2 +- 9 files changed, 1211 insertions(+), 1119 deletions(-) create mode 100644 dw/tasks/audio_handlers.py create mode 100644 dw/tasks/finish_handlers.py create mode 100644 dw/tasks/model_handlers.py create mode 100644 dw/tasks/video_handlers.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b8593b08..0d5bbc2b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,7 +31,7 @@ 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` | +| 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` | 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` | | 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` | diff --git a/dw/tasks/audio_handlers.py b/dw/tasks/audio_handlers.py new file mode 100644 index 00000000..63594dc9 --- /dev/null +++ b/dw/tasks/audio_handlers.py @@ -0,0 +1,291 @@ +"""The audio task commands' handlers, and the checks of a finished cut. + +Split from `dw/tasks/task.py` (#790), which had grown past the module-size +ceiling with every handler in one file. These are the commands that shape or +measure a soundtrack - fade, gain, slice, mix, dynamics, loop beds - and the +assessment commands that measure a cut's audio against itself and its picture +(#485). None loads a model, so none consumes a device. + +Each handler imports its implementation on use, as task.py's did: an audio +module pulls in numpy/scipy, a cost a workflow that never touches audio +should not pay at startup. `dw/tasks/task.py` imports this module, which is +what registers its commands (`docs/TASKS.md` *Adding a task*). +""" + +import logging + +from .registry import register_command +from ..task_domains import ( + FINITE, + NON_NEGATIVE, + NON_POSITIVE, + POSITIVE, + SEED, + slice_audio_errors, +) + +logger = logging.getLogger("dw") + + +@register_command( + "fade_audio", + implementation="dw.tasks.audio_utils.fade_audio", + domains={ + "fade_in_ms": NON_NEGATIVE, + "fade_out_ms": NON_NEGATIVE, + "sample_rate": POSITIVE, + }, +) +def _handle_fade_audio(task, arguments, previous_pipelines): + """Fade an audio track in from silence and out to it""" + logger.debug("Fading audio") + from .audio_utils import fade_audio + + return fade_audio(**arguments) + + +@register_command( + "normalize_audio", + implementation="dw.tasks.audio_dynamics.normalize_audio", + domains={ + "peak_dbfs": FINITE, + "sample_rate": POSITIVE, + "target_lufs": NON_POSITIVE, + }, +) +def _handle_normalize_audio(task, arguments, previous_pipelines): + """Scale an audio track so its peak sits at a given level""" + logger.debug("Normalizing audio") + from .audio_dynamics import normalize_audio + + return normalize_audio(**arguments) + + +@register_command( + "slice_audio", + implementation="dw.tasks.audio_utils.slice_audio", + domains={ + "start_seconds": NON_NEGATIVE, + "duration_seconds": POSITIVE, + "start_frame": NON_NEGATIVE, + "lead_frames": SEED, + "num_frames": POSITIVE, + "fps": POSITIVE, + "sample_rate": POSITIVE, + }, + whole_numbers=("start_frame", "num_frames", "lead_frames"), + static_check=slice_audio_errors, +) +def _handle_slice_audio(task, arguments, previous_pipelines): + """Cut a time- or frame-aligned slice out of an audio track""" + logger.debug("Slicing audio") + from .audio_utils import slice_audio + + return slice_audio(**arguments) + + +@register_command( + "gain_audio", + implementation="dw.tasks.audio_utils.gain_audio", + domains={ + "start_seconds": NON_NEGATIVE, + "duration_seconds": POSITIVE, + "start_frame": NON_NEGATIVE, + "num_frames": POSITIVE, + "fps": POSITIVE, + "gain_db": FINITE, + "sample_rate": POSITIVE, + }, + whole_numbers=("start_frame", "num_frames"), +) +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", + domains={"target_sample_rate": POSITIVE, "sample_rate": POSITIVE}, + whole_numbers=("target_sample_rate",), +) +def _handle_resample_audio(task, arguments, previous_pipelines): + """Resample an audio track to a different sample rate""" + logger.debug("Resampling audio") + from .audio_utils import resample_audio + + return resample_audio(**arguments) + + +@register_command( + "pair_audio", + implementation="dw.tasks.pair_audio.pair_audio", + domains={"sample_rate": POSITIVE}, +) +def _handle_pair_audio(task, arguments, previous_pipelines): + """Pair a video's frames with an audio track generated beside them""" + logger.debug("Pairing audio with video") + from .pair_audio import pair_audio + + return pair_audio(**arguments) + + +@register_command( + "crossfade_audio", + implementation="dw.tasks.audio_utils.crossfade_audio", + domains={"crossfade_ms": NON_NEGATIVE, "sample_rate": POSITIVE}, +) +def _handle_crossfade_audio(task, arguments, previous_pipelines): + """Join audio tracks with an equal-power crossfade""" + logger.debug("Crossfading audio") + from .audio_utils import crossfade_audio + + return crossfade_audio(**arguments) + + +@register_command( + "loop_audio", + implementation="dw.tasks.audio_utils.loop_audio", + domains={ + "duration_seconds": POSITIVE, + "target_frames": POSITIVE, + "fps": POSITIVE, + "crossfade_ms": NON_NEGATIVE, + "sample_rate": POSITIVE, + }, + whole_numbers=("target_frames",), +) +def _handle_loop_audio(task, arguments, previous_pipelines): + """Loop a short recording into a bed of a given length""" + logger.debug("Looping audio") + from .audio_utils import loop_audio + + return loop_audio(**arguments) + + +@register_command( + "find_loop_bed", + implementation="dw.tasks.loop_bed.find_loop_bed", + returns="json", + domains={ + "start_seconds": NON_NEGATIVE, + "end_seconds": POSITIVE, + "min_seconds": POSITIVE, + "max_seconds": POSITIVE, + "max_bin_dbfs": NON_POSITIVE, + "max_mean_dbfs": NON_POSITIVE, + "max_spike_db": NON_NEGATIVE, + "crossfade_ms": NON_NEGATIVE, + "loop_seconds": POSITIVE, + "target_bed_dbfs": NON_POSITIVE, + "max_candidates": POSITIVE, + "fps": POSITIVE, + }, + whole_numbers=("max_candidates",), +) +def _handle_find_loop_bed(task, arguments, previous_pipelines): + """Rank the quiet windows of a recording worth looping into a room-tone bed""" + logger.debug("Searching for a loop bed") + from .loop_bed import find_loop_bed + + return find_loop_bed(**arguments) + + +@register_command( + "mix_audio", + implementation="dw.tasks.audio_utils.mix_audio", + domains={"gains": NON_NEGATIVE, "sample_rate": POSITIVE}, +) +def _handle_mix_audio(task, arguments, previous_pipelines): + """Layer audio tracks on top of one another, rather than end to end""" + logger.debug("Mixing audio") + from .audio_utils import mix_audio + + return mix_audio(**arguments) + + +@register_command( + "compress_audio", + implementation="dw.tasks.audio_dynamics.compress_audio", + domains={ + "threshold_dbfs": FINITE, + "ratio": POSITIVE, + "attack_ms": NON_NEGATIVE, + "release_ms": NON_NEGATIVE, + "sample_rate": POSITIVE, + }, +) +def _handle_compress_audio(task, arguments, previous_pipelines): + """Shape a track's dynamics with a compressor, limiter or gate""" + logger.debug("Compressing audio") + from .audio_dynamics import compress_audio + + return compress_audio(**arguments) + + +@register_command( + "filter_audio", + implementation="dw.tasks.audio_dynamics.filter_audio", + domains={"cutoff_hz": POSITIVE, "q": FINITE, "sample_rate": POSITIVE}, +) +def _handle_filter_audio(task, arguments, previous_pipelines): + """Run a track through a single lowpass/highpass/bandpass/notch filter""" + logger.debug("Filtering audio") + from .audio_dynamics import filter_audio + + return filter_audio(**arguments) + + +@register_command( + "analyze_audio", + implementation="dw.tasks.audio_dynamics.analyze_audio", + domains={"sample_rate": POSITIVE}, +) +def _handle_analyze_audio(task, arguments, previous_pipelines): + """Measure a track's levels and spectral balance without changing it""" + logger.debug("Analyzing audio") + from .audio_dynamics import analyze_audio + + return analyze_audio(**arguments) + + +@register_command( + "analyze_shots", + implementation="dw.tasks.assess.analyze_shots", + returns="json", + assessment=True, +) +def _handle_analyze_shots(task, arguments, previous_pipelines): + """Measure each shot of a cut's soundtrack and how far apart they sit""" + from .assess import analyze_shots + + return analyze_shots(**arguments) + + +@register_command( + "analyze_seams", + implementation="dw.tasks.assess.analyze_seams", + returns="json", + assessment=True, +) +def _handle_analyze_seams(task, arguments, previous_pipelines): + """Measure every seam of a cut - level step, hole, click, frame jump""" + from .assess import analyze_seams + + return analyze_seams(**arguments) + + +@register_command( + "analyze_sync_drift", + implementation="dw.tasks.assess.analyze_sync_drift", + returns="json", + assessment=True, +) +def _handle_analyze_sync_drift(task, arguments, previous_pipelines): + """Measure how far a cut's soundtrack sits from its picture""" + from .assess import analyze_sync_drift + + return analyze_sync_drift(**arguments) diff --git a/dw/tasks/finish_handlers.py b/dw/tasks/finish_handlers.py new file mode 100644 index 00000000..416b0d9c --- /dev/null +++ b/dw/tasks/finish_handlers.py @@ -0,0 +1,207 @@ +"""The finishing task commands' handlers: grade, sharpen, film grain, LUT. + +Split from `dw/tasks/task.py` (#790), which had grown past the module-size +ceiling with every handler in one file. The four share one shape - a `media` +argument that is an image or a whole video, read by `_load_media` and run +frame by frame (#603) - and one rule: each checks its arguments again at run +time, since a value from a variable or an earlier step never met the static +pass. `dw/tasks/task.py` imports this module, which is what registers its +commands (`docs/TASKS.md` *Adding a task*). +""" + +import logging + +from ..events import emit_log +from .image_ops import per_frame +from .registry import register_command +from ..task_domains import ( + AT_LEAST_ONE, + CHANNEL_LEVEL, + CLOSED_UNIT, + FINITE, + NON_NEGATIVE, + POSITIVE, + SEED, + UNIT, + lut_errors, +) + +logger = logging.getLogger("dw") + + +def _load_media(media): + """An image-or-video argument as something per_frame takes. + + A string is a file path (an asset:/output: reference already resolved): + a video extension is read with its audio, anything else as an image. A + value that is not a string - a PIL Image, an AudioVideo, a frame list - + is already loaded and comes back as itself. Shared by the finishing + commands that take `media` (#603). An image file with transparency loads + as RGBA, so the command puts its alpha back after the op (#775). + """ + if not isinstance(media, str): + return media + import os + + from ..security import ALLOWED_VIDEO_EXTENSIONS + from .video_utils import load_audio_video + + if os.path.splitext(media)[1].lower() in ALLOWED_VIDEO_EXTENSIONS: + return load_audio_video(media) + from ..argument_media import fetch_image + + return fetch_image(media, keep_alpha=True) + + +@register_command( + "grade", + implementation="dw.tasks.grade.grade_image", + summary=( + "Adjust exposure, contrast, tonal range (highlights, shadows, whites, " + "blacks), clarity, white balance, saturation, fade and vignette of " + "an image or a video." + ), + parameter_descriptions={ + "media": ( + "Image or video to grade. An image is a PIL Image; a video is a " + "file path or an asset:/output: reference, read with its audio " + "and graded frame by frame, keeping its frame rate and audio " + "unchanged." + ), + }, + domains={ + "exposure": FINITE, + "contrast": NON_NEGATIVE, + "saturation": NON_NEGATIVE, + "temperature": CLOSED_UNIT, + "tint": CLOSED_UNIT, + "highlights": CLOSED_UNIT, + "shadows": CLOSED_UNIT, + "whites": CLOSED_UNIT, + "blacks": CLOSED_UNIT, + "clarity": CLOSED_UNIT, + "vignette": CLOSED_UNIT, + "fade": UNIT, + }, + media_arguments=("media",), +) +def _handle_grade(task, arguments, previous_pipelines): + """Adjust the exposure, tone, white balance and colour of an image or a video""" + logger.debug("Grading media") + media = _load_media(arguments.pop("media")) + import inspect + + from ..task_domains import check_arguments + from .grade import grade_image + + # A value from a variable or an earlier step never met the static pass + check_arguments("grade", **arguments) + # Read off the signature, so a parameter added there cannot drop out of + # the log (#603) + defaults = { + name: parameter.default + for name, parameter in inspect.signature(grade_image).parameters.items() + if parameter.default is not inspect.Parameter.empty + } + applied = { + name: arguments.get(name, default) + for name, default in defaults.items() + if arguments.get(name, default) != default + } + emit_log( + f"grade: applied {applied}" + if applied + else "grade: no adjustment (all identity)", + command="grade", + **applied, + ) + return per_frame(media, lambda frame: grade_image(frame, **arguments)) + + +_MEDIA_DESCRIPTION = ( + "Image or video. An image is a PIL Image; a video is a file path or an " + "asset:/output: reference, read with its audio and processed frame by " + "frame, keeping its frame count, frame rate and audio unchanged." +) + + +@register_command( + "sharpen", + implementation="dw.tasks.finish.sharpen_image", + summary="Sharpen an image or a video with an unsharp mask.", + parameter_descriptions={"media": _MEDIA_DESCRIPTION}, + domains={"amount": NON_NEGATIVE, "radius": POSITIVE, "threshold": CHANNEL_LEVEL}, + media_arguments=("media",), +) +def _handle_sharpen(task, arguments, previous_pipelines): + """Sharpen an image or a video with an unsharp mask""" + logger.debug("Sharpening media") + media = _load_media(arguments.pop("media")) + from ..task_domains import check_arguments + from .finish import sharpen_image + + # A value from a variable or an earlier step never met the static pass + check_arguments("sharpen", **arguments) + return per_frame(media, lambda frame: sharpen_image(frame, **arguments)) + + +@register_command( + "film_grain", + implementation="dw.tasks.finish.film_grain", + summary=( + "Add seeded film grain to an image or a video, strongest in the " + "midtones; every video frame gets different grain." + ), + parameter_descriptions={ + "media": _MEDIA_DESCRIPTION, + "seed": ( + "The grain's seed. Defaults to the workflow's or step's seed, " + "which a run always has (a random one when the workflow names " + "none, recorded in the manifest), so a rerun reproduces the grain." + ), + }, + domains={"amount": UNIT, "size": AT_LEAST_ONE, "chroma": UNIT, "seed": SEED}, + media_arguments=("media",), +) +def _handle_film_grain(task, arguments, previous_pipelines): + """Add film grain to an image or a video, reproducibly from a seed""" + logger.debug("Adding film grain") + media = _load_media(arguments.pop("media")) + seed = task.seed_for(arguments) + from ..task_domains import check_arguments + from .finish import film_grain + + check_arguments("film_grain", seed=seed, **arguments) + emit_log(f"film_grain: seed {seed}", command="film_grain", seed=seed) + return film_grain(media, seed=seed, **arguments) + + +@register_command( + "apply_lut", + implementation="dw.tasks.lut.apply_lut", + summary=( + "Colour an image or a video through a 3D lookup table - from a .cube " + "file or built from a palette - blended with the original by strength." + ), + parameter_descriptions={"media": _MEDIA_DESCRIPTION}, + domains={"strength": UNIT}, + static_check=lut_errors, + media_arguments=( + "media", + "lut", + ), +) +def _handle_apply_lut(task, arguments, previous_pipelines): + """Apply a .cube or palette 3D lookup table to an image or a video""" + logger.debug("Applying a LUT") + media = _load_media(arguments.pop("media")) + from ..task_domains import check_arguments + from .lut import apply_lut, lookup_for + + lut = arguments.pop("lut", None) + palette = arguments.pop("palette", None) + # A value from a variable or an earlier step never met the static pass + check_arguments("apply_lut", **arguments) + # Read or built once, not once per video frame + lookup = lookup_for(lut, palette) + return per_frame(media, lambda frame: apply_lut(frame, lookup, **arguments)) diff --git a/dw/tasks/model_handlers.py b/dw/tasks/model_handlers.py new file mode 100644 index 00000000..b852cedc --- /dev/null +++ b/dw/tasks/model_handlers.py @@ -0,0 +1,318 @@ +"""The model-backed task commands' handlers. + +Split from `dw/tasks/task.py` (#790), which had grown past the module-size +ceiling with every handler in one file. Each of these loads a model - an +upscaler, a face restorer, a captioner, a speech or text model, the H3 latent +upscaler and VAE, a voice or stem separator - so each registers with +`consumes_device=True` and asks `task.device_for(arguments)` which +accelerator to run on. + +Their implementations are imported inside the handlers: at module scope the +transformers/model imports add seconds to every startup for workflows that +never run these tasks. `dw/tasks/task.py` imports this module, which is what +registers its commands (`docs/TASKS.md` *Adding a task*). +""" + +import logging + +from .image_ops import per_frame +from .registry import register_command +from ..task_domains import POSITIVE, UNIT, script_lines_errors + +logger = logging.getLogger("dw") + + +def _voices_argument_errors(arguments): + """attribute_voices' static check, imported on use: its module loads torch.""" + from .voice_attribution import voices_argument_errors + + return voices_argument_errors(arguments) + + +@register_command( + "attribute_voices", + implementation="dw.tasks.voice_attribution.attribute_voices", + consumes_device=True, + returns="json", + domains={"window_seconds": POSITIVE, "min_reference_seconds": POSITIVE}, + static_check=_voices_argument_errors, +) +def _handle_attribute_voices(task, arguments, previous_pipelines): + """Say which reference voice sings each line of a song, by timbre""" + from .voice_attribution import attribute_voices + + return attribute_voices(device=task.device_for(arguments), **arguments) + + +@register_command( + "check_script", + implementation="dw.tasks.script_check.check_script", + consumes_device=True, + returns="json", + domains={"similarity": UNIT}, + static_check=script_lines_errors, +) +def _handle_check_script(task, arguments, previous_pipelines): + """Check that a take speaks its script, line by line""" + from .script_check import check_script + + return check_script(device=task.device_for(arguments), **arguments) + + +@register_command( + "separate_stems", + implementation="dw.tasks.voice_attribution.separate_stems", + consumes_device=True, +) +def _handle_separate_stems(task, arguments, previous_pipelines): + """Split a mix into vocals, drums, bass and other stems, one audio result each""" + from .voice_attribution import separate_stems + + return separate_stems(device=task.device_for(arguments), **arguments) + + +@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") + image = arguments.pop("image") + model_name = arguments.pop("model_name") + from .upscale import upscale_image + + device = task.device_for(arguments) + return per_frame( + image, + lambda frame: upscale_image(frame, model_name, device=device, **arguments), + ) + + +@register_command( + "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""" + logger.debug("Diffusion upscaling image") + image = arguments.pop("image") + from .diffusion_upscale import diffusion_upscale + + device = task.device_for(arguments) + return per_frame( + image, lambda frame: diffusion_upscale(frame, device=device, **arguments) + ) + + +@register_command( + "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""" + logger.debug("Restoring faces") + image = arguments.pop("image") + model_name = arguments.pop("model_name") + from .restore_faces import restore_faces + from .video_utils import is_video + + if is_video(image) and arguments.get("upsample_img") is not None: + raise ValueError( + "restore_faces: 'upsample_img' is a single background image and " + "cannot be used with a video input (it would be pasted under " + "every frame). Remove 'upsample_img' or pass a single image." + ) + + device = task.device_for(arguments) + return per_frame( + image, + lambda frame: restore_faces(frame, model_name, device=device, **arguments), + ) + + +@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") + image = arguments.pop("image") + prompt = arguments.pop("prompt") + from .segment import segment_image + + device = task.device_for(arguments) + return per_frame( + image, lambda frame: segment_image(frame, prompt, device=device, **arguments) + ) + + +@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""" + logger.debug("Interpolating frames") + video = arguments.pop("video") + from .interpolate_frames import interpolate_frames + + return interpolate_frames(video, device=task.device_for(arguments), **arguments) + + +@register_command( + "upscale_h3_latents", + implementation="dw.tasks.h3_latent_upscale.upscale_h3_latents", + consumes_device=True, + summary=( + "Resize MiniMax-H3 video latents to a larger canvas (e.g. a 960x544 " + "take to 1344x768) without denoising again; decode_h3_latents turns " + "the result into frames. The whole promoting workflow is " + "get_guide('workflows', section='upscale_h3_latents')." + ), + parameter_descriptions={ + "latents": ( + "H3 video latents, (batch, 24, frames, height, width) - a MiniMax-H3 " + "pipeline step's 'latents' output, e.g. 'previous_result:base.latents' " + "from a step whose output lists 'latents'." + ), + "width": ( + "Target width in pixels: a multiple of 16, 1x to 4x the latents' " + "own width, within H3's 1344x768 canvas." + ), + "height": ( + "Target height in pixels: a multiple of 16, 1x to 4x the latents' " + "own height, within H3's 1344x768 canvas." + ), + "model_name": ( + "Hugging Face repo id holding the upscaler, never a path (default " + "LBH-123-AI/Minimax_h3_latent_Upscaler, read at a pinned revision)." + ), + "weight_name": ( + "The safetensors file within model_name, a bare file name - no " + "'/' (default: the bf16 v1 checkpoint; in the default repo it is " + "read from the v1 checkpoint folder)." + ), + }, + domains={"width": POSITIVE, "height": POSITIVE}, +) +def _handle_upscale_h3_latents(task, arguments, previous_pipelines): + """Resize MiniMax-H3 video latents to a larger canvas""" + logger.debug("Upscaling H3 latents") + latents = arguments.pop("latents") + from .h3_latent_upscale import upscale_h3_latents + + return upscale_h3_latents(latents, device=task.device_for(arguments), **arguments) + + +@register_command( + "decode_h3_latents", + implementation="dw.tasks.h3_latent_upscale.decode_h3_latents", + consumes_device=True, + summary=( + "Decode MiniMax-H3 video latents into frames with the H3 video VAE, " + "through diffusers' own H3 decode block. Returns video only, at 24 fps; " + "pair_audio puts a soundtrack back under it. The whole promoting " + "workflow is get_guide('workflows', section='upscale_h3_latents')." + ), + parameter_descriptions={ + "latents": ( + "H3 video latents, (batch, 24, frames, height, width) - a pipeline " + "step's 'latents' output or an upscale_h3_latents result." + ), + "model_name": ( + "The MiniMax-H3 repo whose 'vae' subfolder decodes (default " + "MiniMaxAI/MiniMax-H3)." + ), + }, +) +def _handle_decode_h3_latents(task, arguments, previous_pipelines): + """Decode MiniMax-H3 video latents into frames""" + logger.debug("Decoding H3 latents") + latents = arguments.pop("latents") + from .h3_latent_upscale import decode_h3_latents + + return decode_h3_latents(latents, device=task.device_for(arguments), **arguments) + + +@register_command( + "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""" + logger.debug("Captioning image") + image = arguments.pop("image") + from .image_to_text import image_to_text + + return image_to_text(image, device=task.device_for(arguments), **arguments) + + +@register_command( + "judge", + implementation="dw.tasks.judge.judge", + consumes_device=True, + returns="scalar", +) +def _handle_judge(task, arguments, previous_pipelines): + """Score an image against a rubric with a vision-language model""" + logger.debug("Judging") + image = arguments.pop("image") + from .judge import judge + + return judge(image, device=task.device_for(arguments), **arguments) + + +@register_command( + "transcribe_audio", + implementation="dw.tasks.audio_transcription.transcribe_audio", + consumes_device=True, +) +def _handle_transcribe_audio(task, arguments, previous_pipelines): + """Transcribe spoken audio to text""" + logger.debug("Transcribing audio") + audio = arguments.pop("audio") + from .audio_transcription import transcribe_audio + + return transcribe_audio(audio, device=task.device_for(arguments), **arguments) + + +@register_command( + "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""" + logger.debug("Generating text") + prompt = arguments.pop("prompt") + from .text_generation import generate_text + + return generate_text(prompt, device=task.device_for(arguments), **arguments) + + +@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""" + logger.debug("Generating speech") + if ("text" in arguments) == ("messages" in arguments): + raise ValueError( + "generate_speech needs exactly one of 'text' (the line to speak) " + "or 'messages' (chat-templated input for a model such as VibeVoice)" + ) + text = arguments.pop("text", None) + from .speech_generation import generate_speech + + return generate_speech( + text, + device=task.device_for(arguments), + seed=task.seed_for(arguments), + **arguments, + ) diff --git a/dw/tasks/task.py b/dw/tasks/task.py index 82c4a977..477d6906 100644 --- a/dw/tasks/task.py +++ b/dw/tasks/task.py @@ -1,7 +1,6 @@ import logging from .. import resolve_device -from ..events import emit_log from .image_ops import per_frame from .qr_code import get_qrcode_image from .image_utils import process_image @@ -17,39 +16,19 @@ # handler (`beats`) without a cycle through this one; re-exported here under # the names the rest of dw and the tests read from .registry import _COMMAND_INFO, _COMMAND_REGISTRY, register_command # noqa: F401 -from ..task_domains import ( - AT_LEAST_ONE, - CHANNEL_LEVEL, - CLOSED_UNIT, - FINITE, - FIT_MODES, - INGREDIENTS_FITS, - INGREDIENTS_LAYOUTS, - JOIN_WINDOWS_CURVES, - NON_NEGATIVE, - NON_POSITIVE, - POSITIVE, - SEED, - UNIT, - coerce_arguments, - face_track_errors, - fit_to_model_errors, - ingredients_grid_errors, - join_windows_errors, - lut_errors, - paste_face_track_errors, - script_lines_errors, - slice_audio_errors, - window_video_errors, -) +from ..task_domains import FINITE, coerce_arguments from . import beats # noqa: F401 - registers analyze_beats from . import cuts # noqa: F401 - registers plan_cuts from . import trim # noqa: F401 - registers trim_video -# The model-backed handlers (upscale, restore_faces, segment, interpolate_frames, -# image_to_text, text_generation, diffusion_upscale) are imported inside their -# handlers - at module scope their transformers/model imports add seconds to -# every startup for workflows that never run those tasks +# The handlers by family, split out of this module when it outgrew the size +# ceiling (#790). Each registers its commands on import, so these lines are +# what puts them in the registry; the general-purpose commands - gather, text, +# messages, select, qr_code - and the image/video processor dispatch stay here +from . import audio_handlers # noqa: F401 - audio and cut-assessment commands +from . import finish_handlers # noqa: F401 - grade, sharpen, film_grain, apply_lut +from . import model_handlers # noqa: F401 - the model-backed commands +from . import video_handlers # noqa: F401 - video and frame commands logger = logging.getLogger("dw") @@ -109,665 +88,6 @@ def _handle_gather_inputs(task, arguments, previous_pipelines): return gather_inputs(arguments) -@register_command( - "concat_videos", - implementation="dw.tasks.concat_videos.concat_videos", - domains={ - "trim_frames": NON_NEGATIVE, - "crossfade_ms": NON_NEGATIVE, - "audio_bleed_ms": NON_NEGATIVE, - "audio_bleed_gain_db": FINITE, - "match_levels_dbfs": FINITE, - "seam_fade_ms": NON_NEGATIVE, - "fps": POSITIVE, - "sample_rate": POSITIVE, - }, -) -def _handle_concat_videos(task, arguments, previous_pipelines): - """Concatenate videos - and the audio generated with them - into one""" - logger.debug("Concatenating videos") - from .concat_videos import concat_videos - - return concat_videos(**arguments) - - -@register_command( - "dissolve_videos", - implementation="dw.tasks.dissolve_videos.dissolve_videos", - domains={ - "dissolve_frames": NON_NEGATIVE, - "fade_in_frames": NON_NEGATIVE, - "fade_out_frames": NON_NEGATIVE, - "match_levels_dbfs": FINITE, - "fps": POSITIVE, - }, -) -def _handle_dissolve_videos(task, arguments, previous_pipelines): - """Join videos with cross-dissolves, fading the whole from and to a colour""" - logger.debug("Dissolving videos") - from .dissolve_videos import dissolve_videos - - return dissolve_videos(**arguments) - - -@register_command( - "join_into_song", - implementation="dw.tasks.join_into_song.join_into_song", - domains={ - "cue_seconds": NON_NEGATIVE, - "duck_delay_ms": NON_NEGATIVE, - "duck_db": NON_POSITIVE, - "duck_ramp_ms": NON_NEGATIVE, - "dialogue_target_lufs": FINITE, - "fps": POSITIVE, - }, -) -def _handle_join_into_song(task, arguments, previous_pipelines): - """Join dialogue shots and song shots into one video over the unbroken song""" - logger.debug("Joining dialogue into a song") - from .join_into_song import join_into_song - - return join_into_song(**arguments) - - -@register_command( - "fade_audio", - implementation="dw.tasks.audio_utils.fade_audio", - domains={ - "fade_in_ms": NON_NEGATIVE, - "fade_out_ms": NON_NEGATIVE, - "sample_rate": POSITIVE, - }, -) -def _handle_fade_audio(task, arguments, previous_pipelines): - """Fade an audio track in from silence and out to it""" - logger.debug("Fading audio") - from .audio_utils import fade_audio - - return fade_audio(**arguments) - - -@register_command( - "normalize_audio", - implementation="dw.tasks.audio_dynamics.normalize_audio", - domains={ - "peak_dbfs": FINITE, - "sample_rate": POSITIVE, - "target_lufs": NON_POSITIVE, - }, -) -def _handle_normalize_audio(task, arguments, previous_pipelines): - """Scale an audio track so its peak sits at a given level""" - logger.debug("Normalizing audio") - from .audio_dynamics import normalize_audio - - return normalize_audio(**arguments) - - -@register_command( - "slice_audio", - implementation="dw.tasks.audio_utils.slice_audio", - domains={ - "start_seconds": NON_NEGATIVE, - "duration_seconds": POSITIVE, - "start_frame": NON_NEGATIVE, - "lead_frames": SEED, - "num_frames": POSITIVE, - "fps": POSITIVE, - "sample_rate": POSITIVE, - }, - whole_numbers=("start_frame", "num_frames", "lead_frames"), - static_check=slice_audio_errors, -) -def _handle_slice_audio(task, arguments, previous_pipelines): - """Cut a time- or frame-aligned slice out of an audio track""" - logger.debug("Slicing audio") - from .audio_utils import slice_audio - - return slice_audio(**arguments) - - -@register_command( - "gain_audio", - implementation="dw.tasks.audio_utils.gain_audio", - domains={ - "start_seconds": NON_NEGATIVE, - "duration_seconds": POSITIVE, - "start_frame": NON_NEGATIVE, - "num_frames": POSITIVE, - "fps": POSITIVE, - "gain_db": FINITE, - "sample_rate": POSITIVE, - }, - whole_numbers=("start_frame", "num_frames"), -) -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", - domains={"target_sample_rate": POSITIVE, "sample_rate": POSITIVE}, - whole_numbers=("target_sample_rate",), -) -def _handle_resample_audio(task, arguments, previous_pipelines): - """Resample an audio track to a different sample rate""" - logger.debug("Resampling audio") - from .audio_utils import resample_audio - - return resample_audio(**arguments) - - -@register_command("video_frames", implementation="dw.tasks.video_utils.frames_as_array") -def _handle_video_frames(task, arguments, previous_pipelines): - """The frames of a generated video, as one array a later step can condition on""" - logger.debug("Extracting video frames") - from .video_utils import frames_as_array - - return frames_as_array(**arguments) - - -@register_command( - "loop_frames", - implementation="dw.tasks.video_utils.loop_frames", - domains={"num_frames": POSITIVE}, - whole_numbers=("num_frames",), -) -def _handle_loop_frames(task, arguments, previous_pipelines): - """Repeat a still or a short clip into a run of a given length""" - logger.debug("Looping frames") - from .video_utils import loop_frames - - return loop_frames(**arguments) - - -@register_command( - "window_video", - implementation="dw.tasks.windows.window_video", - domains={ - "index": NON_NEGATIVE, - "num_frames": POSITIVE, - "overlap": NON_NEGATIVE, - "fps": POSITIVE, - }, - whole_numbers=("index", "num_frames", "overlap"), - static_check=window_video_errors, -) -def _handle_window_video(task, arguments, previous_pipelines): - """Cut one overlapping, fixed-length window out of a long video""" - logger.debug("Cutting a video window") - from .windows import window_video - - return window_video(**arguments) - - -@register_command( - "fit_to_model", - implementation="dw.tasks.fit.fit_to_model", - summary=( - "Fit a video into a model's working size and frame count - letterbox, " - "stretch or crop - with a record restore_to_source reads to undo it." - ), - parameter_descriptions={ - "video": ( - "The source - an earlier step's video, or an asset:/output: " - "reference. Its fps is kept; its soundtrack is not." - ), - "width": "The model's working width in pixels.", - "height": "The model's working height in pixels.", - "num_frames": ( - "The model's frame count. A longer source is cut to its first " - "num_frames frames; a shorter one holds its last frame." - ), - "mode": ( - "letterbox (scale to fit, centred on black), stretch (resize to " - "fill exactly) or crop (scale to fill, centre-crop)." - ), - "downscale": ( - "Fit into width/downscale x height/downscale instead, default 1 - " - "2 when width/height are a 2x model's output size. Both must be " - "divisible by it; the fit record's model size is the divided one." - ), - }, - domains={ - "width": POSITIVE, - "height": POSITIVE, - "num_frames": POSITIVE, - "downscale": POSITIVE, - }, - whole_numbers=("width", "height", "num_frames", "downscale"), - choices={"mode": FIT_MODES}, - static_check=fit_to_model_errors, -) -def _handle_fit_to_model(task, arguments, previous_pipelines): - """Fit a video into a model's working size and frame count""" - logger.debug("Fitting a video to the model") - from .fit import fit_to_model - - return fit_to_model(**arguments) - - -@register_command( - "restore_to_source", - implementation="dw.tasks.fit.restore_to_source", - summary=( - "Put a model's output for a fit_to_model video back at the source's " - "size (times the model's scale, inferred) and frame count." - ), - parameter_descriptions={ - "video": ( - "The model's output for the fitted video, at the fitted size or a " - "uniform multiple of it - 2x restores to twice the source." - ), - "fit": ( - "The record fit_to_model returned - previous_result:.fit, or " - "its saved .json. A crop fit restores only the part it kept, at " - "the source's pixel density." - ), - }, - media_arguments=("fit",), -) -def _handle_restore_to_source(task, arguments, previous_pipelines): - """Put a fitted video back at its source's size and length""" - logger.debug("Restoring a video to its source") - from .fit import restore_to_source - - return restore_to_source(**arguments) - - -@register_command( - "join_windows", - implementation="dw.tasks.windows.join_windows", - domains={"num_frames": POSITIVE, "overlap": NON_NEGATIVE, "fps": POSITIVE}, - whole_numbers=("num_frames", "overlap"), - choices={"curve": JOIN_WINDOWS_CURVES}, - static_check=join_windows_errors, - media_arguments=("source",), -) -def _handle_join_windows(task, arguments, previous_pipelines): - """Blend processed overlapping windows back into one video the source's length""" - logger.debug("Joining video windows") - from .windows import join_windows - - return join_windows(**arguments) - - -@register_command( - "frame_grid", - implementation="dw.tasks.video_utils.frame_grid", - domains={"count": POSITIVE, "columns": POSITIVE, "tile_width": POSITIVE}, - whole_numbers=("count", "columns", "tile_width"), -) -def _handle_frame_grid(task, arguments, previous_pipelines): - """Tile evenly sampled frames of a video into one contact-sheet image""" - logger.debug("Building frame grid") - from .video_utils import frame_grid - - return frame_grid(**arguments) - - -@register_command( - "ingredients_grid", - implementation="dw.tasks.image_utils.ingredients_grid", - domains={ - "width": POSITIVE, - "height": POSITIVE, - "gap": NON_NEGATIVE, - "max_images": POSITIVE, - }, - whole_numbers=("width", "height", "gap", "max_images"), - choices={"layout": INGREDIENTS_LAYOUTS, "fit": INGREDIENTS_FITS}, - static_check=ingredients_grid_errors, -) -def _handle_ingredients_grid(task, arguments, previous_pipelines): - """Lay individual images out as one reference sheet""" - logger.debug("Building ingredients grid") - from .image_utils import ingredients_grid - - return ingredients_grid(**arguments) - - -@register_command( - "pair_audio", - implementation="dw.tasks.pair_audio.pair_audio", - domains={"sample_rate": POSITIVE}, -) -def _handle_pair_audio(task, arguments, previous_pipelines): - """Pair a video's frames with an audio track generated beside them""" - logger.debug("Pairing audio with video") - from .pair_audio import pair_audio - - return pair_audio(**arguments) - - -@register_command( - "crossfade_audio", - implementation="dw.tasks.audio_utils.crossfade_audio", - domains={"crossfade_ms": NON_NEGATIVE, "sample_rate": POSITIVE}, -) -def _handle_crossfade_audio(task, arguments, previous_pipelines): - """Join audio tracks with an equal-power crossfade""" - logger.debug("Crossfading audio") - from .audio_utils import crossfade_audio - - return crossfade_audio(**arguments) - - -@register_command( - "loop_audio", - implementation="dw.tasks.audio_utils.loop_audio", - domains={ - "duration_seconds": POSITIVE, - "target_frames": POSITIVE, - "fps": POSITIVE, - "crossfade_ms": NON_NEGATIVE, - "sample_rate": POSITIVE, - }, - whole_numbers=("target_frames",), -) -def _handle_loop_audio(task, arguments, previous_pipelines): - """Loop a short recording into a bed of a given length""" - logger.debug("Looping audio") - from .audio_utils import loop_audio - - return loop_audio(**arguments) - - -@register_command( - "find_loop_bed", - implementation="dw.tasks.loop_bed.find_loop_bed", - returns="json", - domains={ - "start_seconds": NON_NEGATIVE, - "end_seconds": POSITIVE, - "min_seconds": POSITIVE, - "max_seconds": POSITIVE, - "max_bin_dbfs": NON_POSITIVE, - "max_mean_dbfs": NON_POSITIVE, - "max_spike_db": NON_NEGATIVE, - "crossfade_ms": NON_NEGATIVE, - "loop_seconds": POSITIVE, - "target_bed_dbfs": NON_POSITIVE, - "max_candidates": POSITIVE, - "fps": POSITIVE, - }, - whole_numbers=("max_candidates",), -) -def _handle_find_loop_bed(task, arguments, previous_pipelines): - """Rank the quiet windows of a recording worth looping into a room-tone bed""" - logger.debug("Searching for a loop bed") - from .loop_bed import find_loop_bed - - return find_loop_bed(**arguments) - - -@register_command( - "stabilize_video", - implementation="dw.tasks.stabilize.stabilize_video", - domains={"smooth": FINITE}, - whole_numbers=("smooth",), - media_arguments=("clip",), -) -def _handle_stabilize_video(task, arguments, previous_pipelines): - """Remove a generated clip's accumulated framing drift""" - logger.debug("Stabilizing video") - from .stabilize import stabilize_video - - return stabilize_video(**arguments) - - -@register_command( - "crop_face_track", - implementation="dw.tasks.face_track.crop_face_track", - consumes_device=True, - summary=( - "Follow one face through a clip and crop a steady square around it, " - "with a per-frame record of the box and a face-detail strength." - ), - parameter_descriptions={ - "clip": ( - "Video to track - a file path, an asset:/output: reference, or an " - "earlier step's video. A clip that records its shots resets the " - "track at each boundary." - ), - "crop_size": "Side of every crop in pixels; a multiple of `multiple`.", - "padding": ( - "Space added around the face on each side, as a fraction of its " - "size (0 to 3)." - ), - "gate_full": ( - "Face width over frame width at or below which a frame's strength is 1." - ), - "gate_zero": ( - "Face width over frame width at or above which a frame's strength " - "is 0; must exceed gate_full." - ), - "min_confidence": "Detector score below which a detection is ignored.", - "modulus": ( - "The crop count is padded to modulus * n + remainder frames - the " - "frame grid of the model the crops feed; 8 with remainder 1 is " - "LTX's 8n+1." - ), - "remainder": "The padded count's remainder, from 0 to modulus - 1.", - "multiple": ( - "crop_size must be a multiple of this - the model's frame-size " - "step (32 for LTX)." - ), - "detector_repo": "Hugging Face repo holding the YuNet face detector.", - "detector_file": "The .onnx file in detector_repo.", - }, - domains={ - "crop_size": POSITIVE, - "padding": NON_NEGATIVE, - "gate_full": POSITIVE, - "gate_zero": POSITIVE, - "min_confidence": POSITIVE, - "modulus": POSITIVE, - "remainder": NON_NEGATIVE, - "multiple": POSITIVE, - }, - whole_numbers=("modulus", "remainder", "multiple"), - static_check=face_track_errors, - media_arguments=("clip",), -) -def _handle_crop_face_track(task, arguments, previous_pipelines): - """Crop a steady square around the one face a clip follows""" - logger.debug("Tracking a face") - from .face_track import crop_face_track - - return crop_face_track(device=task.device_for(arguments), **arguments) - - -@register_command( - "paste_face_track", - implementation="dw.tasks.face_track.paste_face_track", - summary=( - "Blend repaired face crops back into the clip crop_face_track tracked, " - "feathered and scaled by each frame's strength, keeping its audio." - ), - parameter_descriptions={ - "clip": ( - "The source video crop_face_track tracked - a file path, an " - "asset:/output: reference, or an earlier step's video. Its audio, " - "frame rate and shots are kept." - ), - "repaired": ( - "The crops after a face-detail pass, still padded to the count " - "crop_face_track produced." - ), - "track": ( - "The track record crop_face_track returned - " - "previous_result:.track, or its saved .json." - ), - "feather": "Fraction of the paste's radius that fades out (0 to 1).", - "color_match": ( - "Match each crop's mean colour to the source inside the mask " - "before blending." - ), - }, - domains={"feather": NON_NEGATIVE}, - static_check=paste_face_track_errors, - media_arguments=("clip", "repaired", "track"), -) -def _handle_paste_face_track(task, arguments, previous_pipelines): - """Blend repaired face crops back into the clip they were cut from""" - logger.debug("Pasting a face track") - from .face_track import paste_face_track - - return paste_face_track(**arguments) - - -@register_command( - "mix_audio", - implementation="dw.tasks.audio_utils.mix_audio", - domains={"gains": NON_NEGATIVE, "sample_rate": POSITIVE}, -) -def _handle_mix_audio(task, arguments, previous_pipelines): - """Layer audio tracks on top of one another, rather than end to end""" - logger.debug("Mixing audio") - from .audio_utils import mix_audio - - return mix_audio(**arguments) - - -@register_command( - "compress_audio", - implementation="dw.tasks.audio_dynamics.compress_audio", - domains={ - "threshold_dbfs": FINITE, - "ratio": POSITIVE, - "attack_ms": NON_NEGATIVE, - "release_ms": NON_NEGATIVE, - "sample_rate": POSITIVE, - }, -) -def _handle_compress_audio(task, arguments, previous_pipelines): - """Shape a track's dynamics with a compressor, limiter or gate""" - logger.debug("Compressing audio") - from .audio_dynamics import compress_audio - - return compress_audio(**arguments) - - -@register_command( - "filter_audio", - implementation="dw.tasks.audio_dynamics.filter_audio", - domains={"cutoff_hz": POSITIVE, "q": FINITE, "sample_rate": POSITIVE}, -) -def _handle_filter_audio(task, arguments, previous_pipelines): - """Run a track through a single lowpass/highpass/bandpass/notch filter""" - logger.debug("Filtering audio") - from .audio_dynamics import filter_audio - - return filter_audio(**arguments) - - -@register_command( - "analyze_audio", - implementation="dw.tasks.audio_dynamics.analyze_audio", - domains={"sample_rate": POSITIVE}, -) -def _handle_analyze_audio(task, arguments, previous_pipelines): - """Measure a track's levels and spectral balance without changing it""" - logger.debug("Analyzing audio") - from .audio_dynamics import analyze_audio - - return analyze_audio(**arguments) - - -@register_command( - "analyze_shots", - implementation="dw.tasks.assess.analyze_shots", - returns="json", - assessment=True, -) -def _handle_analyze_shots(task, arguments, previous_pipelines): - """Measure each shot of a cut's soundtrack and how far apart they sit""" - from .assess import analyze_shots - - return analyze_shots(**arguments) - - -@register_command( - "analyze_seams", - implementation="dw.tasks.assess.analyze_seams", - returns="json", - assessment=True, -) -def _handle_analyze_seams(task, arguments, previous_pipelines): - """Measure every seam of a cut - level step, hole, click, frame jump""" - from .assess import analyze_seams - - return analyze_seams(**arguments) - - -@register_command( - "analyze_sync_drift", - implementation="dw.tasks.assess.analyze_sync_drift", - returns="json", - assessment=True, -) -def _handle_analyze_sync_drift(task, arguments, previous_pipelines): - """Measure how far a cut's soundtrack sits from its picture""" - from .assess import analyze_sync_drift - - return analyze_sync_drift(**arguments) - - -def _voices_argument_errors(arguments): - """attribute_voices' static check, imported on use: its module loads torch.""" - from .voice_attribution import voices_argument_errors - - return voices_argument_errors(arguments) - - -@register_command( - "attribute_voices", - implementation="dw.tasks.voice_attribution.attribute_voices", - consumes_device=True, - returns="json", - domains={"window_seconds": POSITIVE, "min_reference_seconds": POSITIVE}, - static_check=_voices_argument_errors, -) -def _handle_attribute_voices(task, arguments, previous_pipelines): - """Say which reference voice sings each line of a song, by timbre""" - from .voice_attribution import attribute_voices - - return attribute_voices(device=task.device_for(arguments), **arguments) - - -@register_command( - "check_script", - implementation="dw.tasks.script_check.check_script", - consumes_device=True, - returns="json", - domains={"similarity": UNIT}, - static_check=script_lines_errors, -) -def _handle_check_script(task, arguments, previous_pipelines): - """Check that a take speaks its script, line by line""" - from .script_check import check_script - - return check_script(device=task.device_for(arguments), **arguments) - - -@register_command( - "separate_stems", - implementation="dw.tasks.voice_attribution.separate_stems", - consumes_device=True, -) -def _handle_separate_stems(task, arguments, previous_pipelines): - """Split a mix into vocals, drums, bass and other stems, one audio result each""" - from .voice_attribution import separate_stems - - return separate_stems(device=task.device_for(arguments), **arguments) - - @register_command("compose_text", implementation="dw.tasks.compose_text.compose_text") def _handle_compose_text(task, arguments, previous_pipelines): """Join parts written once into one block of text""" @@ -804,431 +124,6 @@ def _handle_get_dict_value(task, arguments, previous_pipelines): return get_dict_value(**arguments) -@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") - image = arguments.pop("image") - model_name = arguments.pop("model_name") - from .upscale import upscale_image - - device = task.device_for(arguments) - return per_frame( - image, - lambda frame: upscale_image(frame, model_name, device=device, **arguments), - ) - - -@register_command( - "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""" - logger.debug("Diffusion upscaling image") - image = arguments.pop("image") - from .diffusion_upscale import diffusion_upscale - - device = task.device_for(arguments) - return per_frame( - image, lambda frame: diffusion_upscale(frame, device=device, **arguments) - ) - - -@register_command( - "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""" - logger.debug("Restoring faces") - image = arguments.pop("image") - model_name = arguments.pop("model_name") - from .restore_faces import restore_faces - from .video_utils import is_video - - if is_video(image) and arguments.get("upsample_img") is not None: - raise ValueError( - "restore_faces: 'upsample_img' is a single background image and " - "cannot be used with a video input (it would be pasted under " - "every frame). Remove 'upsample_img' or pass a single image." - ) - - device = task.device_for(arguments) - return per_frame( - image, - lambda frame: restore_faces(frame, model_name, device=device, **arguments), - ) - - -def _load_media(media): - """An image-or-video argument as something per_frame takes. - - A string is a file path (an asset:/output: reference already resolved): - a video extension is read with its audio, anything else as an image. A - value that is not a string - a PIL Image, an AudioVideo, a frame list - - is already loaded and comes back as itself. Shared by the finishing - commands that take `media` (#603). An image file with transparency loads - as RGBA, so the command puts its alpha back after the op (#775). - """ - if not isinstance(media, str): - return media - import os - - from ..security import ALLOWED_VIDEO_EXTENSIONS - from .video_utils import load_audio_video - - if os.path.splitext(media)[1].lower() in ALLOWED_VIDEO_EXTENSIONS: - return load_audio_video(media) - from ..argument_media import fetch_image - - return fetch_image(media, keep_alpha=True) - - -@register_command( - "grade", - implementation="dw.tasks.grade.grade_image", - summary=( - "Adjust exposure, contrast, tonal range (highlights, shadows, whites, " - "blacks), clarity, white balance, saturation, fade and vignette of " - "an image or a video." - ), - parameter_descriptions={ - "media": ( - "Image or video to grade. An image is a PIL Image; a video is a " - "file path or an asset:/output: reference, read with its audio " - "and graded frame by frame, keeping its frame rate and audio " - "unchanged." - ), - }, - domains={ - "exposure": FINITE, - "contrast": NON_NEGATIVE, - "saturation": NON_NEGATIVE, - "temperature": CLOSED_UNIT, - "tint": CLOSED_UNIT, - "highlights": CLOSED_UNIT, - "shadows": CLOSED_UNIT, - "whites": CLOSED_UNIT, - "blacks": CLOSED_UNIT, - "clarity": CLOSED_UNIT, - "vignette": CLOSED_UNIT, - "fade": UNIT, - }, - media_arguments=("media",), -) -def _handle_grade(task, arguments, previous_pipelines): - """Adjust the exposure, tone, white balance and colour of an image or a video""" - logger.debug("Grading media") - media = _load_media(arguments.pop("media")) - import inspect - - from ..task_domains import check_arguments - from .grade import grade_image - - # A value from a variable or an earlier step never met the static pass - check_arguments("grade", **arguments) - # Read off the signature, so a parameter added there cannot drop out of - # the log (#603) - defaults = { - name: parameter.default - for name, parameter in inspect.signature(grade_image).parameters.items() - if parameter.default is not inspect.Parameter.empty - } - applied = { - name: arguments.get(name, default) - for name, default in defaults.items() - if arguments.get(name, default) != default - } - emit_log( - f"grade: applied {applied}" - if applied - else "grade: no adjustment (all identity)", - command="grade", - **applied, - ) - return per_frame(media, lambda frame: grade_image(frame, **arguments)) - - -_MEDIA_DESCRIPTION = ( - "Image or video. An image is a PIL Image; a video is a file path or an " - "asset:/output: reference, read with its audio and processed frame by " - "frame, keeping its frame count, frame rate and audio unchanged." -) - - -@register_command( - "sharpen", - implementation="dw.tasks.finish.sharpen_image", - summary="Sharpen an image or a video with an unsharp mask.", - parameter_descriptions={"media": _MEDIA_DESCRIPTION}, - domains={"amount": NON_NEGATIVE, "radius": POSITIVE, "threshold": CHANNEL_LEVEL}, - media_arguments=("media",), -) -def _handle_sharpen(task, arguments, previous_pipelines): - """Sharpen an image or a video with an unsharp mask""" - logger.debug("Sharpening media") - media = _load_media(arguments.pop("media")) - from ..task_domains import check_arguments - from .finish import sharpen_image - - # A value from a variable or an earlier step never met the static pass - check_arguments("sharpen", **arguments) - return per_frame(media, lambda frame: sharpen_image(frame, **arguments)) - - -@register_command( - "film_grain", - implementation="dw.tasks.finish.film_grain", - summary=( - "Add seeded film grain to an image or a video, strongest in the " - "midtones; every video frame gets different grain." - ), - parameter_descriptions={ - "media": _MEDIA_DESCRIPTION, - "seed": ( - "The grain's seed. Defaults to the workflow's or step's seed, " - "which a run always has (a random one when the workflow names " - "none, recorded in the manifest), so a rerun reproduces the grain." - ), - }, - domains={"amount": UNIT, "size": AT_LEAST_ONE, "chroma": UNIT, "seed": SEED}, - media_arguments=("media",), -) -def _handle_film_grain(task, arguments, previous_pipelines): - """Add film grain to an image or a video, reproducibly from a seed""" - logger.debug("Adding film grain") - media = _load_media(arguments.pop("media")) - seed = task.seed_for(arguments) - from ..task_domains import check_arguments - from .finish import film_grain - - check_arguments("film_grain", seed=seed, **arguments) - emit_log(f"film_grain: seed {seed}", command="film_grain", seed=seed) - return film_grain(media, seed=seed, **arguments) - - -@register_command( - "apply_lut", - implementation="dw.tasks.lut.apply_lut", - summary=( - "Colour an image or a video through a 3D lookup table - from a .cube " - "file or built from a palette - blended with the original by strength." - ), - parameter_descriptions={"media": _MEDIA_DESCRIPTION}, - domains={"strength": UNIT}, - static_check=lut_errors, - media_arguments=( - "media", - "lut", - ), -) -def _handle_apply_lut(task, arguments, previous_pipelines): - """Apply a .cube or palette 3D lookup table to an image or a video""" - logger.debug("Applying a LUT") - media = _load_media(arguments.pop("media")) - from ..task_domains import check_arguments - from .lut import apply_lut, lookup_for - - lut = arguments.pop("lut", None) - palette = arguments.pop("palette", None) - # A value from a variable or an earlier step never met the static pass - check_arguments("apply_lut", **arguments) - # Read or built once, not once per video frame - lookup = lookup_for(lut, palette) - return per_frame(media, lambda frame: apply_lut(frame, lookup, **arguments)) - - -@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") - image = arguments.pop("image") - prompt = arguments.pop("prompt") - from .segment import segment_image - - device = task.device_for(arguments) - return per_frame( - image, lambda frame: segment_image(frame, prompt, device=device, **arguments) - ) - - -@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""" - logger.debug("Interpolating frames") - video = arguments.pop("video") - from .interpolate_frames import interpolate_frames - - return interpolate_frames(video, device=task.device_for(arguments), **arguments) - - -@register_command( - "upscale_h3_latents", - implementation="dw.tasks.h3_latent_upscale.upscale_h3_latents", - consumes_device=True, - summary=( - "Resize MiniMax-H3 video latents to a larger canvas (e.g. a 960x544 " - "take to 1344x768) without denoising again; decode_h3_latents turns " - "the result into frames. The whole promoting workflow is " - "get_guide('workflows', section='upscale_h3_latents')." - ), - parameter_descriptions={ - "latents": ( - "H3 video latents, (batch, 24, frames, height, width) - a MiniMax-H3 " - "pipeline step's 'latents' output, e.g. 'previous_result:base.latents' " - "from a step whose output lists 'latents'." - ), - "width": ( - "Target width in pixels: a multiple of 16, 1x to 4x the latents' " - "own width, within H3's 1344x768 canvas." - ), - "height": ( - "Target height in pixels: a multiple of 16, 1x to 4x the latents' " - "own height, within H3's 1344x768 canvas." - ), - "model_name": ( - "Hugging Face repo id holding the upscaler, never a path (default " - "LBH-123-AI/Minimax_h3_latent_Upscaler, read at a pinned revision)." - ), - "weight_name": ( - "The safetensors file within model_name, a bare file name - no " - "'/' (default: the bf16 v1 checkpoint; in the default repo it is " - "read from the v1 checkpoint folder)." - ), - }, - domains={"width": POSITIVE, "height": POSITIVE}, -) -def _handle_upscale_h3_latents(task, arguments, previous_pipelines): - """Resize MiniMax-H3 video latents to a larger canvas""" - logger.debug("Upscaling H3 latents") - latents = arguments.pop("latents") - from .h3_latent_upscale import upscale_h3_latents - - return upscale_h3_latents(latents, device=task.device_for(arguments), **arguments) - - -@register_command( - "decode_h3_latents", - implementation="dw.tasks.h3_latent_upscale.decode_h3_latents", - consumes_device=True, - summary=( - "Decode MiniMax-H3 video latents into frames with the H3 video VAE, " - "through diffusers' own H3 decode block. Returns video only, at 24 fps; " - "pair_audio puts a soundtrack back under it. The whole promoting " - "workflow is get_guide('workflows', section='upscale_h3_latents')." - ), - parameter_descriptions={ - "latents": ( - "H3 video latents, (batch, 24, frames, height, width) - a pipeline " - "step's 'latents' output or an upscale_h3_latents result." - ), - "model_name": ( - "The MiniMax-H3 repo whose 'vae' subfolder decodes (default " - "MiniMaxAI/MiniMax-H3)." - ), - }, -) -def _handle_decode_h3_latents(task, arguments, previous_pipelines): - """Decode MiniMax-H3 video latents into frames""" - logger.debug("Decoding H3 latents") - latents = arguments.pop("latents") - from .h3_latent_upscale import decode_h3_latents - - return decode_h3_latents(latents, device=task.device_for(arguments), **arguments) - - -@register_command( - "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""" - logger.debug("Captioning image") - image = arguments.pop("image") - from .image_to_text import image_to_text - - return image_to_text(image, device=task.device_for(arguments), **arguments) - - -@register_command( - "judge", - implementation="dw.tasks.judge.judge", - consumes_device=True, - returns="scalar", -) -def _handle_judge(task, arguments, previous_pipelines): - """Score an image against a rubric with a vision-language model""" - logger.debug("Judging") - image = arguments.pop("image") - from .judge import judge - - return judge(image, device=task.device_for(arguments), **arguments) - - -@register_command( - "transcribe_audio", - implementation="dw.tasks.audio_transcription.transcribe_audio", - consumes_device=True, -) -def _handle_transcribe_audio(task, arguments, previous_pipelines): - """Transcribe spoken audio to text""" - logger.debug("Transcribing audio") - audio = arguments.pop("audio") - from .audio_transcription import transcribe_audio - - return transcribe_audio(audio, device=task.device_for(arguments), **arguments) - - -@register_command( - "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""" - logger.debug("Generating text") - prompt = arguments.pop("prompt") - from .text_generation import generate_text - - return generate_text(prompt, device=task.device_for(arguments), **arguments) - - -@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""" - logger.debug("Generating speech") - if ("text" in arguments) == ("messages" in arguments): - raise ValueError( - "generate_speech needs exactly one of 'text' (the line to speak) " - "or 'messages' (chat-templated input for a model such as VibeVoice)" - ) - text = arguments.pop("text", None) - from .speech_generation import generate_speech - - return generate_speech( - text, - device=task.device_for(arguments), - seed=task.seed_for(arguments), - **arguments, - ) - - @register_command( "extract_sections", implementation="dw.tasks.text_sections.extract_sections" ) diff --git a/dw/tasks/video_handlers.py b/dw/tasks/video_handlers.py new file mode 100644 index 00000000..066fcaf6 --- /dev/null +++ b/dw/tasks/video_handlers.py @@ -0,0 +1,381 @@ +"""The video and frame task commands' handlers. + +Split from `dw/tasks/task.py` (#790), which had grown past the module-size +ceiling with every handler in one file. These are the commands that cut, +join, fit and re-frame video: joining shots (with the audio generated beside +them), frame runs and grids, long-video windows, fitting a clip to a model's +working size and back (#603), stabilizing, and the face-track crop and paste. + +Each handler imports its implementation on use, as task.py's did - the video +modules pull in av and numpy, a cost a workflow that never edits video +should not pay at startup. `dw/tasks/task.py` imports this module, which is +what registers its commands (`docs/TASKS.md` *Adding a task*). +""" + +import logging + +from .registry import register_command +from ..task_domains import ( + FINITE, + FIT_MODES, + INGREDIENTS_FITS, + INGREDIENTS_LAYOUTS, + JOIN_WINDOWS_CURVES, + NON_NEGATIVE, + NON_POSITIVE, + POSITIVE, + face_track_errors, + fit_to_model_errors, + ingredients_grid_errors, + join_windows_errors, + paste_face_track_errors, + window_video_errors, +) + +logger = logging.getLogger("dw") + + +@register_command( + "concat_videos", + implementation="dw.tasks.concat_videos.concat_videos", + domains={ + "trim_frames": NON_NEGATIVE, + "crossfade_ms": NON_NEGATIVE, + "audio_bleed_ms": NON_NEGATIVE, + "audio_bleed_gain_db": FINITE, + "match_levels_dbfs": FINITE, + "seam_fade_ms": NON_NEGATIVE, + "fps": POSITIVE, + "sample_rate": POSITIVE, + }, +) +def _handle_concat_videos(task, arguments, previous_pipelines): + """Concatenate videos - and the audio generated with them - into one""" + logger.debug("Concatenating videos") + from .concat_videos import concat_videos + + return concat_videos(**arguments) + + +@register_command( + "dissolve_videos", + implementation="dw.tasks.dissolve_videos.dissolve_videos", + domains={ + "dissolve_frames": NON_NEGATIVE, + "fade_in_frames": NON_NEGATIVE, + "fade_out_frames": NON_NEGATIVE, + "match_levels_dbfs": FINITE, + "fps": POSITIVE, + }, +) +def _handle_dissolve_videos(task, arguments, previous_pipelines): + """Join videos with cross-dissolves, fading the whole from and to a colour""" + logger.debug("Dissolving videos") + from .dissolve_videos import dissolve_videos + + return dissolve_videos(**arguments) + + +@register_command( + "join_into_song", + implementation="dw.tasks.join_into_song.join_into_song", + domains={ + "cue_seconds": NON_NEGATIVE, + "duck_delay_ms": NON_NEGATIVE, + "duck_db": NON_POSITIVE, + "duck_ramp_ms": NON_NEGATIVE, + "dialogue_target_lufs": FINITE, + "fps": POSITIVE, + }, +) +def _handle_join_into_song(task, arguments, previous_pipelines): + """Join dialogue shots and song shots into one video over the unbroken song""" + logger.debug("Joining dialogue into a song") + from .join_into_song import join_into_song + + return join_into_song(**arguments) + + +@register_command("video_frames", implementation="dw.tasks.video_utils.frames_as_array") +def _handle_video_frames(task, arguments, previous_pipelines): + """The frames of a generated video, as one array a later step can condition on""" + logger.debug("Extracting video frames") + from .video_utils import frames_as_array + + return frames_as_array(**arguments) + + +@register_command( + "loop_frames", + implementation="dw.tasks.video_utils.loop_frames", + domains={"num_frames": POSITIVE}, + whole_numbers=("num_frames",), +) +def _handle_loop_frames(task, arguments, previous_pipelines): + """Repeat a still or a short clip into a run of a given length""" + logger.debug("Looping frames") + from .video_utils import loop_frames + + return loop_frames(**arguments) + + +@register_command( + "window_video", + implementation="dw.tasks.windows.window_video", + domains={ + "index": NON_NEGATIVE, + "num_frames": POSITIVE, + "overlap": NON_NEGATIVE, + "fps": POSITIVE, + }, + whole_numbers=("index", "num_frames", "overlap"), + static_check=window_video_errors, +) +def _handle_window_video(task, arguments, previous_pipelines): + """Cut one overlapping, fixed-length window out of a long video""" + logger.debug("Cutting a video window") + from .windows import window_video + + return window_video(**arguments) + + +@register_command( + "fit_to_model", + implementation="dw.tasks.fit.fit_to_model", + summary=( + "Fit a video into a model's working size and frame count - letterbox, " + "stretch or crop - with a record restore_to_source reads to undo it." + ), + parameter_descriptions={ + "video": ( + "The source - an earlier step's video, or an asset:/output: " + "reference. Its fps is kept; its soundtrack is not." + ), + "width": "The model's working width in pixels.", + "height": "The model's working height in pixels.", + "num_frames": ( + "The model's frame count. A longer source is cut to its first " + "num_frames frames; a shorter one holds its last frame." + ), + "mode": ( + "letterbox (scale to fit, centred on black), stretch (resize to " + "fill exactly) or crop (scale to fill, centre-crop)." + ), + "downscale": ( + "Fit into width/downscale x height/downscale instead, default 1 - " + "2 when width/height are a 2x model's output size. Both must be " + "divisible by it; the fit record's model size is the divided one." + ), + }, + domains={ + "width": POSITIVE, + "height": POSITIVE, + "num_frames": POSITIVE, + "downscale": POSITIVE, + }, + whole_numbers=("width", "height", "num_frames", "downscale"), + choices={"mode": FIT_MODES}, + static_check=fit_to_model_errors, +) +def _handle_fit_to_model(task, arguments, previous_pipelines): + """Fit a video into a model's working size and frame count""" + logger.debug("Fitting a video to the model") + from .fit import fit_to_model + + return fit_to_model(**arguments) + + +@register_command( + "restore_to_source", + implementation="dw.tasks.fit.restore_to_source", + summary=( + "Put a model's output for a fit_to_model video back at the source's " + "size (times the model's scale, inferred) and frame count." + ), + parameter_descriptions={ + "video": ( + "The model's output for the fitted video, at the fitted size or a " + "uniform multiple of it - 2x restores to twice the source." + ), + "fit": ( + "The record fit_to_model returned - previous_result:.fit, or " + "its saved .json. A crop fit restores only the part it kept, at " + "the source's pixel density." + ), + }, + media_arguments=("fit",), +) +def _handle_restore_to_source(task, arguments, previous_pipelines): + """Put a fitted video back at its source's size and length""" + logger.debug("Restoring a video to its source") + from .fit import restore_to_source + + return restore_to_source(**arguments) + + +@register_command( + "join_windows", + implementation="dw.tasks.windows.join_windows", + domains={"num_frames": POSITIVE, "overlap": NON_NEGATIVE, "fps": POSITIVE}, + whole_numbers=("num_frames", "overlap"), + choices={"curve": JOIN_WINDOWS_CURVES}, + static_check=join_windows_errors, + media_arguments=("source",), +) +def _handle_join_windows(task, arguments, previous_pipelines): + """Blend processed overlapping windows back into one video the source's length""" + logger.debug("Joining video windows") + from .windows import join_windows + + return join_windows(**arguments) + + +@register_command( + "frame_grid", + implementation="dw.tasks.video_utils.frame_grid", + domains={"count": POSITIVE, "columns": POSITIVE, "tile_width": POSITIVE}, + whole_numbers=("count", "columns", "tile_width"), +) +def _handle_frame_grid(task, arguments, previous_pipelines): + """Tile evenly sampled frames of a video into one contact-sheet image""" + logger.debug("Building frame grid") + from .video_utils import frame_grid + + return frame_grid(**arguments) + + +@register_command( + "ingredients_grid", + implementation="dw.tasks.image_utils.ingredients_grid", + domains={ + "width": POSITIVE, + "height": POSITIVE, + "gap": NON_NEGATIVE, + "max_images": POSITIVE, + }, + whole_numbers=("width", "height", "gap", "max_images"), + choices={"layout": INGREDIENTS_LAYOUTS, "fit": INGREDIENTS_FITS}, + static_check=ingredients_grid_errors, +) +def _handle_ingredients_grid(task, arguments, previous_pipelines): + """Lay individual images out as one reference sheet""" + logger.debug("Building ingredients grid") + from .image_utils import ingredients_grid + + return ingredients_grid(**arguments) + + +@register_command( + "stabilize_video", + implementation="dw.tasks.stabilize.stabilize_video", + domains={"smooth": FINITE}, + whole_numbers=("smooth",), + media_arguments=("clip",), +) +def _handle_stabilize_video(task, arguments, previous_pipelines): + """Remove a generated clip's accumulated framing drift""" + logger.debug("Stabilizing video") + from .stabilize import stabilize_video + + return stabilize_video(**arguments) + + +@register_command( + "crop_face_track", + implementation="dw.tasks.face_track.crop_face_track", + consumes_device=True, + summary=( + "Follow one face through a clip and crop a steady square around it, " + "with a per-frame record of the box and a face-detail strength." + ), + parameter_descriptions={ + "clip": ( + "Video to track - a file path, an asset:/output: reference, or an " + "earlier step's video. A clip that records its shots resets the " + "track at each boundary." + ), + "crop_size": "Side of every crop in pixels; a multiple of `multiple`.", + "padding": ( + "Space added around the face on each side, as a fraction of its " + "size (0 to 3)." + ), + "gate_full": ( + "Face width over frame width at or below which a frame's strength is 1." + ), + "gate_zero": ( + "Face width over frame width at or above which a frame's strength " + "is 0; must exceed gate_full." + ), + "min_confidence": "Detector score below which a detection is ignored.", + "modulus": ( + "The crop count is padded to modulus * n + remainder frames - the " + "frame grid of the model the crops feed; 8 with remainder 1 is " + "LTX's 8n+1." + ), + "remainder": "The padded count's remainder, from 0 to modulus - 1.", + "multiple": ( + "crop_size must be a multiple of this - the model's frame-size " + "step (32 for LTX)." + ), + "detector_repo": "Hugging Face repo holding the YuNet face detector.", + "detector_file": "The .onnx file in detector_repo.", + }, + domains={ + "crop_size": POSITIVE, + "padding": NON_NEGATIVE, + "gate_full": POSITIVE, + "gate_zero": POSITIVE, + "min_confidence": POSITIVE, + "modulus": POSITIVE, + "remainder": NON_NEGATIVE, + "multiple": POSITIVE, + }, + whole_numbers=("modulus", "remainder", "multiple"), + static_check=face_track_errors, + media_arguments=("clip",), +) +def _handle_crop_face_track(task, arguments, previous_pipelines): + """Crop a steady square around the one face a clip follows""" + logger.debug("Tracking a face") + from .face_track import crop_face_track + + return crop_face_track(device=task.device_for(arguments), **arguments) + + +@register_command( + "paste_face_track", + implementation="dw.tasks.face_track.paste_face_track", + summary=( + "Blend repaired face crops back into the clip crop_face_track tracked, " + "feathered and scaled by each frame's strength, keeping its audio." + ), + parameter_descriptions={ + "clip": ( + "The source video crop_face_track tracked - a file path, an " + "asset:/output: reference, or an earlier step's video. Its audio, " + "frame rate and shots are kept." + ), + "repaired": ( + "The crops after a face-detail pass, still padded to the count " + "crop_face_track produced." + ), + "track": ( + "The track record crop_face_track returned - " + "previous_result:.track, or its saved .json." + ), + "feather": "Fraction of the paste's radius that fades out (0 to 1).", + "color_match": ( + "Match each crop's mean colour to the source inside the mask " + "before blending." + ), + }, + domains={"feather": NON_NEGATIVE}, + static_check=paste_face_track_errors, + media_arguments=("clip", "repaired", "track"), +) +def _handle_paste_face_track(task, arguments, previous_pipelines): + """Blend repaired face crops back into the clip they were cut from""" + logger.debug("Pasting a face track") + from .face_track import paste_face_track + + return paste_face_track(**arguments) diff --git a/tests/test_finish.py b/tests/test_finish.py index fbe1893b..b24b11cb 100644 --- a/tests/test_finish.py +++ b/tests/test_finish.py @@ -439,14 +439,14 @@ def test_a_file_path_comes_back_rgba_with_its_alpha( assert numpy.array_equal(_array(result.getchannel("A")), alpha) def test_an_opaque_file_still_loads_rgb(self, tmp_path): - from dw.tasks.task import _load_media + from dw.tasks.finish_handlers import _load_media path = tmp_path / "opaque.png" Image.new("RGB", (8, 8), "red").save(path) assert _load_media(str(path)).mode == "RGB" def test_a_palette_file_with_transparency_loads_rgba(self, tmp_path): - from dw.tasks.task import _load_media + from dw.tasks.finish_handlers import _load_media path = tmp_path / "palette.png" image = Image.new("P", (8, 8), 0) diff --git a/tests/test_restore_faces_video.py b/tests/test_restore_faces_video.py index 8bb843be..e2c773de 100644 --- a/tests/test_restore_faces_video.py +++ b/tests/test_restore_faces_video.py @@ -6,7 +6,7 @@ from PIL import Image from dw.media_types import AudioVideo -from dw.tasks.task import _handle_restore_faces +from dw.tasks.model_handlers import _handle_restore_faces def _task(): diff --git a/tests/test_speech_generation.py b/tests/test_speech_generation.py index 88233a76..00b3e72e 100644 --- a/tests/test_speech_generation.py +++ b/tests/test_speech_generation.py @@ -385,7 +385,7 @@ def test_output_shape_is_batch_of_one_by_512( class TestHandleSpeechGeneration(unittest.TestCase): - """Covers the task.py dispatch handler directly, since + """Covers the model_handlers.py dispatch handler directly, since test_task_discovery.py only introspects the command registry rather than running handlers.""" From 7f5e2acbc290a82eec401b9a91f7600c60864581 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 9 Oct 2026 20:16:18 -0500 Subject: [PATCH 06/46] refactor(references): one iter_steps walk for every step-validation pass (#790) Twenty-odd validation passes each copied the same walk over an expanded definition's steps: skip a non-list, skip a non-dict step, pick out its pipeline/task/result block, map it back with author_index and build the " in member ''" suffix. dw/references.py, which already owns author_index and MEMBER_SEPARATOR, now owns that walk too (iter_steps, member_suffix), and every copy in dw/ goes through it. Behaviour and messages are unchanged. select_errors and guides._step_problems each give a block to a helper (_select_rule_errors, _guide_problems) to come in under C901 15. duplicate_blocks 8 -> 0, complex_functions 16 -> 14. Co-Authored-By: Claude Opus 5.5 --- dw/adapter_compatibility.py | 32 ++----- dw/content_types.py | 22 ++--- dw/dissolve_frame_errors.py | 24 ++---- dw/guides.py | 132 +++++++++++++--------------- dw/hold_audio.py | 42 ++------- dw/introspection.py | 22 +---- dw/kernel_availability.py | 23 +---- dw/locations.py | 8 +- dw/previous_results.py | 22 ++--- dw/reference_limits.py | 22 ++--- dw/reference_names.py | 19 +--- dw/references.py | 48 ++++++++++- dw/scalar_result_validation.py | 24 ++---- dw/shot_span_preflight.py | 24 ++---- dw/slice_preflight.py | 24 ++---- dw/step_value_checks.py | 153 +++++++++++++-------------------- dw/subfolders.py | 22 +---- dw/task_domains.py | 23 +---- dw/type_references.py | 39 ++------- dw/video_extensions.py | 19 +--- dw/video_size_errors.py | 24 ++---- dw/window_count_errors.py | 24 ++---- tests/test_references.py | 33 +++++++ 23 files changed, 298 insertions(+), 527 deletions(-) diff --git a/dw/adapter_compatibility.py b/dw/adapter_compatibility.py index 6dabd985..b4bf34f8 100644 --- a/dw/adapter_compatibility.py +++ b/dw/adapter_compatibility.py @@ -30,7 +30,7 @@ import logging from . import references -from .references import MEMBER_SEPARATOR, render_path +from .references import render_path logger = logging.getLogger("dw") @@ -109,13 +109,9 @@ def _lora_problems(steps, source_indices, written=None, supplied=()): """ written_steps = (written or {}).get("steps") found = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - pipeline = step.get("pipeline") - if not isinstance(pipeline, dict): - continue - source = references.author_index(source_indices, index) + for _, _, pipeline, source, where in references.iter_steps( + steps, source_indices, "pipeline" + ): from_pretrained = pipeline.get(FROM_PRETRAINED_KEY) workflow = ( from_pretrained.get(WORKFLOW_KEY) @@ -127,12 +123,6 @@ def _lora_problems(steps, source_indices, written=None, supplied=()): loras = pipeline.get(LORAS_KEY) if not isinstance(loras, list): continue - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) for position, lora in enumerate(loras): if not isinstance(lora, dict): continue @@ -212,18 +202,12 @@ def _disabled_loras(steps, source_indices, written=None, supplied=()): engine's, and so is saying that it was used.""" written_steps = (written or {}).get("steps") found = [] - for index, step in enumerate(steps): - pipeline = step.get("pipeline") if isinstance(step, dict) else None - loras = pipeline.get(LORAS_KEY) if isinstance(pipeline, dict) else None + for _, _, pipeline, source, where in references.iter_steps( + steps, source_indices, "pipeline" + ): + loras = pipeline.get(LORAS_KEY) if not isinstance(loras, list): continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) for position, lora in enumerate(loras): if not isinstance(lora, dict) or lora.get(MODEL_NAME_KEY) is not None: continue diff --git a/dw/content_types.py b/dw/content_types.py index a9d03320..aa53ed1d 100644 --- a/dw/content_types.py +++ b/dw/content_types.py @@ -29,7 +29,7 @@ import mimetypes from . import references -from .references import MEMBER_SEPARATOR, render_path +from .references import render_path from .security import InvalidInputError, validate_content_type logger = logging.getLogger("dw") @@ -126,29 +126,17 @@ def content_type_errors(workflow_definition, source_indices=None): several, and the path an error carries has to be one the author can find in the file they wrote; the member is named in the message. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - result = step.get("result") - if not isinstance(result, dict) or CONTENT_TYPE_KEY not in result: + for _, _, result, source, where in references.iter_steps( + workflow_definition.get("steps"), source_indices, "result" + ): + if CONTENT_TYPE_KEY not in result: continue value = result[CONTENT_TYPE_KEY] # A prefix substitution resolves before this pass: one still spelled # out is the undeclared-variable pass's complaint, not a shape error if references.is_ref(references.SUBSTITUTED, value): continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) fault = content_type_fault(value) if fault is not None: errors.append( diff --git a/dw/dissolve_frame_errors.py b/dw/dissolve_frame_errors.py index 1ef8bff6..9e487dec 100644 --- a/dw/dissolve_frame_errors.py +++ b/dw/dissolve_frame_errors.py @@ -31,10 +31,10 @@ already silent, shape of problem with no run-time error to move earlier. """ -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .media import probe_metadata from .probe_paths import resolve_probe_path -from .references import author_index +from .references import iter_steps from .task_domains import dissolve_shortfalls @@ -63,16 +63,11 @@ def dissolve_frame_errors( a memoizing wrapper so a file referenced by more than one check is probed once per validation rather than once per check. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - task = step.get("task") - if not isinstance(task, dict) or task.get("command") != "dissolve_videos": + for _, _, task, source, where in iter_steps( + workflow_definition.get("steps"), source_indices, "task" + ): + if task.get("command") != "dissolve_videos": continue arguments = task.get("arguments") if not isinstance(arguments, dict): @@ -96,13 +91,6 @@ def dissolve_frame_errors( if not problems: continue - source = author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) errors.append( { "path": render_path( diff --git a/dw/guides.py b/dw/guides.py index fd20f6c1..36f13f74 100644 --- a/dw/guides.py +++ b/dw/guides.py @@ -32,7 +32,7 @@ REFERENCE_WORKFLOWS, WORKFLOW_KEY, ) -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .pipeline_processors.h3_guides import default_num_frames from .pipeline_processors.h3_rules import ( CHAIN_CONTINUITY_MODES, @@ -116,6 +116,53 @@ def _takes_guides_problem(pipeline, arguments): return None +def _guide_problems(guide, render, base_dir, probe): + """[message] for one entry of a step's `guides`, in the order + `_step_problems` reports them; `render` is the step's literal render + length, or None.""" + if not isinstance(guide, dict): + return [f"a guide must be {{video, frame}}, got {type(guide).__name__}"] + problems = [] + unknown = sorted(set(guide) - set(GUIDE_KEYS)) + if unknown: + problems.append( + f"unknown key(s) {unknown} - a guide is {{video, frame}}, with " + f"an optional 'audio'" + ) + if "video" not in guide or "frame" not in guide: + problems.append("a guide needs both 'video' and 'frame'") + return problems + frame = guide["frame"] + frame_known = not ( + isinstance(frame, str) and references.is_ref(references.UNRESOLVED, frame) + ) + frame_ok = False + if frame_known: + problem = guide_frame_problem(frame) + if problem: + problems.append(problem) + else: + frame_ok = True + with_audio = guide.get("audio", False) + if isinstance(with_audio, str) and references.is_ref( + references.UNRESOLVED, with_audio + ): + with_audio = False + elif not isinstance(with_audio, bool): + problems.append( + f"'audio' must be true or false, got {type(with_audio).__name__}" + ) + with_audio = False + problem, count = _clip_frames(guide["video"], base_dir, probe, with_audio) + if problem: + problems.append(problem) + elif frame_ok and count is not None and render is not None: + problem = guide_end_problem(frame, snap_guide_length(count), render) + if problem: + problems.append(problem) + return problems + + def _step_problems(pipeline, arguments, base_dir, probe): """[(index or None, message)] for one step's `guides`.""" guides = arguments[GUIDES_INPUT] @@ -139,54 +186,10 @@ def _step_problems(pipeline, arguments, base_dir, probe): ) render = _render_length(arguments) for index, guide in enumerate(guides): - if not isinstance(guide, dict): - problems.append( - (index, f"a guide must be {{video, frame}}, got {type(guide).__name__}") - ) - continue - unknown = sorted(set(guide) - set(GUIDE_KEYS)) - if unknown: - problems.append( - ( - index, - f"unknown key(s) {unknown} - a guide is {{video, frame}}, with " - f"an optional 'audio'", - ) - ) - if "video" not in guide or "frame" not in guide: - problems.append((index, "a guide needs both 'video' and 'frame'")) - continue - frame = guide["frame"] - frame_known = not ( - isinstance(frame, str) and references.is_ref(references.UNRESOLVED, frame) + problems.extend( + (index, message) + for message in _guide_problems(guide, render, base_dir, probe) ) - frame_ok = False - if frame_known: - problem = guide_frame_problem(frame) - if problem: - problems.append((index, problem)) - else: - frame_ok = True - with_audio = guide.get("audio", False) - if isinstance(with_audio, str) and references.is_ref( - references.UNRESOLVED, with_audio - ): - with_audio = False - elif not isinstance(with_audio, bool): - problems.append( - ( - index, - f"'audio' must be true or false, got {type(with_audio).__name__}", - ) - ) - with_audio = False - problem, count = _clip_frames(guide["video"], base_dir, probe, with_audio) - if problem: - problems.append((index, problem)) - elif frame_ok and count is not None and render is not None: - problem = guide_end_problem(frame, snap_guide_length(count), render) - if problem: - problems.append((index, problem)) return problems @@ -197,27 +200,13 @@ def guides_errors(workflow_definition, source_indices=None, base_dir=None, probe does. `probe` is the metadata-only probe (or the validation's memoizing wrapper); without one the clips themselves are not looked at. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - pipeline = step.get("pipeline") - if not isinstance(pipeline, dict): - continue + for _, _, pipeline, source, where in references.iter_steps( + workflow_definition.get("steps"), source_indices, "pipeline" + ): arguments = pipeline.get("arguments") if not isinstance(arguments, dict) or arguments.get(GUIDES_INPUT) is None: continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) base = ("steps", source, "pipeline", "arguments", GUIDES_INPUT) for entry, message in _step_problems(pipeline, arguments, base_dir, probe): path = base if entry is None else base + (entry,) @@ -229,14 +218,11 @@ def guide_chain_errors(workflow_definition, source_indices=None): """Every chain block refused for its continuity before the run, as [{path, message}]: an unknown mode (the schema lets a `variable:` through), and a `guide` chain its step or its own settings cannot run.""" - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - pipeline = step.get("pipeline") if isinstance(step, dict) else None - chain = pipeline.get("chain") if isinstance(pipeline, dict) else None + for _, _, pipeline, source, _ in references.iter_steps( + workflow_definition.get("steps"), source_indices, "pipeline" + ): + chain = pipeline.get("chain") if not isinstance(chain, dict): continue continuity = chain.get("continuity", "last_frame") @@ -244,7 +230,7 @@ def guide_chain_errors(workflow_definition, source_indices=None): references.UNRESOLVED, continuity ): continue - base = ("steps", references.author_index(source_indices, index), "pipeline") + base = ("steps", source, "pipeline") if continuity not in CHAIN_CONTINUITY_MODES: errors.append( { diff --git a/dw/hold_audio.py b/dw/hold_audio.py index 103477ef..f29164aa 100644 --- a/dw/hold_audio.py +++ b/dw/hold_audio.py @@ -29,7 +29,7 @@ from . import references from .argument_media import is_media_reference -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .pipeline_processors.h3_rules import ( HOLD_AUDIO_INPUT, REFINE_STRENGTH_INPUT, @@ -83,27 +83,13 @@ def hold_audio_errors(workflow_definition, source_indices=None): back to the one the author wrote, and a path inside a `for_each` member names the member. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - pipeline = step.get("pipeline") - if not isinstance(pipeline, dict): - continue + for _, _, pipeline, source, where in references.iter_steps( + workflow_definition.get("steps"), source_indices, "pipeline" + ): arguments = pipeline.get("arguments") if not isinstance(arguments, dict) or arguments.get(HOLD_AUDIO_INPUT) is None: continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) path = render_path(("steps", source, "pipeline", "arguments", HOLD_AUDIO_INPUT)) for problem in (not_h3(pipeline), _not_audio(arguments[HOLD_AUDIO_INPUT])): if problem is not None: @@ -114,30 +100,16 @@ def hold_audio_errors(workflow_definition, source_indices=None): def refine_strength_errors(workflow_definition, source_indices=None): """Every `refine_strength` argument refused before the run, as [{path, message}] - walked the way `hold_audio_errors` walks.""" - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - pipeline = step.get("pipeline") - if not isinstance(pipeline, dict): - continue + for _, _, pipeline, source, where in references.iter_steps( + workflow_definition.get("steps"), source_indices, "pipeline" + ): arguments = pipeline.get("arguments") if ( not isinstance(arguments, dict) or arguments.get(REFINE_STRENGTH_INPUT) is None ): continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) path = render_path( ("steps", source, "pipeline", "arguments", REFINE_STRENGTH_INPUT) ) diff --git a/dw/introspection.py b/dw/introspection.py index b5dba440..553314c0 100644 --- a/dw/introspection.py +++ b/dw/introspection.py @@ -818,13 +818,9 @@ def task_signature_errors( no arguments of its own can treat it as caller input rather than a defect in the document. """ - from .for_each import MEMBER_SEPARATOR, render_path + from .for_each import render_path from .tasks.task import task_command_info - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - written_steps = ( (written_definition or {}).get("steps") or [] if isinstance(written_definition, dict) @@ -837,25 +833,15 @@ def task_signature_errors( ) errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - task = step.get("task") - if not isinstance(task, dict): - continue + for _, _, task, source, where in references.iter_steps( + workflow_definition.get("steps"), source_indices, "task" + ): command = task.get("command") # 'inputs' is a list template rather than a named-argument dict - # the command consumes it whole, so there is no name to miss arguments = task.get("arguments") if not isinstance(command, str): continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) try: task_command_info(command) diff --git a/dw/kernel_availability.py b/dw/kernel_availability.py index e0844c27..a67f434f 100644 --- a/dw/kernel_availability.py +++ b/dw/kernel_availability.py @@ -29,7 +29,7 @@ import inspect from . import references -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .type_helpers import load_type_from_name ATTN_PROCESSOR_KEY = "attn_processor_type" @@ -178,31 +178,16 @@ def kernel_availability_errors(workflow_definition, source_indices=None): several, and the path an error carries has to be one the author can find in the file they wrote; the member is named in the message. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - pipeline = step.get("pipeline") - if not isinstance(pipeline, dict): - continue + for _, _, pipeline, source, where in references.iter_steps( + workflow_definition.get("steps"), source_indices, "pipeline" + ): configuration = pipeline.get("configuration") for path_suffix, value in _attn_processor_locations(configuration): fault = kernel_availability_fault(value) if fault is None: continue - - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) errors.append( { "path": render_path( diff --git a/dw/locations.py b/dw/locations.py index cce32411..7b896dc0 100644 --- a/dw/locations.py +++ b/dw/locations.py @@ -656,11 +656,9 @@ def location_errors(definition, source_indices=None, base_dir=None): base_dir: The workflow file's directory """ errors = [] - steps = definition.get("steps") or [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - source = references.author_index(source_indices, index) + for _, step, _, source, _ in references.iter_steps( + definition.get("steps"), source_indices + ): _walk(step, f"steps[{source}]", base_dir, errors, _weight_rules(step)) _task_media_errors(step, f"steps[{source}]", base_dir, errors) return errors diff --git a/dw/previous_results.py b/dw/previous_results.py index f444d441..6f4cbc79 100644 --- a/dw/previous_results.py +++ b/dw/previous_results.py @@ -6,7 +6,7 @@ from .arguments import ( build_objects, ) -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .step_cache import reference_resolves_to logger = logging.getLogger("dw") @@ -380,30 +380,20 @@ def previous_result_reference_errors(workflow_definition, source_indices=None): the path an error carries has to be one the author can find in the file they wrote. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] seen = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue + # `where` names which expansion it was: the source path alone points at + # the one step the author wrote, and every member reports the same path + for _, step, _, source, where in references.iter_steps( + workflow_definition.get("steps"), source_indices + ): name = step.get("name") found = {} _collect_reference_paths(step, (), found) for path, reference in sorted(found.items(), key=lambda item: str(item[0])): if any(reference_resolves_to(reference, name) for name in seen): continue - source = references.author_index(source_indices, index) location = render_path(("steps", source) + path) - # Which expansion it was: the source path alone points at the one - # step the author wrote, and every member reports the same path - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) errors.append( { "path": location, diff --git a/dw/reference_limits.py b/dw/reference_limits.py index 5add5387..0eb628c2 100644 --- a/dw/reference_limits.py +++ b/dw/reference_limits.py @@ -23,7 +23,7 @@ import logging from . import references -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path logger = logging.getLogger("dw") @@ -174,25 +174,13 @@ def reference_limit_errors(workflow_definition, source_indices=None): wrote, and the member is named in the message - the same convention subfolder_errors uses. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - pipeline = step.get("pipeline") - arguments = pipeline.get("arguments") if isinstance(pipeline, dict) else None + for _, _, pipeline, source, where in references.iter_steps( + workflow_definition.get("steps"), source_indices, "pipeline" + ): + arguments = pipeline.get("arguments") if not isinstance(arguments, dict): continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) for key, value in arguments.items(): found = _kinds(value) if found is None: diff --git a/dw/reference_names.py b/dw/reference_names.py index c3d856ff..afe6f8fc 100644 --- a/dw/reference_names.py +++ b/dw/reference_names.py @@ -25,7 +25,7 @@ """ from . import references -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .security import ( InvalidInputError, validate_asset_reference, @@ -62,21 +62,10 @@ def reference_name_errors(workflow_definition, source_indices=None): error inside a `for_each` member carries a path in their file and names the member. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) + for _, step, _, source, where in references.iter_steps( + workflow_definition.get("steps"), source_indices + ): for path, value in _strings(step, ("steps", source)): problem = reference_fault(value) if problem is not None: diff --git a/dw/references.py b/dw/references.py index 82d4ddfa..8eebcf15 100644 --- a/dw/references.py +++ b/dw/references.py @@ -10,8 +10,10 @@ (`scripts/arch_metrics.py` counts any that do). It also holds how a reference's location is written: the separator in a -for_each member's name (`@`) and a JSON path to a value -(`steps[3].task.arguments.videos[1]`). +for_each member's name (`@`), a JSON path to a value +(`steps[3].task.arguments.videos[1]`), and the walk over an expanded +definition's steps that finds each one's author index and member +(`iter_steps`). This module imports nothing from dw, so every module can import it. """ @@ -101,6 +103,48 @@ def author_index(source_indices, index): MEMBER_SEPARATOR = "@" +def member_suffix(name): + """The `" in member ''"` a message ends with when its step is a + for_each member, else "". + + A path is reported at the step the author wrote (`author_index`), which + every member of one group shares, so the message says which member it + was.""" + if isinstance(name, str) and MEMBER_SEPARATOR in name: + return f" in member '{name}'" + return "" + + +def iter_steps(steps, source_indices=None, key=None): + """Every dict step in `steps`, as `(index, step, block, source, where)`. + + The walk each validation pass over a substituted, expanded definition + makes: `steps` is the definition's "steps" (anything but a list yields + nothing), a step that is not a dict is skipped, and with `key` ("pipeline", + "task", "result", ...) so is one whose `step[key]` is not a dict - `block` + is that dict, or None without a `key`. `source` is the step's + `author_index` and `where` its `member_suffix`, so an error lands at a + path in the file the author wrote and names the member. One walk rather + than a copy per pass (#790).""" + if not isinstance(steps, list): + return + for index, step in enumerate(steps): + if not isinstance(step, dict): + continue + block = None + if key is not None: + block = step.get(key) + if not isinstance(block, dict): + continue + yield ( + index, + step, + block, + author_index(source_indices, index), + member_suffix(step.get("name")), + ) + + def render_path(path): """'steps[3].task.arguments.videos[1]' - the same shape schema errors use.""" rendered = "" diff --git a/dw/scalar_result_validation.py b/dw/scalar_result_validation.py index e29576cf..b6de8826 100644 --- a/dw/scalar_result_validation.py +++ b/dw/scalar_result_validation.py @@ -29,8 +29,8 @@ named at save time (`Result.save_artifact`). """ -from .for_each import MEMBER_SEPARATOR, render_path -from .references import author_index +from .for_each import render_path +from .references import iter_steps from .tasks.task import task_command_info RESULT_KEY = "result" @@ -67,17 +67,10 @@ def scalar_result_errors(workflow_definition, source_indices=None): the author can find in the file they wrote; the member is named in the message. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - result = step.get(RESULT_KEY) - if not isinstance(result, dict): - continue + for _, step, result, source, where in iter_steps( + workflow_definition.get("steps"), source_indices, RESULT_KEY + ): task = step.get("task") if not isinstance(task, dict): continue @@ -117,13 +110,6 @@ def scalar_result_errors(workflow_definition, source_indices=None): else: continue - source = author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) errors.append( { "path": render_path(("steps", source, RESULT_KEY)), diff --git a/dw/shot_span_preflight.py b/dw/shot_span_preflight.py index 65d0236a..659d3acc 100644 --- a/dw/shot_span_preflight.py +++ b/dw/shot_span_preflight.py @@ -20,10 +20,10 @@ names no records yet and is left to the run-time check. """ -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .media import probe_metadata from .probe_paths import resolve_probe_path -from .references import author_index +from .references import iter_steps PROBE_COMMANDS = ("analyze_shots", "analyze_seams", "analyze_sync_drift") @@ -57,16 +57,11 @@ def shot_span_warnings( `probe` defaults to the metadata-only `probe_metadata` (B9); see `dissolve_frame_errors` for why and for the memoizing-wrapper contract. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - warnings = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - task = step.get("task") - if not isinstance(task, dict) or task.get("command") not in PROBE_COMMANDS: + for _, _, task, source, where in iter_steps( + workflow_definition.get("steps"), source_indices, "task" + ): + if task.get("command") not in PROBE_COMMANDS: continue task_args = task.get("arguments") if not isinstance(task_args, dict): @@ -99,13 +94,6 @@ def shot_span_warnings( if not problems: continue - source = author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) path_str = render_path(("steps", source, "task", "arguments", "shots")) warnings.append( f"{path_str}: {task['command']} will clip {'; '.join(problems)}{where} " diff --git a/dw/slice_preflight.py b/dw/slice_preflight.py index 48b3d2f6..43c5782d 100644 --- a/dw/slice_preflight.py +++ b/dw/slice_preflight.py @@ -30,10 +30,10 @@ from fractions import Fraction -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .media import probe_metadata from .probe_paths import resolve_probe_path -from .references import author_index +from .references import iter_steps from .task_domains import slice_padding, slice_region @@ -108,16 +108,11 @@ def slice_past_end_warnings( `probe` defaults to the metadata-only `probe_metadata` (B9); see `dissolve_frame_errors` for why and for the memoizing-wrapper contract. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - warnings = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - task = step.get("task") - if not isinstance(task, dict) or task.get("command") != "slice_audio": + for _, _, task, source, where in iter_steps( + workflow_definition.get("steps"), source_indices, "task" + ): + if task.get("command") != "slice_audio": continue task_args = task.get("arguments") if not isinstance(task_args, dict): @@ -151,13 +146,6 @@ def slice_past_end_warnings( source_seconds = total / float(sample_rate) requested_end = (start + length) / float(sample_rate) - source = author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) path_str = render_path(("steps", source, "task", "arguments", "audio")) warnings.append( f"{path_str}: slice_audio will run {padded_seconds:.2f} s past " diff --git a/dw/step_value_checks.py b/dw/step_value_checks.py index 085f2f76..364befad 100644 --- a/dw/step_value_checks.py +++ b/dw/step_value_checks.py @@ -12,12 +12,12 @@ from . import references from .arguments import names_no_media -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .references import ( FROM_ARGUMENTS_KEY, FROM_FILE_KEY, FROM_PREVIOUS_RESULT_KEY, - author_index, + iter_steps, ) from .task_domains import ( SELECT_RULES, @@ -55,16 +55,11 @@ def fps_errors(workflow_definition, source_indices=None): several, and the path an error carries has to be one the author can find in the file they wrote; the member is named in the message. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - result = step.get("result") - if not isinstance(result, dict) or FPS_KEY not in result: + for _, _, result, source, where in iter_steps( + workflow_definition.get("steps"), source_indices, "result" + ): + if FPS_KEY not in result: continue value = result[FPS_KEY] # A prefix substitution resolves before this pass: one still spelled @@ -72,13 +67,6 @@ def fps_errors(workflow_definition, source_indices=None): if references.is_ref(references.SUBSTITUTED, value): continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) message = None if isinstance(value, bool) or not isinstance(value, (int, float)): message = f"Invalid fps: {value!r} - fps is a number of frames per second" @@ -150,25 +138,13 @@ def null_media_errors(workflow_definition, source_indices=None): wrote, and the member is named in the message - the same convention reference_limit_errors uses. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - pipeline = step.get("pipeline") - arguments = pipeline.get("arguments") if isinstance(pipeline, dict) else None + for _, _, pipeline, source, where in iter_steps( + workflow_definition.get("steps"), source_indices, "pipeline" + ): + arguments = pipeline.get("arguments") if not isinstance(arguments, dict): continue - source = author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) step_errors = [] for key, value in arguments.items(): _walk( @@ -207,14 +183,6 @@ def _select_problem_key(rule): return "threshold" if rule in SELECT_THRESHOLD_RULES else "index" -def _select_where(name): - return ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) - - def _is_gather(value): return references.is_ref(references.GATHER, value) @@ -253,6 +221,44 @@ def _written_candidate_count(written_steps, source, candidates): return len(candidates) +def _select_rule_errors(rule, arguments, candidate_count, add): + """What select_errors refuses about `rule` and the arguments it reads, + through `add(key, message)`.""" + # What the run itself refuses, in its own sentence - at most one, + # keyed to the argument it is about + problems = { + _select_problem_key(rule): problem + for problem in select_rule_problems( + rule, + arguments.get("threshold"), + arguments.get("index"), + # The range is refused here, in the run's own sentence, + # when the count is the author's + candidate_count, + ) + } + if "rule" in problems: + add("rule", problems["rule"]) + return + # Beside it, what the run ignores and validation refuses + # anyway: an argument this rule does not read + if "threshold" in problems: + add("threshold", problems["threshold"]) + elif rule not in SELECT_THRESHOLD_RULES and "threshold" in arguments: + add( + "threshold", + f"select: 'threshold' is only meaningful for rule " + f"'first_above'/'first_below', not {rule!r}", + ) + if "index" in problems: + add("index", problems["index"]) + elif rule != "index" and "index" in arguments: + add( + "index", + f"select: 'index' is only meaningful for rule 'index', not {rule!r}", + ) + + def select_errors(workflow_definition, source_indices=None, written=None): """Every select step whose arguments cannot be right, as [{path, message}]. @@ -265,20 +271,13 @@ def select_errors(workflow_definition, source_indices=None, written=None): written_steps = (workflow_definition if written is None else written).get("steps") errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - task = step.get("task") - if not isinstance(task, dict) or task.get("command") != "select": + for _, _, task, source, where in iter_steps(steps, source_indices, "task"): + if task.get("command") != "select": continue arguments = task.get("arguments") if not isinstance(arguments, dict): continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = _select_where(name) - def add(key, message): path = render_path(("steps", source, "task", "arguments", key)) errors.append({"path": path, "message": f"{message}{where}."}) @@ -286,40 +285,12 @@ def add(key, message): candidates = arguments.get("candidates") rule = arguments.get("rule") if isinstance(rule, str): - # What the run itself refuses, in its own sentence - at most one, - # keyed to the argument it is about - problems = { - _select_problem_key(rule): problem - for problem in select_rule_problems( - rule, - arguments.get("threshold"), - arguments.get("index"), - # The range is refused here, in the run's own sentence, - # when the count is the author's - _written_candidate_count(written_steps, source, candidates), - ) - } - if "rule" in problems: - add("rule", problems["rule"]) - else: - # Beside it, what the run ignores and validation refuses - # anyway: an argument this rule does not read - if "threshold" in problems: - add("threshold", problems["threshold"]) - elif rule not in SELECT_THRESHOLD_RULES and "threshold" in arguments: - add( - "threshold", - f"select: 'threshold' is only meaningful for rule " - f"'first_above'/'first_below', not {rule!r}", - ) - if "index" in problems: - add("index", problems["index"]) - elif rule != "index" and "index" in arguments: - add( - "index", - f"select: 'index' is only meaningful for rule 'index', " - f"not {rule!r}", - ) + _select_rule_errors( + rule, + arguments, + _written_candidate_count(written_steps, source, candidates), + add, + ) scores = arguments.get("scores") if "candidates" in arguments and "scores" in arguments: @@ -352,17 +323,13 @@ def add(key, message): def chain_prompts_errors(workflow_definition, source_indices=None): """Every chain whose resolved 'prompts' is a string, as [{path, message}].""" - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - pipeline = step.get("pipeline") if isinstance(step, dict) else None - chain = pipeline.get("chain") if isinstance(pipeline, dict) else None + for _, _, pipeline, source, _ in iter_steps( + workflow_definition.get("steps"), source_indices, "pipeline" + ): + chain = pipeline.get("chain") if not isinstance(chain, dict) or not isinstance(chain.get("prompts"), str): continue - source = references.author_index(source_indices, index) path = render_path(("steps", source, "pipeline", "chain", "prompts")) errors.append( { diff --git a/dw/subfolders.py b/dw/subfolders.py index d8c48bfc..e169e52f 100644 --- a/dw/subfolders.py +++ b/dw/subfolders.py @@ -21,7 +21,7 @@ """ from . import references -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .security import ( InvalidInputError, validate_file_base_name, @@ -62,24 +62,10 @@ def subfolder_errors(workflow_definition, source_indices=None): several, and the path an error carries has to be one the author can find in the file they wrote; the member is named in the message. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - result = step.get("result") - if not isinstance(result, dict): - continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) + for _, _, result, source, where in references.iter_steps( + workflow_definition.get("steps"), source_indices, "result" + ): for key, check in ( (SUBFOLDER_KEY, validate_subfolder), (FILE_BASE_NAME_KEY, validate_file_base_name), diff --git a/dw/task_domains.py b/dw/task_domains.py index fbfd3945..ba3170d6 100644 --- a/dw/task_domains.py +++ b/dw/task_domains.py @@ -38,9 +38,8 @@ from .references import ( DEFERRED, GATHER, - MEMBER_SEPARATOR, - author_index, is_ref, + iter_steps, render_path, ) from .script_lines import parse_lines, parse_shots, shot_names_error @@ -1044,17 +1043,10 @@ def task_argument_errors(workflow_definition, source_indices=None): wrote, and the member is named in the message - the convention subfolder_errors and reference_limit_errors both follow. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - task = step.get("task") - if not isinstance(task, dict): - continue + for _, _, task, source, where in iter_steps( + workflow_definition.get("steps"), source_indices, "task" + ): command = task.get("command") arguments = task.get("arguments") if not isinstance(command, str) or not isinstance(arguments, dict): @@ -1064,13 +1056,6 @@ def task_argument_errors(workflow_definition, source_indices=None): static_check = TASK_STATIC_CHECKS.get(command) if not domains and static_check is None: continue - source = author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) for key, domain in domains.items(): if key not in arguments: continue diff --git a/dw/type_references.py b/dw/type_references.py index 2e5d1e39..337f2e62 100644 --- a/dw/type_references.py +++ b/dw/type_references.py @@ -13,7 +13,7 @@ from . import references from .argument_media import is_media_reference from .arguments import fetch_constant, is_constant_reference -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .introspection import ( CLASS_NAME_PATTERN, list_classes, @@ -159,21 +159,10 @@ def component_type_errors(workflow_definition, source_indices=None): matching task_signature_errors; source_indices maps each expanded step back to the step the author wrote. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) + for _, step, _, source, where in references.iter_steps( + workflow_definition.get("steps"), source_indices + ): found = [] # The whole step, since realize_args loads a type or a constant # wherever one sits in it - a task's arguments as much as a pipeline @@ -211,17 +200,10 @@ def component_name_errors(workflow_definition, source_indices=None): matching component_type_errors; source_indices maps each expanded step back to the step the author wrote. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - pipeline = step.get("pipeline") - if not isinstance(pipeline, dict): - continue + for _, step, pipeline, source, where in references.iter_steps( + workflow_definition.get("steps"), source_indices, "pipeline" + ): configuration = pipeline.get("configuration") if not isinstance(configuration, dict): continue @@ -238,13 +220,6 @@ def component_name_errors(workflow_definition, source_indices=None): unknown = unknown_pipeline_components(component_type, component_names) if not unknown: continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) for component_name in unknown: errors.append( { diff --git a/dw/video_extensions.py b/dw/video_extensions.py index ba14f386..18cf3618 100644 --- a/dw/video_extensions.py +++ b/dw/video_extensions.py @@ -22,7 +22,7 @@ from . import references from .argument_media import is_media_reference -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .security import ALLOWED_IMAGE_EXTENSIONS, ALLOWED_VIDEO_EXTENSIONS @@ -101,21 +101,10 @@ def video_extension_errors(workflow_definition, source_indices=None): maps an expanded step back to the one the author wrote, and a path inside a `for_each` member names the member. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - source = references.author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) + for _, step, _, source, where in references.iter_steps( + workflow_definition.get("steps"), source_indices + ): for path, value in _video_values(step, ()): problem = _extension_problem(value) if problem is None: diff --git a/dw/video_size_errors.py b/dw/video_size_errors.py index 8d9ed415..6dbcb068 100644 --- a/dw/video_size_errors.py +++ b/dw/video_size_errors.py @@ -27,10 +27,10 @@ size is not known until the step that produces it runs. """ -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .media import probe_metadata from .probe_paths import resolve_probe_path -from .references import author_index +from .references import iter_steps from .task_domains import frame_size_error _CHECKED_COMMANDS = ("dissolve_videos", "concat_videos") @@ -60,16 +60,11 @@ def video_size_errors( `probe` defaults to the metadata-only `probe_metadata` (B9); see `dissolve_frame_errors` for why and for the memoizing-wrapper contract. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - task = step.get("task") - if not isinstance(task, dict) or task.get("command") not in _CHECKED_COMMANDS: + for _, _, task, source, where in iter_steps( + workflow_definition.get("steps"), source_indices, "task" + ): + if task.get("command") not in _CHECKED_COMMANDS: continue command = task["command"] arguments = task.get("arguments") @@ -92,13 +87,6 @@ def video_size_errors( if sentence is None: continue - source = author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) fit_width, fit_height = next(iter(sizes.values())) errors.append( { diff --git a/dw/window_count_errors.py b/dw/window_count_errors.py index f47d473e..101815c3 100644 --- a/dw/window_count_errors.py +++ b/dw/window_count_errors.py @@ -30,10 +30,10 @@ report, so it is skipped here rather than reported twice. """ -from .for_each import MEMBER_SEPARATOR, render_path +from .for_each import render_path from .media import probe_metadata from .probe_paths import resolve_probe_path -from .references import author_index +from .references import iter_steps from .task_domains import whole_number, window_count_problem @@ -71,16 +71,11 @@ def window_count_errors( names the member. `probe` is the metadata-only `probe_metadata` or the validation's memoizing wrapper of it - see `dissolve_frame_errors`. """ - steps = workflow_definition.get("steps") - if not isinstance(steps, list): - return [] - errors = [] - for index, step in enumerate(steps): - if not isinstance(step, dict): - continue - task = step.get("task") - if not isinstance(task, dict) or task.get("command") != "join_windows": + for _, _, task, source, where in iter_steps( + workflow_definition.get("steps"), source_indices, "task" + ): + if task.get("command") != "join_windows": continue arguments = task.get("arguments") if not isinstance(arguments, dict): @@ -102,13 +97,6 @@ def window_count_errors( if problem is None: continue - source = author_index(source_indices, index) - name = step.get("name") - where = ( - f" in member '{name}'" - if isinstance(name, str) and MEMBER_SEPARATOR in name - else "" - ) errors.append( { "path": render_path(("steps", source)), diff --git a/tests/test_references.py b/tests/test_references.py index 957b1f8e..6b83bbd7 100644 --- a/tests/test_references.py +++ b/tests/test_references.py @@ -19,6 +19,7 @@ VARIABLE, author_index, is_ref, + iter_steps, make_ref, ref_name, render_path, @@ -77,6 +78,38 @@ def test_author_index_maps_an_expanded_step_back_to_its_template( assert author_index(source_indices, index) == expected +def test_iter_steps_yields_each_dict_step_with_its_author_index_and_member(): + steps = [ + {"name": "shot@a", "task": {"command": "x"}}, + "not a step", + {"name": "plain", "pipeline": {}}, + {"name": "shot@b", "task": None}, + ] + sites = list(iter_steps(steps, [0, 1, 2, 0])) + assert [(index, source, where) for index, _, _, source, where in sites] == [ + (0, 0, " in member 'shot@a'"), + (2, 2, ""), + (3, 0, " in member 'shot@b'"), + ] + assert all(block is None for _, _, block, _, _ in sites) + + +def test_iter_steps_with_a_key_keeps_only_steps_whose_block_is_a_dict(): + steps = [ + {"name": "a", "task": {"command": "x"}}, + {"name": "b", "pipeline": {}}, + {"name": "c", "task": "variable:t"}, + ] + assert [ + (index, block) for index, _, block, _, _ in iter_steps(steps, key="task") + ] == [(0, {"command": "x"})] + + +@pytest.mark.parametrize("steps", [None, {}, "steps", 3]) +def test_iter_steps_over_anything_but_a_list_yields_nothing(steps): + assert list(iter_steps(steps)) == [] + + def test_references_imports_nothing_from_dw(): tree = ast.parse(pathlib.Path(references.__file__).read_text()) imported = [ From b54eac53c712b1f95ce7525f9c65f9afe2784cd7 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 9 Oct 2026 20:16:22 -0500 Subject: [PATCH 07/46] refactor: bring ten long or complex functions under the ratchet (#790) Private-helper extractions only; signatures, docstrings, messages and the order of every validator call are unchanged. - chain.run_chain: _segment_arguments, _record_seam, _spilled_frames (the AudioVideo constructors stay in run_chain, where test_shots pins them) - components.load_component: _warn_float16_on_mps, _hub_auth_error - concat_videos: _record_seam_fade; the waveform rides the input zip - script_check.check: _heard_shot (was a closure), _silent_take, _clipped_findings, _unassigned_tags, _silent_shot_findings - pipeline._with_guides: module-level _prepared_guide per entry - locations._walk: _named_value_errors, _media_value_errors - admission.argument_reference_errors: _effective_values, _string_leaves (was a closure), _resolve_reference - media.extract_audio: _seek_with_preroll, _flushed - hub_cache._tracker_class: the Tracker body is a module-level _Tracker, subclassed per download with its counters and cancel event bound - dw_mcp media.get_output_frames: _hear_tiles functions_over_150_lines 7 -> 3, complex_functions 16 -> 8. Co-Authored-By: Claude Opus 5.5 --- dw/hub_cache.py | 105 ++++++++++-------- dw/locations.py | 111 +++++++++++-------- dw/media.py | 54 +++++---- dw/pipeline_processors/chain.py | 103 +++++++++++------- dw/pipeline_processors/components.py | 79 ++++++++------ dw/pipeline_processors/pipeline.py | 111 ++++++++++--------- dw/server/admission.py | 102 +++++++++-------- dw/tasks/concat_videos.py | 33 +++--- dw/tasks/script_check.py | 157 ++++++++++++++++----------- dw_mcp/media.py | 70 ++++++------ 10 files changed, 527 insertions(+), 398 deletions(-) diff --git a/dw/hub_cache.py b/dw/hub_cache.py index 3fa34472..d67e7b0a 100644 --- a/dw/hub_cache.py +++ b/dw/hub_cache.py @@ -372,64 +372,75 @@ def _public(entry): def _tracker_class(manager, entry, cancel_event): """A tqdm stand-in snapshot_download instantiates per file; every update feeds the shared counters and honours cancellation.""" + return type( + "Tracker", + (_Tracker,), + {"_manager": manager, "_entry": entry, "_cancel_event": cancel_event}, + ) - class Tracker: - def __init__(self, *args, **kwargs): - self.n = 0 - self.total = kwargs.get("total") - # The xet transfer bar still ticks (cancellation works through - # it) but its network bytes stay out of the shared counters - self._counted = ( - XET_TRANSFER_BAR_FORMAT is None - or kwargs.get("bar_format") != XET_TRANSFER_BAR_FORMAT - ) - def update(self, n=1): - if cancel_event.is_set(): - raise DownloadCancelled() - if n: - self.n += n - if self._counted: - manager._add_progress(entry, n) - return True +class _Tracker: + """The tqdm surface huggingface_hub drives; _tracker_class binds one to a + download's counters and cancel event.""" + + _manager = None + _entry = None + _cancel_event = None - def close(self): - pass + def __init__(self, *args, **kwargs): + self.n = 0 + self.total = kwargs.get("total") + # The xet transfer bar still ticks (cancellation works through + # it) but its network bytes stay out of the shared counters + self._counted = ( + XET_TRANSFER_BAR_FORMAT is None + or kwargs.get("bar_format") != XET_TRANSFER_BAR_FORMAT + ) + + def update(self, n=1): + if self._cancel_event.is_set(): + raise DownloadCancelled() + if n: + self.n += n + if self._counted: + self._manager._add_progress(self._entry, n) + return True - def refresh(self): - pass + def close(self): + pass - def set_description(self, *args, **kwargs): - pass + def refresh(self): + pass - def set_description_str(self, *args, **kwargs): - pass + def set_description(self, *args, **kwargs): + pass - def set_postfix(self, *args, **kwargs): - pass + def set_description_str(self, *args, **kwargs): + pass - def set_postfix_str(self, *args, **kwargs): - pass + def set_postfix(self, *args, **kwargs): + pass - @property - def format_dict(self): - # What tqdm exposes for rate rendering; huggingface_hub reads - # 'rate' from it when composing the xet speed postfix - return {"n": self.n, "total": self.total, "elapsed": 0, "rate": None} + def set_postfix_str(self, *args, **kwargs): + pass - def __enter__(self): - return self + @property + def format_dict(self): + # What tqdm exposes for rate rendering; huggingface_hub reads + # 'rate' from it when composing the xet speed postfix + return {"n": self.n, "total": self.total, "elapsed": 0, "rate": None} - def __exit__(self, *exc): - self.close() - return False + def __enter__(self): + return self - @staticmethod - def get_lock(): - return threading.RLock() + def __exit__(self, *exc): + self.close() + return False - @staticmethod - def set_lock(lock): - pass + @staticmethod + def get_lock(): + return threading.RLock() - return Tracker + @staticmethod + def set_lock(lock): + pass diff --git a/dw/locations.py b/dw/locations.py index 7b896dc0..6d5047c9 100644 --- a/dw/locations.py +++ b/dw/locations.py @@ -715,61 +715,76 @@ def _walk(node, path, base_dir, errors, weight_rules=(None, True, False)): if isinstance(node, dict): for key, value in node.items(): here = f"{path}.{key}" - if key == "model_name" and isinstance(value, str): - message = _model_name_message(value, base_dir, weight_rules[2]) - if message: - errors.append({"path": here, "message": message}) + if _named_value_errors(key, value, here, base_dir, errors, weight_rules): continue - if key == WEIGHT_NAME_KEY and isinstance(value, str): - if not _deferred(value): - suffixes, subfolders, _ = weight_rules - message = _refusal( - validate_weight_name, - value, - suffixes, - WEIGHT_NAME_KEY, - subfolders, - ) - if message: - errors.append({"path": here, "message": message}) - continue - if key == "remote_text_encoder" and isinstance(value, dict): - url = value.get("url") - if isinstance(url, str) and not _deferred(url): - message = _refusal(validate_remote_encoder_url, url) - if message: - errors.append({"path": f"{here}.url", "message": message}) - continue - if key == GLOB_ARGUMENT and isinstance(value, str): - message = _glob_message(value, base_dir) - if message: - errors.append({"path": here, "message": message}) - continue - if _is_media_key(key): - for sub_path, item in _each(value, here): - message = _check(item, base_dir, f"'{key}'") - if message: - errors.append({"path": sub_path, "message": message}) - if key == "voices" and isinstance(value, dict): - # attribute_voices maps a voice's name to its reference, and - # a string reference is a clip it reads - the key is the - # voice's name, not a media key, so it is checked here or - # only when the run reaches it (#494) - for name, item in value.items(): - message = _check(item, base_dir, f"voice '{name}'") - if message: - errors.append({"path": f"{here}.{name}", "message": message}) - if key == "urls" and isinstance(value, list): - for sub_path, item in _each(value, here): - message = _check(item, base_dir, f"'{key}'") - if message: - errors.append({"path": sub_path, "message": message}) + _media_value_errors(key, value, here, base_dir, errors) _walk(value, here, base_dir, errors, weight_rules) elif isinstance(node, list): for index, item in enumerate(node): _walk(item, f"{path}[{index}]", base_dir, errors, weight_rules) +def _named_value_errors(key, value, here, base_dir, errors, weight_rules): + """Check a key whose value is one name - a model, a weight file, a remote + encoder's URL or a glob - and say whether it was one, which ends the walk + down it.""" + if key == "model_name" and isinstance(value, str): + message = _model_name_message(value, base_dir, weight_rules[2]) + if message: + errors.append({"path": here, "message": message}) + return True + if key == WEIGHT_NAME_KEY and isinstance(value, str): + if not _deferred(value): + suffixes, subfolders, _ = weight_rules + message = _refusal( + validate_weight_name, + value, + suffixes, + WEIGHT_NAME_KEY, + subfolders, + ) + if message: + errors.append({"path": here, "message": message}) + return True + if key == "remote_text_encoder" and isinstance(value, dict): + url = value.get("url") + if isinstance(url, str) and not _deferred(url): + message = _refusal(validate_remote_encoder_url, url) + if message: + errors.append({"path": f"{here}.url", "message": message}) + return True + if key == GLOB_ARGUMENT and isinstance(value, str): + message = _glob_message(value, base_dir) + if message: + errors.append({"path": here, "message": message}) + return True + return False + + +def _media_value_errors(key, value, here, base_dir, errors): + """Check the media a key's value names: a media key's, a voice map's + references, and a `urls` list's.""" + if _is_media_key(key): + for sub_path, item in _each(value, here): + message = _check(item, base_dir, f"'{key}'") + if message: + errors.append({"path": sub_path, "message": message}) + if key == "voices" and isinstance(value, dict): + # attribute_voices maps a voice's name to its reference, and + # a string reference is a clip it reads - the key is the + # voice's name, not a media key, so it is checked here or + # only when the run reaches it (#494) + for name, item in value.items(): + message = _check(item, base_dir, f"voice '{name}'") + if message: + errors.append({"path": f"{here}.{name}", "message": message}) + if key == "urls" and isinstance(value, list): + for sub_path, item in _each(value, here): + message = _check(item, base_dir, f"'{key}'") + if message: + errors.append({"path": sub_path, "message": message}) + + def _each(value, path): if isinstance(value, list): return [(f"{path}[{i}]", item) for i, item in enumerate(value)] diff --git a/dw/media.py b/dw/media.py index 4a7c7f39..83df7700 100644 --- a/dw/media.py +++ b/dw/media.py @@ -157,19 +157,7 @@ def extract_audio(path, start=None, duration=None): # about 1.5 s: the wrong audio, silently. anchor = stream.start_time if stream.start_time is not None else 0 if start > 0: - # Seek to the keyframe at or before `start`, less one packet of - # pre-roll: the first packet a decoder sees after a seek is its - # warm-up, and for AAC and mp3 the samples it yields come out - # attenuated or silent - so an excerpt at 2.5 s opened with a - # gap that is not in the file. Landing a packet early hands that - # warm-up to samples the pts-based trim below drops anyway. - frame_size = int(stream.codec_context.frame_size or 0) - preroll = frame_size / rate if frame_size else 0.1 - container.seek( - int(max(0.0, start - preroll) / stream.time_base) + anchor, - stream=stream, - backward=True, - ) + _seek_with_preroll(container, stream, start, rate, anchor) pieces = [] seen = 0 # samples decoded so far - the clock for a frame with no pts @@ -218,14 +206,7 @@ def extract_audio(path, start=None, duration=None): # Flush the resampler whatever ended the loop: it may still hold # samples that belong before `stop`. Anything past `stop` is cut. held = sum(p.shape[0] for p in pieces) - for chunk in resampler.resample(None): - samples = chunk.to_ndarray().reshape(-1, channels) - if stop is not None: - room = max(0, int(round((stop - start) * rate)) - held) - samples = samples[:room] - if samples.shape[0]: - pieces.append(samples) - held += samples.shape[0] + pieces.extend(_flushed(resampler, channels, rate, start, stop, held)) pcm = numpy.concatenate(pieces) if pieces else numpy.zeros((0, channels), " 0: - continuity.inject(segment_arguments, carry, config.segment_argument) + segment_arguments = _segment_arguments( + arguments, config, segment, continuity, carry + ) logger.info( f"Chain segment {segment.index + 1}/{len(config.plan)}" @@ -522,22 +507,7 @@ def run_chain(pipeline, chain_definition, arguments): applied, ) if segment.index > 0: - # The seam's own blend, so assess_output can tell it from a - # dropout in the content (#660) - shots[-1]["trim_frames"] = segment.head_trim - if "crossfade_ms" in applied: - shots[-1]["crossfade_ms"] = applied["crossfade_ms"] - emit_log( - f"Chain seam {segment.index}/{len(config.plan) - 1}: trimmed " - f"{segment.head_trim} head frame(s), crossfade " - + ( - f"{applied['crossfade_ms']} ms" - if "crossfade_ms" in applied - else "none (the guide held the audio)" - if config.guide_holds_audio - else "none (no head material)" - ) - ) + _record_seam(shots[-1], segment, config, applied) shots[-1]["num_samples"] = ( audio.shape[1] if audio is not None else 0 @@ -555,14 +525,7 @@ def run_chain(pipeline, chain_definition, arguments): pipeline.segment_label = None if spill is not None: - # match_audio overshoots by design - the tail trim happens as the - # lazy frames replay, so the files themselves stay whole - frames = SegmentedFrames( - spill.paths, - config.total_frames if config.source_audio is not None else None, - config.keep_segments, - spill.frame_count, - ) + frames = _spilled_frames(config, spill) if config.source_audio is not None: # The video matches the track's duration; the original, unsliced audio @@ -582,6 +545,62 @@ def run_chain(pipeline, chain_definition, arguments): return AudioVideo(frames, audio, audio_rate, fps=config.fps, shots=shots) +def _spilled_frames(config, spill): + """A spilled chain's frames, replayed lazily from its segment files. + + match_audio overshoots by design - the tail trim happens as the lazy + frames replay, so the files themselves stay whole.""" + return SegmentedFrames( + spill.paths, + config.total_frames if config.source_audio is not None else None, + config.keep_segments, + spill.frame_count, + ) + + +def _segment_arguments(arguments, config, segment, continuity, carry): + """One segment's arguments: the step's, with this segment's prompt, its + slice of a match_audio track, and the previous segment's carry.""" + segment_arguments = dict(arguments) + + if config.prompts: + segment_arguments["prompt"] = config.prompts[ + min(segment.index, len(config.prompts) - 1) + ] + + if config.source_audio is not None: + segment_arguments["num_frames"] = segment.num_frames + if config.holds_audio: + segment_arguments[HOLD_AUDIO_INPUT] = _audio_slice(config, segment) + else: + segment_arguments["references"] = _sliced_references( + config, segment, arguments["references"] + ) + + if segment.index > 0: + continuity.inject(segment_arguments, carry, config.segment_argument) + return segment_arguments + + +def _record_seam(shot, segment, config, applied): + """Record a seam's own blend on its shot, so assess_output can tell it + from a dropout in the content (#660), and say so in the job log.""" + shot["trim_frames"] = segment.head_trim + if "crossfade_ms" in applied: + shot["crossfade_ms"] = applied["crossfade_ms"] + emit_log( + f"Chain seam {segment.index}/{len(config.plan) - 1}: trimmed " + f"{segment.head_trim} head frame(s), crossfade " + + ( + f"{applied['crossfade_ms']} ms" + if "crossfade_ms" in applied + else "none (the guide held the audio)" + if config.guide_holds_audio + else "none (no head material)" + ) + ) + + def _check_guide_chain(pipeline, arguments, chain_definition, config): """Refuse a guide chain the H3 guides cannot take before the first segment renders, and note once in the job log the settings it does not read.""" diff --git a/dw/pipeline_processors/components.py b/dw/pipeline_processors/components.py index 14d3a38e..f8d24a71 100644 --- a/dw/pipeline_processors/components.py +++ b/dw/pipeline_processors/components.py @@ -563,18 +563,7 @@ def load_component( if components_manager is not None: from_pretrained_arguments["components_manager"] = components_manager - # MPS (Apple Silicon) has numerical instability with float16 matmul operations, - # producing NaN values that result in black images. The dtype is left as asked for - - # silently loading a model in a dtype the workflow did not request would be worse - - # so this only warns. - if ( - get_device_type(device) == "mps" - and from_pretrained_arguments.get("torch_dtype") == torch.float16 - ): - logger.warning( - f"{component_name} loads in float16 on MPS, which can produce NaN " - "values (black images) on Apple Silicon - bfloat16 is the usual fix" - ) + _warn_float16_on_mps(component_name, from_pretrained_arguments, device) model_name = None try: @@ -653,30 +642,56 @@ def load_component( # load failure, so no error log raise except Exception as e: - # 401/403 from the Hub means the account behind whatever token (or - # lack of one) HfApi is using cannot read this repo - almost always - # a gated model the user has not requested access to, or has not - # logged in for. A whole-pipeline load raises the HfHubHTTPError - # itself; a per-component load gets it wrapped in an EnvironmentError - # by diffusers' _get_model_file / transformers' cached_file, so the - # cause chain is searched. Every other error (a real outage, a bad - # repo id) is logged once with its traceback and re-raised unchanged - if _hub_auth_status(e) is not None: - repo = model_name or component_name - logger.error( - f"Hugging Face authentication required loading {component_name} " - f"({repo}): {e}", - exc_info=True, - ) - raise RuntimeError( - f"Model '{repo}' requires Hugging Face authentication - run " - f"'huggingface-cli login', or request access at " - f"https://huggingface.co/{repo}" - ) from e + # Every error but a Hub auth refusal (a real outage, a bad repo id) is + # logged once with its traceback and re-raised unchanged + auth_error = _hub_auth_error(e, model_name, component_name) + if auth_error is not None: + raise auth_error from e logger.error(f"{type(e).__name__} loading {component_name}: {e}", exc_info=True) raise +def _warn_float16_on_mps(component_name, from_pretrained_arguments, device): + """MPS (Apple Silicon) has numerical instability with float16 matmul operations, + producing NaN values that result in black images. The dtype is left as asked for - + silently loading a model in a dtype the workflow did not request would be worse - + so this only warns.""" + if ( + get_device_type(device) == "mps" + and from_pretrained_arguments.get("torch_dtype") == torch.float16 + ): + logger.warning( + f"{component_name} loads in float16 on MPS, which can produce NaN " + "values (black images) on Apple Silicon - bfloat16 is the usual fix" + ) + + +def _hub_auth_error(error, model_name, component_name): + """The RuntimeError to raise for a Hub auth refusal, or None for any other + load failure. Called from load_component's except block, so the log line + carries the traceback. + + 401/403 from the Hub means the account behind whatever token (or lack of + one) HfApi is using cannot read this repo - almost always a gated model + the user has not requested access to, or has not logged in for. A + whole-pipeline load raises the HfHubHTTPError itself; a per-component load + gets it wrapped in an EnvironmentError by diffusers' _get_model_file / + transformers' cached_file, so the cause chain is searched.""" + if _hub_auth_status(error) is None: + return None + repo = model_name or component_name + logger.error( + f"Hugging Face authentication required loading {component_name} " + f"({repo}): {error}", + exc_info=True, + ) + return RuntimeError( + f"Model '{repo}' requires Hugging Face authentication - run " + f"'huggingface-cli login', or request access at " + f"https://huggingface.co/{repo}" + ) + + def _hub_auth_status(error): """The 401/403 status an exception (or anything in its cause/context chain) carries from the Hub, else None.""" diff --git a/dw/pipeline_processors/pipeline.py b/dw/pipeline_processors/pipeline.py index 3a428b6a..4a55bde0 100644 --- a/dw/pipeline_processors/pipeline.py +++ b/dw/pipeline_processors/pipeline.py @@ -685,60 +685,10 @@ def _with_guides(self, arguments): raise ValueError( f"{where} takes at most {GUIDE_LIMIT} clips, got {len(guides)}" ) - prepared = [] - for index, guide in enumerate(guides): - if not isinstance(guide, dict): - raise ValueError( - f"{where}[{index}] must be {{video, frame}}, got {type(guide).__name__}" - ) - unknown = sorted(set(guide) - {"video", "frame", "audio"}) - if unknown: - raise ValueError( - f"{where}[{index}] has unknown key(s) {unknown} - a guide is " - f"{{video, frame}}, with an optional 'audio'" - ) - if "video" not in guide or "frame" not in guide: - raise ValueError(f"{where}[{index}] needs both 'video' and 'frame'") - problem = guide_frame_problem(guide["frame"]) - if problem: - raise ValueError(f"{where}[{index}]: {problem}") - with_audio = guide.get("audio", False) - if not isinstance(with_audio, bool): - raise ValueError( - f"{where}[{index}]: 'audio' must be true or false, got " - f"{type(with_audio).__name__}" - ) - video = guide["video"] - if isinstance(video, str) or (isinstance(video, dict) and with_audio): - # Read with its soundtrack only when the guide holds it - video = ( - load_audio_video(video, self.base_dir) - if with_audio - else fetch_video(video, self.base_dir) - ) - try: - frames = guide_frames_array(video) - audio, sample_rate = ( - guide_audio_waveform(video) if with_audio else (None, None) - ) - except ValueError as error: - raise ValueError(f"{where}[{index}]: {error}") from error - length = snap_guide_length(frames.shape[0]) - if length != frames.shape[0]: - emit_warning( - f"{where}[{index}]: a guide clip encodes to whole latents only at " - f"1, 5 or 17m + 5 frames, so its {frames.shape[0]} frames are cut " - f"to the first {length}" - ) - frames = frames[:length] - prepared.append( - { - "video": frames, - "frame": guide["frame"], - "audio": audio, - "sample_rate": sample_rate, - } - ) + prepared = [ + _prepared_guide(where, index, guide, self.base_dir) + for index, guide in enumerate(guides) + ] arguments[GUIDES_INPUT] = prepared return arguments @@ -899,6 +849,59 @@ def configure_loaded_components(self): component.to(torch_dtype) +def _prepared_guide(where, index, guide, base_dir): + """One `guides` entry checked and read: its clip as uint8 frames cut to + a whole-latent length, with its soundtrack when it asks for one.""" + if not isinstance(guide, dict): + raise ValueError( + f"{where}[{index}] must be {{video, frame}}, got {type(guide).__name__}" + ) + unknown = sorted(set(guide) - {"video", "frame", "audio"}) + if unknown: + raise ValueError( + f"{where}[{index}] has unknown key(s) {unknown} - a guide is " + f"{{video, frame}}, with an optional 'audio'" + ) + if "video" not in guide or "frame" not in guide: + raise ValueError(f"{where}[{index}] needs both 'video' and 'frame'") + problem = guide_frame_problem(guide["frame"]) + if problem: + raise ValueError(f"{where}[{index}]: {problem}") + with_audio = guide.get("audio", False) + if not isinstance(with_audio, bool): + raise ValueError( + f"{where}[{index}]: 'audio' must be true or false, got " + f"{type(with_audio).__name__}" + ) + video = guide["video"] + if isinstance(video, str) or (isinstance(video, dict) and with_audio): + # Read with its soundtrack only when the guide holds it + video = ( + load_audio_video(video, base_dir) + if with_audio + else fetch_video(video, base_dir) + ) + try: + frames = guide_frames_array(video) + audio, sample_rate = guide_audio_waveform(video) if with_audio else (None, None) + except ValueError as error: + raise ValueError(f"{where}[{index}]: {error}") from error + length = snap_guide_length(frames.shape[0]) + if length != frames.shape[0]: + emit_warning( + f"{where}[{index}]: a guide clip encodes to whole latents only at " + f"1, 5 or 17m + 5 frames, so its {frames.shape[0]} frames are cut " + f"to the first {length}" + ) + frames = frames[:length] + return { + "video": frames, + "frame": guide["frame"], + "audio": audio, + "sample_rate": sample_rate, + } + + def warn_if_safety_checker_blanked(output): """Say so when a safety checker replaced a generated image with a black one. diff --git a/dw/server/admission.py b/dw/server/admission.py index 853ffecb..cef9da0e 100644 --- a/dw/server/admission.py +++ b/dw/server/admission.py @@ -270,26 +270,27 @@ def argument_reference_errors( is to answer before any bytes move. """ - def _string_leaves(value, path): - """Every string in `value`, paired with the path it sits at. - - `value` is walked the way a for_each entry is - a list or dict - of arbitrary nesting - so a reference inside `shots[2]. - references[1].from_file` is found the same as one at the - argument's own top level.""" - if isinstance(value, str): - yield path, value - elif isinstance(value, list): - for i, item in enumerate(value): - yield from _string_leaves(item, f"{path}[{i}]") - elif isinstance(value, dict): - for key, item in value.items(): - yield from _string_leaves(item, f"{path}.{key}") - if arguments is not None and not isinstance(arguments, dict): return [] supplied = arguments if isinstance(arguments, dict) else {} + errors = [] + for base_path, value in _effective_values(definition, supplied): + for path, leaf in _string_leaves(value, base_path): + try: + _resolve_reference(leaf, outputs, asset_library, prompt_library) + except Exception as e: + # Every resolver here raises with a message written for + # the person who wrote the reference - a traversal + # refusal from the security layer included + errors.append({"path": path, "message": str(e)}) + return errors + + +def _effective_values(definition, supplied): + """(path, value) for every value a run would use: each declared + `variables` default the caller did not override, the caller's own + arguments, and each step as written.""" effective = [] declared = definition.get("variables") if isinstance(definition, dict) else None if isinstance(declared, dict): @@ -306,35 +307,46 @@ def _string_leaves(value, path): if isinstance(steps, list): for index, step in enumerate(steps): effective.append((f"steps[{index}]", step)) - - errors = [] - for base_path, value in effective: - for path, leaf in _string_leaves(value, base_path): - try: - if is_asset_reference(leaf): - if not asset_library.roots(): - # A server configured with no asset library has - # no root to fail against: the resolver would - # name no directory it searched - name = references.ref_name(references.ASSET, leaf).strip() - raise ValueError( - f"Unknown asset {name!r}: " - "this workspace has no asset library" - ) - # One resolve over the whole path, the way a run does: - # a miss names every library it looked in, the - # workspace's own first - resolve_asset_reference(leaf, library=asset_library) - elif references.is_ref(references.PROMPT, leaf): - resolve_prompt_reference(leaf, library=prompt_library) - elif is_output_reference(leaf): - resolve_output_reference(leaf, root=outputs) - except Exception as e: - # Every resolver here raises with a message written for - # the person who wrote the reference - a traversal - # refusal from the security layer included - errors.append({"path": path, "message": str(e)}) - return errors + return effective + + +def _string_leaves(value, path): + """Every string in `value`, paired with the path it sits at. + + `value` is walked the way a for_each entry is - a list or dict + of arbitrary nesting - so a reference inside `shots[2]. + references[1].from_file` is found the same as one at the + argument's own top level.""" + if isinstance(value, str): + yield path, value + elif isinstance(value, list): + for i, item in enumerate(value): + yield from _string_leaves(item, f"{path}[{i}]") + elif isinstance(value, dict): + for key, item in value.items(): + yield from _string_leaves(item, f"{path}.{key}") + + +def _resolve_reference(leaf, outputs, asset_library, prompt_library): + """Resolve `leaf` when it is an asset, prompt or output reference, + raising the resolver's own error on a miss; any other string passes.""" + if is_asset_reference(leaf): + if not asset_library.roots(): + # A server configured with no asset library has + # no root to fail against: the resolver would + # name no directory it searched + name = references.ref_name(references.ASSET, leaf).strip() + raise ValueError( + f"Unknown asset {name!r}: this workspace has no asset library" + ) + # One resolve over the whole path, the way a run does: + # a miss names every library it looked in, the + # workspace's own first + resolve_asset_reference(leaf, library=asset_library) + elif references.is_ref(references.PROMPT, leaf): + resolve_prompt_reference(leaf, library=prompt_library) + elif is_output_reference(leaf): + resolve_output_reference(leaf, root=outputs) class AcknowledgedCost(BaseModel): diff --git a/dw/tasks/concat_videos.py b/dw/tasks/concat_videos.py index 6b35a5a5..133627ef 100644 --- a/dw/tasks/concat_videos.py +++ b/dw/tasks/concat_videos.py @@ -131,7 +131,7 @@ def concat_videos( silence_channels = _silence_channels(waveforms) has_audio = silence_channels is not None - for index, (video, clip) in enumerate(zip(videos, clips)): + for index, (video, clip, waveform) in enumerate(zip(videos, clips, waveforms)): head_trim = trim_frames if index > 0 else 0 start_frame = len(frames) start_sample = audio.shape[1] if audio is not None else 0 @@ -153,13 +153,7 @@ def concat_videos( if not has_audio: continue waveform = _input_waveform( - waveforms[index], - video, - clip, - names[index], - fps, - sample_rate, - silence_channels, + waveform, video, clip, names[index], fps, sample_rate, silence_channels ) if audio is None: audio = waveform @@ -180,15 +174,7 @@ def concat_videos( seam_fade_ms, applied, ) - if applied: - # The fade the join realized is the caller's own edit: - # recorded beside hard_cut so analyze_seams can tell a - # requested dip from a fault (#659) - shots[first_shot[index]].update(applied) - if "audio_bleed_ms" in applied: - # A bled seam is no butt join: it records the bleed it - # got instead of hard_cut (#783) - shots[first_shot[index]].pop("hard_cut", None) + _record_seam_fade(shots[first_shot[index]], applied) audio_native_rate = getattr(video, "sample_rate", None) _warn_fps_override(videos, names, fps) @@ -197,6 +183,19 @@ def concat_videos( return AudioVideo(frames, audio, sample_rate, fps=written_fps, shots=shots) +def _record_seam_fade(shot, applied): + """Record on a seam's first shot the fade its join realized: the caller's + own edit, recorded beside hard_cut so analyze_seams can tell a requested + dip from a fault (#659).""" + if not applied: + return + shot.update(applied) + if "audio_bleed_ms" in applied: + # A bled seam is no butt join: it records the bleed it got instead of + # hard_cut (#783) + shot.pop("hard_cut", None) + + def _warn_fps_override(videos, names, fps): """Say so when `fps` re-times an input that carries a different rate (#673): that input plays faster or slower than it was written, with no other trace.""" diff --git a/dw/tasks/script_check.py b/dw/tasks/script_check.py index 9318db4d..8ab72481 100644 --- a/dw/tasks/script_check.py +++ b/dw/tasks/script_check.py @@ -429,33 +429,8 @@ def check( duration = len(mono) / sample_rate if sample_rate else 0.0 findings = [] - def heard_shot(word): - if spans is None: - return None - return shot_at(spans, (word["start"] + word["end"]) / 2) - if not parsed_lines: - if heard: - findings.append( - finding( - SPEECH_WHERE_SILENT, - len(heard), - _at(None, heard[0], shot=heard_shot(heard[0])), - ) - ) - return { - "findings": findings, - "lines": [], - "discarded": discarded, - "unmatched": [_heard_entry(word) for word in heard], - "shots": _shots_entry(spans), - "shots_source": shots_source, - "rules_applied": list(SILENT_RULES), - "rules_skipped": [ - {"rule": rule, "reason": "lines is [] - no speech was expected"} - for rule in LINE_RULES + SHOT_RULES - ], - } + return _silent_take(heard, discarded, spans, shots_source) expected = [normalize_words(line["text"]) for line in parsed_lines] assigned, unassigned = align(expected, heard) @@ -517,28 +492,12 @@ def heard_shot(word): if own: tails = ([(shot, span["end"])] if span else []) + [(None, duration)] - clipped = _clipped_tail(mono, sample_rate, end, tails) - if clipped is not None: - at = {"line": index, "seconds": _round(end), "word": own[-1]["text"]} - if clipped[0] is not None or shot is not None: - at["shot"] = clipped[0] or shot - findings.append(finding(CLIPPED_AT_END, _round(clipped[1], 2), at)) + findings.extend( + _clipped_findings(mono, sample_rate, tails, index, own, shot) + ) previous_end = end - for j, neighbours in unassigned: - word = heard[j] - for index in neighbours: - if word["word"] in set(parsed_lines[index]["tokens"]) - set( - expected[index] - ): - findings.append( - finding( - TAG_SPOKEN, - word["word"], - _at(index, word, shot=heard_shot(word)), - ) - ) - break + findings.extend(_unassigned_tags(heard, unassigned, parsed_lines, expected, spans)) rules_applied = list(LINE_RULES) rules_skipped = [ @@ -548,21 +507,7 @@ def heard_shot(word): no_shot = _no_shot_reason(shots_source, spans, parsed_lines) if no_shot is None: rules_applied.extend(SHOT_RULES) - named = {line["shot"] for line in parsed_lines if line["shot"]} - for span in spans: - if span["name"] in named: - continue - inside = [ - word - for j, word in enumerate(heard) - if heard_shot(word) == span["name"] - and claimed.get(j) in (None, span["name"]) - ] - if inside: - at = _at(None, inside[0], shot=span["name"]) - # A word straddling the cut into this shot is placed at the cut - at["seconds"] = max(at["seconds"], _round(span["start"])) - findings.append(finding(SPEECH_IN_SILENT_SHOT, len(inside), at)) + findings.extend(_silent_shot_findings(spans, parsed_lines, heard, claimed)) else: rules_skipped.extend({"rule": rule, "reason": no_shot} for rule in SHOT_RULES) @@ -579,6 +524,96 @@ def heard_shot(word): } +def _heard_shot(spans, word): + """The shot a heard word's midpoint falls in, None when no shots are known.""" + if spans is None: + return None + return shot_at(spans, (word["start"] + word["end"]) / 2) + + +def _silent_take(heard, discarded, spans, shots_source): + """check's result for `lines: []`: any speech at all is the finding, and + the line and shot rules have nothing to compare against.""" + findings = [] + if heard: + findings.append( + finding( + SPEECH_WHERE_SILENT, + len(heard), + _at(None, heard[0], shot=_heard_shot(spans, heard[0])), + ) + ) + return { + "findings": findings, + "lines": [], + "discarded": discarded, + "unmatched": [_heard_entry(word) for word in heard], + "shots": _shots_entry(spans), + "shots_source": shots_source, + "rules_applied": list(SILENT_RULES), + "rules_skipped": [ + {"rule": rule, "reason": "lines is [] - no speech was expected"} + for rule in LINE_RULES + SHOT_RULES + ], + } + + +def _clipped_findings(mono, sample_rate, tails, index, own, shot): + """line_clipped_at_end for one heard line, as a list of zero or one + finding: its last word ends in a voiced tail of its shot or the file.""" + end = own[-1]["end"] + clipped = _clipped_tail(mono, sample_rate, end, tails) + if clipped is None: + return [] + at = {"line": index, "seconds": _round(end), "word": own[-1]["text"]} + if clipped[0] is not None or shot is not None: + at["shot"] = clipped[0] or shot + return [finding(CLIPPED_AT_END, _round(clipped[1], 2), at)] + + +def _unassigned_tags(heard, unassigned, parsed_lines, expected, spans): + """tag_spoken for the heard words align left unassigned: a markup token + of a neighbouring line, spoken aloud between lines.""" + findings = [] + for j, neighbours in unassigned: + word = heard[j] + for index in neighbours: + if word["word"] in set(parsed_lines[index]["tokens"]) - set( + expected[index] + ): + findings.append( + finding( + TAG_SPOKEN, + word["word"], + _at(index, word, shot=_heard_shot(spans, word)), + ) + ) + break + return findings + + +def _silent_shot_findings(spans, parsed_lines, heard, claimed): + """speech_in_silent_shot: words heard inside a shot no line names, other + than those a line in another shot claimed.""" + findings = [] + named = {line["shot"] for line in parsed_lines if line["shot"]} + for span in spans: + if span["name"] in named: + continue + inside = [ + word + for j, word in enumerate(heard) + if _heard_shot(spans, word) == span["name"] + and claimed.get(j) in (None, span["name"]) + ] + if inside: + at = _at(None, inside[0], shot=span["name"]) + # A word straddling the cut into this shot is placed at the cut + at["seconds"] = max(at["seconds"], _round(span["start"])) + findings.append(finding(SPEECH_IN_SILENT_SHOT, len(inside), at)) + return findings + + def _heard_entry(word): return { "text": word["text"], diff --git a/dw_mcp/media.py b/dw_mcp/media.py index b0bd2b76..7ba67ee5 100644 --- a/dw_mcp/media.py +++ b/dw_mcp/media.py @@ -233,37 +233,7 @@ def get_output_frames( downscaled_to = body.get("downscaled_to") audio_truncated = False if hear is not None: - span = float(hear) - # The tiles already spent part of the one response budget - audio_bytes_so_far = sum(len(tile["data"]) for tile in tiles) - budget_exceeded = False - for tile in tiles: - if budget_exceeded: - tile["audio_error"] = "skipped - would exceed the response size budget" - audio_truncated = True - continue - start = max(0.0, float(tile["seconds"]) - span / 2) - try: - audio = get_output_audio( - client, name, start=start, duration=span, workspace=workspace - ) - except DwApiError as e: - tile["audio_error"] = str(e) - continue - # A per-tile cap (get_output_audio's own MAX_RETURNED_BYTES check) - # bounds one excerpt; this is the whole response's cap, tiles - # included, on top of - not instead of - the per-tile one. - if audio_bytes_so_far + len(audio["data"]) > MAX_RETURNED_BYTES: - tile["audio_error"] = "skipped - would exceed the response size budget" - audio_truncated = True - budget_exceeded = True - continue - audio_bytes_so_far += len(audio["data"]) - tile["audio"] = { - "data": audio["data"], - "mime_type": audio["mime_type"], - "excerpt": audio["excerpt"], - } + audio_truncated = _hear_tiles(client, name, tiles, float(hear), workspace) return { "name": name, "frame_count": body.get("frame_count"), @@ -276,6 +246,44 @@ def get_output_frames( } +def _hear_tiles(client, name, tiles, span, workspace): + """Attach `span` seconds of soundtrack around each tile's moment, within + the one response budget the tiles already spent part of. True when an + excerpt was skipped for that budget.""" + audio_truncated = False + # The tiles already spent part of the one response budget + audio_bytes_so_far = sum(len(tile["data"]) for tile in tiles) + budget_exceeded = False + for tile in tiles: + if budget_exceeded: + tile["audio_error"] = "skipped - would exceed the response size budget" + audio_truncated = True + continue + start = max(0.0, float(tile["seconds"]) - span / 2) + try: + audio = get_output_audio( + client, name, start=start, duration=span, workspace=workspace + ) + except DwApiError as e: + tile["audio_error"] = str(e) + continue + # A per-tile cap (get_output_audio's own MAX_RETURNED_BYTES check) + # bounds one excerpt; this is the whole response's cap, tiles + # included, on top of - not instead of - the per-tile one. + if audio_bytes_so_far + len(audio["data"]) > MAX_RETURNED_BYTES: + tile["audio_error"] = "skipped - would exceed the response size budget" + audio_truncated = True + budget_exceeded = True + continue + audio_bytes_so_far += len(audio["data"]) + tile["audio"] = { + "data": audio["data"], + "mime_type": audio["mime_type"], + "excerpt": audio["excerpt"], + } + return audio_truncated + + def get_output_text( client, name, max_characters=MAX_RETURNED_CHARACTERS, workspace=None ): From 0daafee15e1a0b10de8e82c5d506a97fcb4f25e3 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 9 Oct 2026 20:17:53 -0500 Subject: [PATCH 08/46] refactor(h3): move the H3 block classes to module level (#790) The hold and guide block factories (`h3_hold._make_blocks`, 261 lines, C901 18; `h3_guides._make_guide_blocks`, 313 lines, C901 32) defined their ModularPipelineBlocks subclasses in the function body so diffusers loaded lazily, and the ratchet counted every class into the factory. The classes now live at module level in h3_hold_steps.py and h3_guide_steps.py, which import diffusers when they load and are imported only inside `blocks()` / `guide_blocks()`. The tensor helpers the blocks call move with them - left in h3_hold/h3_guides they would make the lazy import a cycle. The closure values become module imports, the two t2va condition steps keep their shared `DwH3WhenConditionedStep` name, and the guide layout's `__call__` is split into `_encode_guides` and `_hold_guide_audio`. The guide layout reads `encode_vae_condition` and `h3_hold_steps.encode_audio_span` through their modules at call time, so the tests patch them without resetting a cache. tests/test_h3_rules.py now also pins that h3_hold and h3_guides import no diffusers. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 2 +- dw/pipeline_processors/h3_guide_steps.py | 542 +++++++++++++++++++++++ dw/pipeline_processors/h3_guides.py | 529 +--------------------- dw/pipeline_processors/h3_hold.py | 428 +----------------- dw/pipeline_processors/h3_hold_steps.py | 397 +++++++++++++++++ tests/test_h3_guides.py | 76 ++-- tests/test_h3_hold_audio.py | 8 +- tests/test_h3_refine.py | 6 +- tests/test_h3_rules.py | 20 +- 9 files changed, 1043 insertions(+), 965 deletions(-) create mode 100644 dw/pipeline_processors/h3_guide_steps.py create mode 100644 dw/pipeline_processors/h3_hold_steps.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0d5bbc2b..207b49f4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -96,7 +96,7 @@ one to open. | 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` | | 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` | diff --git a/dw/pipeline_processors/h3_guide_steps.py b/dw/pipeline_processors/h3_guide_steps.py new file mode 100644 index 00000000..ababf080 --- /dev/null +++ b/dw/pipeline_processors/h3_guide_steps.py @@ -0,0 +1,542 @@ +"""MiniMax-H3 guides: the four blocks dw puts into the denoise graph for a +`guides` call, and the row and position arithmetic they run. + +What a guide is, and where the blocks go, is dw/pipeline_processors/h3_guides.py, +which puts them in. They live here, at module level, rather than in a factory +inside h3_guides (#790): a class defined in a function body is counted into that +function, and the factory read as one 313-line function to the ratchet. This +module imports diffusers when it loads, so nothing imports it at module level - +`h3_guides.guide_blocks()` imports it on first use, and validation, which imports +h3_guides for `default_num_frames`, still never loads a modular pipeline. + +The splicing helpers are here too, not in h3_guides, because the layout block +calls them and h3_guides imports this module: a helper left behind would make +the import a cycle. A guide's audio is encoded through the hold blocks' own +`h3_hold_steps.encode_audio_span`, called through the module so a test can stand +in for the encode; h3_hold_steps never imports this module back. + +The private diffusers helpers the layout is built on (`_frame_position_grid`, +`_temporal_position_grid`, `_fill_audio_positions`) are read off +`before_denoise` when they run, not imported by name: they are among +`LAYOUT_ANCHORS` in h3_guides, and a diffusers missing one refuses `guides` +there, with the name, rather than failing this import. +""" + +import logging + +import numpy as np +import torch +from diffusers.models import AutoencoderKLMiniMaxH3, AutoencoderKLMiniMaxH3Audio +from diffusers.modular_pipelines.minimax_h3 import before_denoise, encoders +from diffusers.modular_pipelines.minimax_h3.before_encoder import ( + MiniMaxH3Ref2VASetupStep, +) +from diffusers.modular_pipelines.minimax_h3.encoders import ( + MiniMaxH3Ref2VAReferenceEncoderStep, +) +from diffusers.modular_pipelines.minimax_h3.modular_pipeline import ( + MINIMAX_H3_AUDIO_LATENTS_PER_SECOND, +) +from diffusers.modular_pipelines.modular_pipeline import ModularPipelineBlocks +from diffusers.modular_pipelines.modular_pipeline_utils import ( + ComponentSpec, + InputParam, + OutputParam, +) +from PIL import Image + +from . import h3_hold_steps +from .h3_rules import ( + GUIDE_AUDIO_ROWS, + GUIDE_FRAMES_PER_CHUNK, + GUIDE_LATENTS_PER_CHUNK, + GUIDES_INPUT, + guide_end_problem, + guide_frame_problem, +) + +logger = logging.getLogger("dw") + +# What the stock layout sets, read back off the state by the guide layout +_LAID_OUT = ( + "height", + "width", + "num_frames", + "text_token_tags", + "latent_height", + "latent_width", + "num_latent_frames", + "position_ids", + "token_tags", + "video_indices", + "audio_indices", + "text_indices", + "num_condition_video_rows", + "condition_latents", + "num_audio_latents", +) + +# The packed-sequence tensors a splice widens, in `splice_guide_rows` order +_PACKED = ( + "position_ids", + "token_tags", + "video_indices", + "audio_indices", + "text_indices", +) + + +def guide_position_ids(target_time, frame_grid, latent_index, num_latent_frames): + """`(num_latent_frames * rows_per_frame, 3)` rotary positions of one guide. + + Time runs from the target's own time at `latent_index`, with the target's + spacing (`_temporal_position_grid`); every frame carries the target's (h, w) + grid. Rows are frame-major, the order `patchify_video_latents` packs them in. + """ + times = before_denoise._temporal_position_grid( + num_latent_frames, float(target_time[latent_index]) + ) + rows_per_frame = frame_grid.shape[0] + positions = torch.empty(num_latent_frames, rows_per_frame, 3, dtype=torch.float64) + positions[:, :, 0] = times[:, None] + positions[:, :, 1:] = frame_grid[None] + return positions.reshape(-1, 3) + + +def splice_guide_rows(layout, num_text_tokens, guide_positions, video_tag): + """The packed layout with guide rows inserted after the existing condition rows. + + `layout` is `(position_ids, token_tags, video_indices, audio_indices, + text_indices, num_condition_video_rows)` as the stock layout built it, on the + CPU. Returns the same six, widened by `len(guide_positions)` video rows that + sit between the keyframe rows and the target audio. + """ + position_ids, token_tags, video_indices, audio_indices, text_indices, rows = layout + count = guide_positions.shape[0] + if not count: + return layout + at = num_text_tokens + rows + sequence_length = position_ids.shape[0] + count + position_ids = torch.cat( + [position_ids[:at], guide_positions.to(position_ids.dtype), position_ids[at:]] + ) + token_tags = torch.cat( + [ + token_tags[:at], + torch.full((count,), video_tag, dtype=token_tags.dtype), + token_tags[at:], + ] + ) + audio_start = int(audio_indices[0]) if audio_indices.numel() else at + video_start = audio_start + audio_indices.numel() + video_indices = torch.cat( + [ + torch.arange(num_text_tokens, audio_start + count), + torch.arange(video_start + count, sequence_length), + ] + ) + audio_indices = audio_indices + count + return ( + position_ids, + token_tags, + video_indices, + audio_indices, + text_indices, + rows + count, + ) + + +def guide_audio_latents(num_frames, fps=24, latents_per_second=40): + """M, the audio latents a guide of `num_frames` frames holds at frame 0: + round(num_frames * 40 / 24). 22 -> 37, 39 -> 65.""" + return int(round(num_frames * latents_per_second / fps)) + + +def guide_audio_span(frame, num_frames, fps=24, latents_per_second=40): + """(start, pad, count) of a guide's audio on the target audio grid. + + `start` is the target audio latent the guide's audio lands on, the one at or + before its first frame: floor(frame * 40 / 24). `pad` is the seconds of + silence that go in front of the guide's audio so its first sample keeps its + time - frame / 24 - once the encode starts at that latent. `count` is the + latents per channel it covers, to round((frame + num_frames) * 40 / 24); M at + frame 0 (`guide_audio_latents`).""" + start = frame * latents_per_second // fps + pad = frame / fps - start / latents_per_second + end = guide_audio_latents(frame + num_frames, fps, latents_per_second) + return start, pad, end - start + + +def pad_guide_audio(waveform, frame, sample_rate, fps=24, latents_per_second=40): + """A guide's `(channels, samples)` waveform, at `sample_rate`, with the + silence in front that puts its first sample at `frame / fps` when sample 0 + is the guide's start latent (`guide_audio_span`) - the audio VAE's hop.""" + start = frame * latents_per_second // fps + origin = int(round(frame * sample_rate / fps)) + pad = origin - start * (sample_rate // latents_per_second) + return torch.nn.functional.pad(waveform, (pad, 0)) if pad > 0 else waveform + + +def guide_audio_positions(num_text_tokens, width_grid, start, count, audio_channels): + """`(count * audio_channels, 3)` rotary positions of one guide's audio rows: + the target soundtrack's own, from its latent `start` - time num_text + start + on, channel-major on the two extremes of the width grid.""" + positions = torch.zeros(count * audio_channels, 3, dtype=torch.float64) + before_denoise._fill_audio_positions( + positions, + slice(0, positions.shape[0]), + count, + float(num_text_tokens + start), + width_grid, + audio_channels, + ) + return positions + + +def splice_guide_audio_rows(layout, audio_positions, audio_tag): + """The packed layout with guide audio rows in front of the target audio. + + `layout` is `(position_ids, token_tags, video_indices, audio_indices, + text_indices)` on the CPU. Returns the same five, widened by + `len(audio_positions)` audio rows that lead `audio_indices` - the + condition audio prefix - with every video row after them moved along. + """ + position_ids, token_tags, video_indices, audio_indices, text_indices = layout + count = audio_positions.shape[0] + if not count: + return layout + at = int(audio_indices[0]) + position_ids = torch.cat( + [position_ids[:at], audio_positions.to(position_ids.dtype), position_ids[at:]] + ) + token_tags = torch.cat( + [ + token_tags[:at], + torch.full((count,), audio_tag, dtype=token_tags.dtype), + token_tags[at:], + ] + ) + video_indices = torch.where( + video_indices >= at, video_indices + count, video_indices + ) + audio_indices = torch.cat([torch.arange(at, at + count), audio_indices + count]) + return position_ids, token_tags, video_indices, audio_indices, text_indices + + +def fit_guide_frames(frames, height, width): + """`(n, height, width, 3)` uint8 frames covering the canvas: scaled to cover + it and centre-cropped, with the arithmetic H3 fits a follower keyframe with + (diffusers' `MiniMaxH3BeforeEncodeStep`). A clip already at the canvas + passes through untouched.""" + if frames.shape[1:3] == (height, width): + return frames + source_height, source_width = frames.shape[1:3] + scale = max(width / source_width, height / source_height) + size = ( + max(width, round(source_width * scale)), + max(height, round(source_height * scale)), + ) + left = max(0, (size[0] - width) // 2) + top = max(0, (size[1] - height) // 2) + box = (left, top, left + width, top + height) + return np.stack( + [ + np.asarray( + Image.fromarray(frame).resize(size, Image.Resampling.LANCZOS).crop(box) + ) + for frame in frames + ] + ) + + +class DwH3GuideLayoutStep(ModularPipelineBlocks): + model_name = "minimax-h3" + + def __init__(self): + super().__init__() + # Called, not copied: the stock layout resolves the canvas and builds + # the sequence, and the guides are spliced into what it built + self._layout = before_denoise.MiniMaxH3PrepareLayoutStep() + # ...and the encode a Ref2VA soundtrack reference takes, for a + # guide's audio + self._audio_encoder = MiniMaxH3Ref2VAReferenceEncoderStep() + + @property + def description(self): + return ( + "dw: the stock layout, plus `guides` - each guide clip VAE-encoded on the canvas and appended as " + "condition rows timed from the target frame it lands on. Without `guides` it is the stock layout." + ) + + @property + def expected_components(self): + return list(self._layout.expected_components) + [ + ComponentSpec("vae", AutoencoderKLMiniMaxH3), + ComponentSpec("audio_vae", AutoencoderKLMiniMaxH3Audio), + ] + + @property + def expected_configs(self): + return self._layout.expected_configs + + @property + def inputs(self): + return list(self._layout.inputs) + [ + InputParam( + name=GUIDES_INPUT, + type_hint=list, + default=None, + description=( + "Clips to hold the generated video to: a list of {'video': (n, h, w, 3) uint8 frames, " + "'frame': pixel frame, a multiple of 17}, n one of 1, 5 or 17m + 5. A guide that also " + "carries 'audio': (channels, samples) and 'sample_rate' holds that soundtrack over its span." + ), + ), + InputParam(name="condition_latents", type_hint=list, default=None), + ] + + @property + def intermediate_outputs(self): + return list(self._layout.intermediate_outputs) + [ + OutputParam("condition_latents", type_hint=list), + OutputParam(GUIDE_AUDIO_ROWS, type_hint=torch.Tensor), + ] + + @torch.no_grad() + def __call__(self, components, state): + components, state = self._layout(components, state) + guides = state.get(GUIDES_INPUT) or [] + if not guides: + return components, state + # What the stock layout set is its output, not this block's input, + # so it is read off the state rather than a block state + laid = state.get(list(_LAID_OUT)) + + device = components._execution_device + num_text = laid["text_token_tags"].shape[0] + _, patch_h, patch_w = components.patch_size + frame_grid, width_grid = before_denoise._frame_position_grid( + laid["latent_height"], laid["latent_width"], patch_h, patch_w + ) + latents, positions = self._encode_guides( + components, guides, laid, frame_grid, num_text + ) + layout = splice_guide_rows( + tuple(laid[name].cpu() for name in _PACKED) + + (laid["num_condition_video_rows"],), + num_text, + torch.cat(positions), + components.video_tag, + ) + layout, num_audio_rows = self._hold_guide_audio( + components, state, guides, layout, laid, width_grid, num_text + ) + + for name, value in zip(_PACKED, layout): + state.set(name, value.to(device)) + state.set("num_condition_video_rows", layout[5]) + state.set("num_condition_audio_rows", num_audio_rows) + # Keyframes first, then guides: the order the rows were laid out in + state.set("condition_latents", list(laid["condition_latents"] or []) + latents) + return components, state + + def _encode_guides(self, components, guides, laid, frame_grid, num_text): + """([latents], [positions]): each guide VAE-encoded on the canvas, and + its rows' rotary positions, timed from the target frame it lands on.""" + device = components._execution_device + target_time = before_denoise._temporal_position_grid( + laid["num_latent_frames"], float(num_text) + ) + latents, positions = [], [] + for index, guide in enumerate(guides): + frame, frames = guide["frame"], guide["video"] + problem = guide_frame_problem(frame) or guide_end_problem( + frame, frames.shape[0], laid["num_frames"] + ) + if problem: + raise ValueError(f"guides[{index}]: {problem}") + pixels = fit_guide_frames(frames, laid["height"], laid["width"]) + pixels = torch.from_numpy(np.ascontiguousarray(pixels)) + # Through the module, so the encode is the one installed when it runs + encoded = encoders.encode_vae_condition( + components.vae, + pixels.to(device).permute(3, 0, 1, 2)[None], + components.pixel_mean, + components.pixel_std, + components.keyframe_encode_seed, + ) + latents.append(encoded) + positions.append( + guide_position_ids( + target_time, + frame_grid, + frame // GUIDE_FRAMES_PER_CHUNK * GUIDE_LATENTS_PER_CHUNK, + encoded.shape[2], + ) + ) + logger.info( + f"Guide {index}: {frames.shape[0]} frames at frame {frame} " + f"({encoded.shape[2]} latent frames)" + ) + return latents, positions + + def _hold_guide_audio( + self, components, state, guides, layout, laid, width_grid, num_text + ): + """(layout, num_condition_audio_rows): each guide's audio, encoded on the + target audio grid from the latent at or before its first frame, spliced + in as condition audio rows timed there, and left in the state for + `DwH3GuideAudioStep`.""" + audio_rows, audio_positions = [], [] + for index, guide in enumerate(guides): + if guide.get("audio") is None: + continue + rows, start, count = self._guide_audio( + components, guide, laid["num_audio_latents"] + ) + audio_rows.append(rows) + audio_positions.append( + guide_audio_positions( + num_text, width_grid, start, count, components.audio_channels + ) + ) + logger.info( + f"Guide {index}: audio held over audio latents {start} to " + f"{start + count} ({rows.shape[0]} rows)" + ) + num_audio_rows = state.get("num_condition_audio_rows") or 0 + if audio_rows: + layout = ( + splice_guide_audio_rows( + layout[:5], torch.cat(audio_positions), components.audio_tag + ) + + layout[5:] + ) + state.set(GUIDE_AUDIO_ROWS, torch.cat(audio_rows)) + num_audio_rows += sum(rows.shape[0] for rows in audio_rows) + return layout, num_audio_rows + + def _guide_audio(self, components, guide, num_audio_latents): + """(rows, start, count): a guide's audio as `(channels * count, C)` + channel-major rows from target audio latent `start`.""" + frame, length = guide["frame"], guide["video"].shape[0] + fps = components.fps + vae_rate = components.audio_sampling_rate + latents_per_second = MINIMAX_H3_AUDIO_LATENTS_PER_SECOND + start, _, count = guide_audio_span(frame, length, fps, latents_per_second) + count = min(count, num_audio_latents - start) + original = h3_hold_steps.as_channels_samples(guide["audio"]) + rate = guide.get("sample_rate") or vae_rate + # The guide's span only, on the VAE's rate and channels the way a + # Ref2VA soundtrack is, then pre-padded to the VAE's hop + waveform = MiniMaxH3Ref2VASetupStep._normalize_audio_condition( + original, rate, vae_rate, max_duration=length / fps + ) + waveform = pad_guide_audio(waveform, frame, vae_rate, fps, latents_per_second) + latents = h3_hold_steps.encode_audio_span( + self._audio_encoder, components, waveform, count + ) + return latents.reshape(-1, components.audio_latent_channels), start, count + + +# A t2va step that runs the stock `fl2va` one only with conditioning to pack - +# the stock step refuses an empty list. `_when_conditioned` makes one per stock +# step, each under this one name, as the factory before #790 did +class DwH3WhenConditionedStep(ModularPipelineBlocks): + model_name = "minimax-h3" + stock = None + stock_description = None + + def __init__(self): + super().__init__() + self._stock = self.stock() + + @property + def description(self): + return self.stock_description + + @property + def expected_components(self): + return self._stock.expected_components + + @property + def inputs(self): + inputs = [] + for param in self._stock.inputs: + if param.name == "condition_latents": + param = InputParam( + name="condition_latents", type_hint=list, default=None + ) + elif param.name == "condition_rows": + param = InputParam( + name="condition_rows", + type_hint=torch.Tensor, + default=None, + ) + inputs.append(param) + return inputs + + @property + def intermediate_outputs(self): + return self._stock.intermediate_outputs + + def __call__(self, components, state): + # Read off the state: the latents step does not declare it + if not state.get("condition_latents"): + return components, state + return self._stock(components, state) + + +def _when_conditioned(stock, description): + """A `DwH3WhenConditionedStep` running the stock step `stock`.""" + return type( + DwH3WhenConditionedStep.__name__, + (DwH3WhenConditionedStep,), + {"stock": stock, "stock_description": description}, + ) + + +CONDITION_STEP = _when_conditioned( + before_denoise.MiniMaxH3PrepareConditionLatentsStep, + "dw: diffusers' `prepare_condition_latents`, run in `t2va` only when `guides` gave it condition latents.", +) +LATENTS_STEP = _when_conditioned( + before_denoise.MiniMaxH3FL2VAPrepareLatentsStep, + "dw: diffusers' `prepare_latents_fl2va`, run in `t2va` only when `guides` gave it condition rows.", +) + + +class DwH3GuideAudioStep(ModularPipelineBlocks): + model_name = "minimax-h3" + + @property + def description(self): + return ( + "dw: puts the guides' encoded audio in front of `audio_latents` as the condition audio prefix the " + "guide layout reserved. Does nothing when no guide carries audio." + ) + + @property + def inputs(self): + return [ + InputParam(name="audio_latents", type_hint=torch.Tensor, required=True), + InputParam(name="audio_indices", type_hint=torch.Tensor, required=True), + InputParam(name=GUIDE_AUDIO_ROWS, type_hint=torch.Tensor, default=None), + ] + + @property + def intermediate_outputs(self): + return [OutputParam("audio_latents", type_hint=torch.Tensor)] + + def __call__(self, components, state): + block_state = self.get_block_state(state) + rows = getattr(block_state, GUIDE_AUDIO_ROWS) + audio_latents = block_state.audio_latents + # Once: the layout counted the rows into `audio_indices` already + if rows is None or audio_latents.shape[0] >= block_state.audio_indices.numel(): + return components, state + block_state.audio_latents = torch.cat( + [rows.to(audio_latents.device, audio_latents.dtype), audio_latents] + ) + self.set_block_state(state, block_state) + return components, state diff --git a/dw/pipeline_processors/h3_guides.py b/dw/pipeline_processors/h3_guides.py index 6cd8b98f..7f7042ab 100644 --- a/dw/pipeline_processors/h3_guides.py +++ b/dw/pipeline_processors/h3_guides.py @@ -20,30 +20,27 @@ Nothing restores them before decode: `after_denoise` slices the condition prefix off, and the target rows at the same times are generated, as with a video guide. -The hold blocks are dw/pipeline_processors/h3_hold.py, which this module builds -on and calls through the module (`h3_hold.encode_audio_span`), never the reverse; -the pure guide rules are dw/pipeline_processors/h3_rules.py. +This module puts the guide blocks in; the blocks themselves, and the row and +position arithmetic they run (`splice_guide_rows`, `guide_audio_span`), are +dw/pipeline_processors/h3_guide_steps.py, imported in `guide_blocks()` only, +since it imports diffusers when it loads (#790). The hold blocks are +dw/pipeline_processors/h3_hold.py and h3_hold_steps.py, which these modules build +on and call through the module (`h3_hold_steps.encode_audio_span`), never the +reverse; the pure guide rules are dw/pipeline_processors/h3_rules.py. """ import logging import torch -from . import h3_hold -from .h3_hold import as_channels_samples, core_denoise_sequences +from .h3_hold import core_denoise_sequences from .h3_rules import ( GUIDE_AUDIO_BLOCK, - GUIDE_AUDIO_ROWS, GUIDE_CONDITION_BLOCK, - GUIDE_FRAMES_PER_CHUNK, GUIDE_LATENTS_BLOCK, - GUIDE_LATENTS_PER_CHUNK, - GUIDES_INPUT, HOLD_BEFORE, HOLD_BLOCK, LAYOUT_STEP, - guide_end_problem, - guide_frame_problem, ) logger = logging.getLogger("dw") @@ -110,178 +107,6 @@ def layout_anchor_problem(): return None -def guide_position_ids(target_time, frame_grid, latent_index, num_latent_frames): - """`(num_latent_frames * rows_per_frame, 3)` rotary positions of one guide. - - Time runs from the target's own time at `latent_index`, with the target's - spacing (`_temporal_position_grid`); every frame carries the target's (h, w) - grid. Rows are frame-major, the order `patchify_video_latents` packs them in. - """ - from diffusers.modular_pipelines.minimax_h3.before_denoise import ( - _temporal_position_grid, - ) - - times = _temporal_position_grid(num_latent_frames, float(target_time[latent_index])) - rows_per_frame = frame_grid.shape[0] - positions = torch.empty(num_latent_frames, rows_per_frame, 3, dtype=torch.float64) - positions[:, :, 0] = times[:, None] - positions[:, :, 1:] = frame_grid[None] - return positions.reshape(-1, 3) - - -def splice_guide_rows(layout, num_text_tokens, guide_positions, video_tag): - """The packed layout with guide rows inserted after the existing condition rows. - - `layout` is `(position_ids, token_tags, video_indices, audio_indices, - text_indices, num_condition_video_rows)` as the stock layout built it, on the - CPU. Returns the same six, widened by `len(guide_positions)` video rows that - sit between the keyframe rows and the target audio. - """ - position_ids, token_tags, video_indices, audio_indices, text_indices, rows = layout - count = guide_positions.shape[0] - if not count: - return layout - at = num_text_tokens + rows - sequence_length = position_ids.shape[0] + count - position_ids = torch.cat( - [position_ids[:at], guide_positions.to(position_ids.dtype), position_ids[at:]] - ) - token_tags = torch.cat( - [ - token_tags[:at], - torch.full((count,), video_tag, dtype=token_tags.dtype), - token_tags[at:], - ] - ) - audio_start = int(audio_indices[0]) if audio_indices.numel() else at - video_start = audio_start + audio_indices.numel() - video_indices = torch.cat( - [ - torch.arange(num_text_tokens, audio_start + count), - torch.arange(video_start + count, sequence_length), - ] - ) - audio_indices = audio_indices + count - return ( - position_ids, - token_tags, - video_indices, - audio_indices, - text_indices, - rows + count, - ) - - -def guide_audio_latents(num_frames, fps=24, latents_per_second=40): - """M, the audio latents a guide of `num_frames` frames holds at frame 0: - round(num_frames * 40 / 24). 22 -> 37, 39 -> 65.""" - return int(round(num_frames * latents_per_second / fps)) - - -def guide_audio_span(frame, num_frames, fps=24, latents_per_second=40): - """(start, pad, count) of a guide's audio on the target audio grid. - - `start` is the target audio latent the guide's audio lands on, the one at or - before its first frame: floor(frame * 40 / 24). `pad` is the seconds of - silence that go in front of the guide's audio so its first sample keeps its - time - frame / 24 - once the encode starts at that latent. `count` is the - latents per channel it covers, to round((frame + num_frames) * 40 / 24); M at - frame 0 (`guide_audio_latents`).""" - start = frame * latents_per_second // fps - pad = frame / fps - start / latents_per_second - end = guide_audio_latents(frame + num_frames, fps, latents_per_second) - return start, pad, end - start - - -def pad_guide_audio(waveform, frame, sample_rate, fps=24, latents_per_second=40): - """A guide's `(channels, samples)` waveform, at `sample_rate`, with the - silence in front that puts its first sample at `frame / fps` when sample 0 - is the guide's start latent (`guide_audio_span`) - the audio VAE's hop.""" - start = frame * latents_per_second // fps - origin = int(round(frame * sample_rate / fps)) - pad = origin - start * (sample_rate // latents_per_second) - return torch.nn.functional.pad(waveform, (pad, 0)) if pad > 0 else waveform - - -def guide_audio_positions(num_text_tokens, width_grid, start, count, audio_channels): - """`(count * audio_channels, 3)` rotary positions of one guide's audio rows: - the target soundtrack's own, from its latent `start` - time num_text + start - on, channel-major on the two extremes of the width grid.""" - from diffusers.modular_pipelines.minimax_h3.before_denoise import ( - _fill_audio_positions, - ) - - positions = torch.zeros(count * audio_channels, 3, dtype=torch.float64) - _fill_audio_positions( - positions, - slice(0, positions.shape[0]), - count, - float(num_text_tokens + start), - width_grid, - audio_channels, - ) - return positions - - -def splice_guide_audio_rows(layout, audio_positions, audio_tag): - """The packed layout with guide audio rows in front of the target audio. - - `layout` is `(position_ids, token_tags, video_indices, audio_indices, - text_indices)` on the CPU. Returns the same five, widened by - `len(audio_positions)` audio rows that lead `audio_indices` - the - condition audio prefix - with every video row after them moved along. - """ - position_ids, token_tags, video_indices, audio_indices, text_indices = layout - count = audio_positions.shape[0] - if not count: - return layout - at = int(audio_indices[0]) - position_ids = torch.cat( - [position_ids[:at], audio_positions.to(position_ids.dtype), position_ids[at:]] - ) - token_tags = torch.cat( - [ - token_tags[:at], - torch.full((count,), audio_tag, dtype=token_tags.dtype), - token_tags[at:], - ] - ) - video_indices = torch.where( - video_indices >= at, video_indices + count, video_indices - ) - audio_indices = torch.cat([torch.arange(at, at + count), audio_indices + count]) - return position_ids, token_tags, video_indices, audio_indices, text_indices - - -def fit_guide_frames(frames, height, width): - """`(n, height, width, 3)` uint8 frames covering the canvas: scaled to cover - it and centre-cropped, with the arithmetic H3 fits a follower keyframe with - (diffusers' `MiniMaxH3BeforeEncodeStep`). A clip already at the canvas - passes through untouched.""" - import numpy as np - from PIL import Image - - if frames.shape[1:3] == (height, width): - return frames - source_height, source_width = frames.shape[1:3] - scale = max(width / source_width, height / source_height) - size = ( - max(width, round(source_width * scale)), - max(height, round(source_height * scale)), - ) - left = max(0, (size[0] - width) // 2) - top = max(0, (size[1] - height) // 2) - box = (left, top, left + width, top + height) - return np.stack( - [ - np.asarray( - Image.fromarray(frame).resize(size, Image.Resampling.LANCZOS).crop(box) - ) - for frame in frames - ] - ) - - def guide_audio_waveform(value): """(waveform, sample_rate) of a guide video's soundtrack: a `(channels, samples)` float32 tensor, from what a guide's `video` loads to with its audio - @@ -290,6 +115,9 @@ def guide_audio_waveform(value): Raises: ValueError: If the video carries no audio """ + # Here, not at the top: h3_hold_steps imports diffusers when it loads (#790) + from .h3_hold_steps import as_channels_samples + while True: if hasattr(value, "value") and hasattr(value, "position"): value = value.value @@ -362,332 +190,19 @@ def guide_frames_array(value): return np.ascontiguousarray(value) -def _make_guide_blocks(): - """The guide layout and the two t2va condition steps, defined against the - installed diffusers.""" - import numpy as np - from diffusers.models import AutoencoderKLMiniMaxH3, AutoencoderKLMiniMaxH3Audio - from diffusers.modular_pipelines.minimax_h3 import before_denoise - from diffusers.modular_pipelines.minimax_h3.encoders import encode_vae_condition - from diffusers.modular_pipelines.modular_pipeline import ModularPipelineBlocks - from diffusers.modular_pipelines.modular_pipeline_utils import ( - ComponentSpec, - InputParam, - OutputParam, - ) - - setup_step, reference_encoder, _, latents_per_second = h3_hold._diffusers() - stock_layout = before_denoise.MiniMaxH3PrepareLayoutStep - stock_condition = before_denoise.MiniMaxH3PrepareConditionLatentsStep - stock_latents = before_denoise.MiniMaxH3FL2VAPrepareLatentsStep - - class DwH3GuideLayoutStep(ModularPipelineBlocks): - model_name = "minimax-h3" - - def __init__(self): - super().__init__() - # Called, not copied: the stock layout resolves the canvas and builds - # the sequence, and the guides are spliced into what it built - self._layout = stock_layout() - # ...and the encode a Ref2VA soundtrack reference takes, for a - # guide's audio - self._audio_encoder = reference_encoder() - - @property - def description(self): - return ( - "dw: the stock layout, plus `guides` - each guide clip VAE-encoded on the canvas and appended as " - "condition rows timed from the target frame it lands on. Without `guides` it is the stock layout." - ) - - @property - def expected_components(self): - return list(self._layout.expected_components) + [ - ComponentSpec("vae", AutoencoderKLMiniMaxH3), - ComponentSpec("audio_vae", AutoencoderKLMiniMaxH3Audio), - ] - - @property - def expected_configs(self): - return self._layout.expected_configs - - @property - def inputs(self): - return list(self._layout.inputs) + [ - InputParam( - name=GUIDES_INPUT, - type_hint=list, - default=None, - description=( - "Clips to hold the generated video to: a list of {'video': (n, h, w, 3) uint8 frames, " - "'frame': pixel frame, a multiple of 17}, n one of 1, 5 or 17m + 5. A guide that also " - "carries 'audio': (channels, samples) and 'sample_rate' holds that soundtrack over its span." - ), - ), - InputParam(name="condition_latents", type_hint=list, default=None), - ] - - @property - def intermediate_outputs(self): - return list(self._layout.intermediate_outputs) + [ - OutputParam("condition_latents", type_hint=list), - OutputParam(GUIDE_AUDIO_ROWS, type_hint=torch.Tensor), - ] - - @torch.no_grad() - def __call__(self, components, state): - components, state = self._layout(components, state) - guides = state.get(GUIDES_INPUT) or [] - if not guides: - return components, state - # What the stock layout set is its output, not this block's input, - # so it is read off the state rather than a block state - laid = state.get( - [ - "height", - "width", - "num_frames", - "text_token_tags", - "latent_height", - "latent_width", - "num_latent_frames", - "position_ids", - "token_tags", - "video_indices", - "audio_indices", - "text_indices", - "num_condition_video_rows", - "condition_latents", - "num_audio_latents", - ] - ) - - device = components._execution_device - num_text = laid["text_token_tags"].shape[0] - _, patch_h, patch_w = components.patch_size - frame_grid, width_grid = before_denoise._frame_position_grid( - laid["latent_height"], laid["latent_width"], patch_h, patch_w - ) - target_time = before_denoise._temporal_position_grid( - laid["num_latent_frames"], float(num_text) - ) - - latents, positions = [], [] - for index, guide in enumerate(guides): - frame, frames = guide["frame"], guide["video"] - problem = guide_frame_problem(frame) or guide_end_problem( - frame, frames.shape[0], laid["num_frames"] - ) - if problem: - raise ValueError(f"guides[{index}]: {problem}") - pixels = fit_guide_frames(frames, laid["height"], laid["width"]) - pixels = torch.from_numpy(np.ascontiguousarray(pixels)) - encoded = encode_vae_condition( - components.vae, - pixels.to(device).permute(3, 0, 1, 2)[None], - components.pixel_mean, - components.pixel_std, - components.keyframe_encode_seed, - ) - latents.append(encoded) - positions.append( - guide_position_ids( - target_time, - frame_grid, - frame // GUIDE_FRAMES_PER_CHUNK * GUIDE_LATENTS_PER_CHUNK, - encoded.shape[2], - ) - ) - logger.info( - f"Guide {index}: {frames.shape[0]} frames at frame {frame} " - f"({encoded.shape[2]} latent frames)" - ) - - names = ( - "position_ids", - "token_tags", - "video_indices", - "audio_indices", - "text_indices", - ) - layout = splice_guide_rows( - tuple(laid[name].cpu() for name in names) - + (laid["num_condition_video_rows"],), - num_text, - torch.cat(positions), - components.video_tag, - ) - - # Each guide's audio, encoded on the target audio grid from the latent - # at or before its first frame, as condition audio rows timed there - audio_rows, audio_positions = [], [] - for index, guide in enumerate(guides): - if guide.get("audio") is None: - continue - rows, start, count = self._guide_audio( - components, guide, laid["num_audio_latents"] - ) - audio_rows.append(rows) - audio_positions.append( - guide_audio_positions( - num_text, width_grid, start, count, components.audio_channels - ) - ) - logger.info( - f"Guide {index}: audio held over audio latents {start} to " - f"{start + count} ({rows.shape[0]} rows)" - ) - num_audio_rows = state.get("num_condition_audio_rows") or 0 - if audio_rows: - layout = ( - splice_guide_audio_rows( - layout[:5], torch.cat(audio_positions), components.audio_tag - ) - + layout[5:] - ) - state.set(GUIDE_AUDIO_ROWS, torch.cat(audio_rows)) - num_audio_rows += sum(rows.shape[0] for rows in audio_rows) - - for name, value in zip(names, layout): - state.set(name, value.to(device)) - state.set("num_condition_video_rows", layout[5]) - state.set("num_condition_audio_rows", num_audio_rows) - # Keyframes first, then guides: the order the rows were laid out in - state.set( - "condition_latents", list(laid["condition_latents"] or []) + latents - ) - return components, state - - def _guide_audio(self, components, guide, num_audio_latents): - """(rows, start, count): a guide's audio as `(channels * count, C)` - channel-major rows from target audio latent `start`.""" - frame, length = guide["frame"], guide["video"].shape[0] - fps = components.fps - vae_rate = components.audio_sampling_rate - start, _, count = guide_audio_span(frame, length, fps, latents_per_second) - count = min(count, num_audio_latents - start) - original = as_channels_samples(guide["audio"]) - rate = guide.get("sample_rate") or vae_rate - # The guide's span only, on the VAE's rate and channels the way a - # Ref2VA soundtrack is, then pre-padded to the VAE's hop - waveform = setup_step._normalize_audio_condition( - original, rate, vae_rate, max_duration=length / fps - ) - waveform = pad_guide_audio( - waveform, frame, vae_rate, fps, latents_per_second - ) - latents = h3_hold.encode_audio_span( - self._audio_encoder, components, waveform, count - ) - return latents.reshape(-1, components.audio_latent_channels), start, count - - def _when_conditioned(stock, description): - """A t2va step that runs the stock `fl2va` one only with conditioning - to pack - the stock step refuses an empty list.""" - - class DwH3WhenConditionedStep(ModularPipelineBlocks): - model_name = "minimax-h3" - - def __init__(self): - super().__init__() - self._stock = stock() - - @property - def description(self): - return description - - @property - def expected_components(self): - return self._stock.expected_components - - @property - def inputs(self): - inputs = [] - for param in self._stock.inputs: - if param.name == "condition_latents": - param = InputParam( - name="condition_latents", type_hint=list, default=None - ) - elif param.name == "condition_rows": - param = InputParam( - name="condition_rows", - type_hint=torch.Tensor, - default=None, - ) - inputs.append(param) - return inputs - - @property - def intermediate_outputs(self): - return self._stock.intermediate_outputs - - def __call__(self, components, state): - # Read off the state: the latents step does not declare it - if not state.get("condition_latents"): - return components, state - return self._stock(components, state) - - return DwH3WhenConditionedStep - - class DwH3GuideAudioStep(ModularPipelineBlocks): - model_name = "minimax-h3" - - @property - def description(self): - return ( - "dw: puts the guides' encoded audio in front of `audio_latents` as the condition audio prefix the " - "guide layout reserved. Does nothing when no guide carries audio." - ) - - @property - def inputs(self): - return [ - InputParam(name="audio_latents", type_hint=torch.Tensor, required=True), - InputParam(name="audio_indices", type_hint=torch.Tensor, required=True), - InputParam(name=GUIDE_AUDIO_ROWS, type_hint=torch.Tensor, default=None), - ] - - @property - def intermediate_outputs(self): - return [OutputParam("audio_latents", type_hint=torch.Tensor)] - - def __call__(self, components, state): - block_state = self.get_block_state(state) - rows = getattr(block_state, GUIDE_AUDIO_ROWS) - audio_latents = block_state.audio_latents - # Once: the layout counted the rows into `audio_indices` already - if ( - rows is None - or audio_latents.shape[0] >= block_state.audio_indices.numel() - ): - return components, state - block_state.audio_latents = torch.cat( - [rows.to(audio_latents.device, audio_latents.dtype), audio_latents] - ) - self.set_block_state(state, block_state) - return components, state - - condition_step = _when_conditioned( - stock_condition, - "dw: diffusers' `prepare_condition_latents`, run in `t2va` only when `guides` gave it condition latents.", - ) - latents_step = _when_conditioned( - stock_latents, - "dw: diffusers' `prepare_latents_fl2va`, run in `t2va` only when `guides` gave it condition rows.", - ) - return DwH3GuideLayoutStep, condition_step, latents_step, DwH3GuideAudioStep - - -_GUIDE_BLOCKS = None - - def guide_blocks(): """(DwH3GuideLayoutStep, the t2va condition step, the t2va latents step, - DwH3GuideAudioStep), built once.""" - global _GUIDE_BLOCKS - if _GUIDE_BLOCKS is None: - _GUIDE_BLOCKS = _make_guide_blocks() - return _GUIDE_BLOCKS + DwH3GuideAudioStep) - the classes in + dw/pipeline_processors/h3_guide_steps.py, imported here on first use because + that module imports diffusers when it loads (#790).""" + from . import h3_guide_steps + + return ( + h3_guide_steps.DwH3GuideLayoutStep, + h3_guide_steps.CONDITION_STEP, + h3_guide_steps.LATENTS_STEP, + h3_guide_steps.DwH3GuideAudioStep, + ) def _layout_sequences(pipeline): diff --git a/dw/pipeline_processors/h3_hold.py b/dw/pipeline_processors/h3_hold.py index 3cb7ffd7..fb8136b5 100644 --- a/dw/pipeline_processors/h3_hold.py +++ b/dw/pipeline_processors/h3_hold.py @@ -33,430 +33,43 @@ duration (dw/output_extraction.py), which `HELD_AUDIO_OUTPUT` names for the call (dw/pipeline_processors/pipeline.py). -The names and pure rules these blocks share with validation are -dw/pipeline_processors/h3_rules.py; the guide layout, built on these, is -dw/pipeline_processors/h3_guides.py. +This module puts the blocks in and resolves the `hold_audio` argument; the +blocks themselves, and the sigma and row arithmetic they run, are +dw/pipeline_processors/h3_hold_steps.py, imported in `blocks()` only, since it +imports diffusers when it loads (#790). The names and pure rules these blocks +share with validation are dw/pipeline_processors/h3_rules.py; the guide layout, +built on these, is dw/pipeline_processors/h3_guides.py. """ import logging -import torch - from .h3_rules import ( CORE_DENOISE_SEQUENCES, DENOISE_STEP, - HELD_AUDIO_OUTPUT, - HELD_AUDIO_RATE_OUTPUT, - HELD_ROWS, - HOLD_AUDIO_INPUT, HOLD_BEFORE, HOLD_BLOCK, REFINE_BEFORE, REFINE_BLOCK, - REFINE_STRENGTH_INPUT, RELEASE_BEFORE, RELEASE_BLOCK, ) logger = logging.getLogger("dw") -# Encoded silence pads a track shorter than the video: this many latents of zeros -# are encoded and the middle one taken, clear of any edge effect of the VAE's -SILENCE_LATENTS = 4 - - -def refine_sigmas(strength, num_points, shift): - """The refine schedule: σ₀ = `strength`, then `num_points` points in all down - to 0, linear in the unshifted coordinate u and mapped through the scheduler's - shift σ = shift·u / (1 + (shift−1)·u) - the spacing `set_timesteps` gives a - full grid, which `set_timesteps(sigmas=...)` does not apply itself. - Raises: - ValueError: If `strength` is not in (0, 1) or `num_points` is below 2 - """ - if isinstance(strength, bool) or not isinstance(strength, (int, float)): - raise ValueError(f"refine_strength must be a number, got {strength!r}") - if not 0 < strength < 1: - raise ValueError(f"refine_strength must be in (0, 1), got {strength}") - if num_points is None or num_points < 2: - raise ValueError( - f"refine_strength needs num_inference_steps of 2 or more, got {num_points}" - ) - shift = float(shift) - start = strength / (shift - (shift - 1) * strength) - sigmas = shifted_sigma_grid(num_points, shift, start) - sigmas[0], sigmas[-1] = strength, 0.0 - return sigmas.float() - - -def shifted_sigma_grid(num_points, shift, start=1.0): - """`num_points` sigmas, float64, linear in the unshifted coordinate u from - `start` down to 0 and mapped through σ = shift·u / (1 + (shift−1)·u). At - `start` 1 this is the grid stock `set_timesteps(num_inference_steps)` builds - - the copy `refine_sigmas` rests on, pinned against it by - tests/test_h3_refine.py.""" - shift = float(shift) - u = torch.linspace(start, 0.0, int(num_points), dtype=torch.float64) - return shift * u / (1 + (shift - 1) * u) - - -def _diffusers(): - """The diffusers pieces the blocks are built from - imported on first use, so - importing this module does not import a modular pipeline.""" - from diffusers.modular_pipelines.minimax_h3 import before_encoder, encoders - from diffusers.modular_pipelines.minimax_h3.modular_pipeline import ( - MINIMAX_H3_AUDIO_LATENTS_PER_SECOND, - ) - from diffusers.modular_pipelines.minimax_h3.references import ( - MiniMaxH3AudioReference, - ) +def blocks(): + """(DwH3HoldAudioStep, DwH3RefineScheduleStep, DwH3ReleaseAudioStep) - the + classes in dw/pipeline_processors/h3_hold_steps.py, imported here on first use + because that module imports diffusers when it loads (#790).""" + from . import h3_hold_steps return ( - before_encoder.MiniMaxH3Ref2VASetupStep, - encoders.MiniMaxH3Ref2VAReferenceEncoderStep, - MiniMaxH3AudioReference, - MINIMAX_H3_AUDIO_LATENTS_PER_SECOND, + h3_hold_steps.DwH3HoldAudioStep, + h3_hold_steps.DwH3RefineScheduleStep, + h3_hold_steps.DwH3ReleaseAudioStep, ) -def as_channels_samples(waveform): - """A waveform as a float32 `(channels, samples)` tensor - a bare mono - `(samples,)` one gains its channel axis.""" - waveform = torch.as_tensor(waveform).float() - if waveform.ndim == 1: - waveform = waveform[None] - return waveform - - -def fit_samples(waveform, num_samples): - """Crop a `(channels, samples)` waveform to `num_samples`, or pad it with - silence to that length.""" - missing = num_samples - waveform.shape[-1] - if missing <= 0: - return waveform[..., :num_samples] - return torch.nn.functional.pad(waveform, (0, missing)) - - -def fit_latents(latents, num_latents, silence): - """Crop `(channels, n, C)` audio latents to `num_latents` per channel, or pad - them with `silence` - one `(channels, 1, C)` latent of encoded silence, - repeated.""" - missing = num_latents - latents.shape[1] - if missing <= 0: - return latents[:, :num_latents] - padding = silence.to(latents.dtype).expand(-1, missing, -1) - return torch.cat([latents, padding], dim=1) - - -def encode_audio_latents(encoder, components, waveform): - """`(channels, n, C)` normalized audio latents of a VAE-rate stereo waveform, - through `encoder` - a MiniMaxH3Ref2VAReferenceEncoderStep, whose soundtrack - encode takes the posterior mode.""" - from diffusers.modular_pipelines.modular_pipeline import PipelineState - - audio_reference = _diffusers()[2] - sub_state = PipelineState() - sub_state.set( - "normalized_references", - [audio_reference(audio=waveform, sample_rate=components.audio_sampling_rate)], - ) - _, sub_state = encoder(components, sub_state) - rows = sub_state.get("audio_condition_latents")[0] - return rows.reshape(components.audio_channels, -1, components.audio_latent_channels) - - -def encode_audio_span(encoder, components, waveform, num_latents): - """`(channels, num_latents, C)` latents of a VAE-rate stereo waveform: cut or - padded with silence to `num_latents` latents of samples, encoded, and any - latent the encode falls short by filled with encoded silence.""" - samples_per_latent = components.audio_sampling_rate // _diffusers()[3] - waveform = fit_samples(waveform, num_latents * samples_per_latent) - latents = encode_audio_latents(encoder, components, waveform) - if latents.shape[1] >= num_latents: - return fit_latents(latents, num_latents, None) - silence = encode_audio_latents( - encoder, components, torch.zeros(2, SILENCE_LATENTS * samples_per_latent) - ) - middle = silence.shape[1] // 2 - return fit_latents(latents, num_latents, silence[:, middle : middle + 1]) - - -def _make_blocks(): - """The three block classes, defined against the installed diffusers.""" - from diffusers.modular_pipelines.minimax_h3.before_denoise import ( - MiniMaxH3SetTimestepsStep, - ) - from diffusers.modular_pipelines.modular_pipeline import ModularPipelineBlocks - from diffusers.modular_pipelines.modular_pipeline_utils import ( - InputParam, - OutputParam, - ) - from diffusers.utils.torch_utils import randn_tensor - - ( - setup_step, - reference_encoder, - audio_reference, - latents_per_second, - ) = _diffusers() - - class DwH3HoldAudioStep(ModularPipelineBlocks): - model_name = "minimax-h3" - - def __init__(self): - super().__init__() - # Called, not copied: the encode a Ref2VA soundtrack reference takes - self._encoder = reference_encoder() - - @property - def description(self): - return ( - "dw: holds `hold_audio` as the target soundtrack - encodes it like a Ref2VA soundtrack reference, " - "writes it over the target audio rows and widens `num_condition_audio_rows` over them, so the " - "denoiser treats it as clean conditioning. Does nothing without `hold_audio`." - ) - - @property - def inputs(self): - return [ - InputParam( - name=HOLD_AUDIO_INPUT, - type_hint=audio_reference, - default=None, - description="The soundtrack to generate the video to, as a MiniMaxH3AudioReference.", - ), - InputParam(name="audio_latents", type_hint=torch.Tensor, required=True), - InputParam(name="num_audio_latents", type_hint=int, required=True), - InputParam(name="num_condition_audio_rows", type_hint=int, default=0), - InputParam(name="num_frames", type_hint=int, required=True), - ] - - @property - def intermediate_outputs(self): - return [ - OutputParam("audio_latents", type_hint=torch.Tensor), - OutputParam("num_condition_audio_rows", type_hint=int), - OutputParam(HELD_ROWS, type_hint=int), - OutputParam(HELD_AUDIO_OUTPUT, type_hint=torch.Tensor), - OutputParam(HELD_AUDIO_RATE_OUTPUT, type_hint=int), - ] - - @torch.no_grad() - def __call__(self, components, state): - block_state = self.get_block_state(state) - block_state.dw_held_audio_rows = 0 - block_state.dw_held_audio = None - block_state.dw_held_audio_sampling_rate = None - held = block_state.hold_audio - if held is None: - self.set_block_state(state, block_state) - return components, state - - vae_rate = components.audio_sampling_rate - duration = block_state.num_frames / components.fps - num_latents = block_state.num_audio_latents - original = as_channels_samples(held.audio) - rate = held.sample_rate or vae_rate - - # Onto the VAE's rate and channels the way a Ref2VA reference is, then - # padded with silence to the whole target - a short track encodes - # with its silence rather than having it appended in latent space - waveform = setup_step._normalize_audio_condition( - original, rate, vae_rate, max_duration=duration - ) - latents = encode_audio_span( - self._encoder, components, waveform, num_latents - ) - - # Channel-major target rows, after any Ref2VA reference rows - audio_latents = block_state.audio_latents - start = block_state.num_condition_audio_rows or 0 - rows = latents.reshape(-1, components.audio_latent_channels) - audio_latents[start : start + rows.shape[0]] = rows.to( - audio_latents.device, audio_latents.dtype - ) - block_state.audio_latents = audio_latents - block_state.num_condition_audio_rows = start + rows.shape[0] - block_state.dw_held_audio_rows = rows.shape[0] - - # What the step's result plays: the caller's own track, not the VAE - # round trip of it, fitted to the video at the rate it came at - fitted = fit_samples(original, int(round(duration * rate))) - block_state.dw_held_audio = fitted[None] - block_state.dw_held_audio_sampling_rate = int(rate) - - logger.info( - f"Holding {original.shape[-1] / rate:.2f}s of audio as the " - f"{duration:.2f}s target soundtrack ({rows.shape[0]} rows)" - ) - self.set_block_state(state, block_state) - return components, state - - class DwH3RefineScheduleStep(ModularPipelineBlocks): - model_name = "minimax-h3" - - @property - def description(self): - return ( - "dw: with `refine_strength`, re-denoises the passed `latents` from that sigma - replaces both " - "schedules with `num_inference_steps` shift-spaced points from it down to 0 and re-noises the " - "generated video rows to it. Needs every audio row held (`hold_audio`). Does nothing without " - "`refine_strength`." - ) - - @property - def inputs(self): - return [ - InputParam( - name=REFINE_STRENGTH_INPUT, - type_hint=float, - default=None, - description=( - "The sigma in (0, 1) to re-denoise a passed `latents` from - an upscaled take refined at " - "about 0.2. Runs num_inference_steps - 1 evaluations." - ), - ), - InputParam(name="num_inference_steps", type_hint=int, default=50), - InputParam.template("generator"), - InputParam(name="latents", type_hint=torch.Tensor, required=True), - InputParam(name="audio_latents", type_hint=torch.Tensor, required=True), - InputParam(name="video_indices", type_hint=torch.Tensor, required=True), - InputParam(name="audio_indices", type_hint=torch.Tensor, required=True), - InputParam(name="text_indices", type_hint=torch.Tensor, required=True), - InputParam(name="num_condition_video_rows", type_hint=int, default=0), - InputParam(name="num_condition_audio_rows", type_hint=int, default=0), - ] - - @property - def intermediate_outputs(self): - return [ - OutputParam("latents", type_hint=torch.Tensor), - OutputParam("timesteps", type_hint=torch.Tensor), - OutputParam("audio_timesteps", type_hint=torch.Tensor), - OutputParam("row_timestep_plan", type_hint=list), - ] - - @torch.no_grad() - def __call__(self, components, state): - block_state = self.get_block_state(state) - strength = block_state.refine_strength - if strength is None: - # Leaves the state as set_timesteps left it: the timesteps are - # outputs here, not inputs, so set_block_state would refuse them - return components, state - - audio_rows = block_state.audio_latents.shape[0] - held = block_state.num_condition_audio_rows or 0 - if held != audio_rows: - raise ValueError( - f"refine_strength re-denoises the video only, and {audio_rows - held} of " - f"{audio_rows} audio rows are not held - pass hold_audio with it" - ) - - device = components._execution_device - sigmas = refine_sigmas( - strength, block_state.num_inference_steps, components.scheduler.shift - ) - # Verbatim: set_timesteps(sigmas=...) applies no shift. The audio - # schedule only has to match the video's for the loop's audio step, - # which steps no row when every one is held - components.scheduler.set_timesteps(sigmas=sigmas, device=device) - components.audio_scheduler.set_timesteps(sigmas=sigmas, device=device) - block_state.timesteps = components.scheduler.timesteps - block_state.audio_timesteps = components.audio_scheduler.timesteps - block_state.row_timestep_plan = [ - tuple( - tensor.to(device) - for tensor in MiniMaxH3SetTimestepsStep.build_row_timesteps( - block_state.video_indices, - block_state.audio_indices, - block_state.num_condition_video_rows, - block_state.num_condition_audio_rows, - block_state.text_indices.numel(), - float(timestep), - float(audio_timestep), - max(float(timestep), components.keyframe_noise_aug), - 1.0, - ) - ) - for timestep, audio_timestep in zip( - block_state.timesteps, block_state.audio_timesteps - ) - ] - - # Only the generated rows: FL2VA and Ref2VA conditioning rows lead - latents = block_state.latents - start = block_state.num_condition_video_rows or 0 - clean = latents[start:] - noise = randn_tensor( - clean.shape, - generator=block_state.generator, - device=clean.device, - dtype=clean.dtype, - ) - # The scheduler's own forward process, in H3's t = 1 - σ convention - block_state.latents = torch.cat( - [ - latents[:start], - components.scheduler.scale_noise(clean, 1 - strength, noise), - ] - ) - - logger.info( - f"Refining from sigma {strength} over {len(block_state.timesteps)} steps" - ) - self.set_block_state(state, block_state) - return components, state - - class DwH3ReleaseAudioStep(ModularPipelineBlocks): - model_name = "minimax-h3" - - @property - def description(self): - return ( - "dw: restores `num_condition_audio_rows` to what it was before the hold, so the held rows decode " - "as the generated soundtrack." - ) - - @property - def inputs(self): - return [ - InputParam(name="num_condition_audio_rows", type_hint=int, default=0), - InputParam(name=HELD_ROWS, type_hint=int, default=0), - ] - - @property - def intermediate_outputs(self): - return [ - OutputParam("num_condition_audio_rows", type_hint=int), - OutputParam(HELD_ROWS, type_hint=int), - ] - - @torch.no_grad() - def __call__(self, components, state): - block_state = self.get_block_state(state) - held = block_state.dw_held_audio_rows or 0 - if held: - block_state.num_condition_audio_rows -= held - block_state.dw_held_audio_rows = 0 - self.set_block_state(state, block_state) - return components, state - - return DwH3HoldAudioStep, DwH3RefineScheduleStep, DwH3ReleaseAudioStep - - -_BLOCKS = None - - -def blocks(): - """(DwH3HoldAudioStep, DwH3RefineScheduleStep, DwH3ReleaseAudioStep), built - once.""" - global _BLOCKS - if _BLOCKS is None: - _BLOCKS = _make_blocks() - return _BLOCKS - - def core_denoise_sequences(pipeline): """[(prefix, sequence)] for every H3 core-denoise sequence a pipeline's own block graph holds - empty for anything that is not a MiniMax-H3 modular @@ -580,8 +193,11 @@ def hold_audio_reference(value, base_dir=None): validate_file_extension, ) - audio_reference = _diffusers()[2] - if isinstance(value, audio_reference): + from diffusers.modular_pipelines.minimax_h3.references import ( + MiniMaxH3AudioReference, + ) + + if isinstance(value, MiniMaxH3AudioReference): return value if isinstance(value, str): try: @@ -602,8 +218,10 @@ def hold_audio_reference(value, base_dir=None): # from_file given a URL downloads it with requests itself, outside # the host policy; given a path it only decodes with local_media_file(location, "hold_audio") as path: - return audio_reference.from_file(path) + return MiniMaxH3AudioReference.from_file(path) try: - return audio_reference(**media_arguments(audio_reference, value)) + return MiniMaxH3AudioReference( + **media_arguments(MiniMaxH3AudioReference, value) + ) except ValueError as error: raise ValueError(f"hold_audio: {error}") from error diff --git a/dw/pipeline_processors/h3_hold_steps.py b/dw/pipeline_processors/h3_hold_steps.py new file mode 100644 index 00000000..2cc19a90 --- /dev/null +++ b/dw/pipeline_processors/h3_hold_steps.py @@ -0,0 +1,397 @@ +"""MiniMax-H3 audio hold and refine: the three blocks dw puts into the denoise +graph, and the arithmetic they run. + +What the blocks do, and where they go, is dw/pipeline_processors/h3_hold.py, +which puts them in. They live here, at module level, rather than in a factory +inside h3_hold (#790): a class defined in a function body is counted into that +function, and the factory read as one 261-line function to the ratchet. This +module imports diffusers when it loads, so nothing imports it at module level - +`h3_hold.blocks()` imports it on first use, and validation, which imports +h3_hold and h3_guides, still never loads a modular pipeline. + +The rows and sigmas are here too, not in h3_hold, because the blocks call them +and h3_hold imports this module: a helper left behind would make the import a +cycle. The guide blocks (dw/pipeline_processors/h3_guide_steps.py) encode a +guide's audio through `encode_audio_span` here, which this module never +imports back. +""" + +import logging + +import torch +from diffusers.modular_pipelines.minimax_h3.before_denoise import ( + MiniMaxH3SetTimestepsStep, +) +from diffusers.modular_pipelines.minimax_h3.before_encoder import ( + MiniMaxH3Ref2VASetupStep, +) +from diffusers.modular_pipelines.minimax_h3.encoders import ( + MiniMaxH3Ref2VAReferenceEncoderStep, +) +from diffusers.modular_pipelines.minimax_h3.modular_pipeline import ( + MINIMAX_H3_AUDIO_LATENTS_PER_SECOND, +) +from diffusers.modular_pipelines.minimax_h3.references import MiniMaxH3AudioReference +from diffusers.modular_pipelines.modular_pipeline import ( + ModularPipelineBlocks, + PipelineState, +) +from diffusers.modular_pipelines.modular_pipeline_utils import InputParam, OutputParam +from diffusers.utils.torch_utils import randn_tensor + +from .h3_rules import ( + HELD_AUDIO_OUTPUT, + HELD_AUDIO_RATE_OUTPUT, + HELD_ROWS, + HOLD_AUDIO_INPUT, + REFINE_STRENGTH_INPUT, +) + +logger = logging.getLogger("dw") + +# Encoded silence pads a track shorter than the video: this many latents of zeros +# are encoded and the middle one taken, clear of any edge effect of the VAE's +SILENCE_LATENTS = 4 + + +def refine_sigmas(strength, num_points, shift): + """The refine schedule: σ₀ = `strength`, then `num_points` points in all down + to 0, linear in the unshifted coordinate u and mapped through the scheduler's + shift σ = shift·u / (1 + (shift−1)·u) - the spacing `set_timesteps` gives a + full grid, which `set_timesteps(sigmas=...)` does not apply itself. + + Raises: + ValueError: If `strength` is not in (0, 1) or `num_points` is below 2 + """ + if isinstance(strength, bool) or not isinstance(strength, (int, float)): + raise ValueError(f"refine_strength must be a number, got {strength!r}") + if not 0 < strength < 1: + raise ValueError(f"refine_strength must be in (0, 1), got {strength}") + if num_points is None or num_points < 2: + raise ValueError( + f"refine_strength needs num_inference_steps of 2 or more, got {num_points}" + ) + shift = float(shift) + start = strength / (shift - (shift - 1) * strength) + sigmas = shifted_sigma_grid(num_points, shift, start) + sigmas[0], sigmas[-1] = strength, 0.0 + return sigmas.float() + + +def shifted_sigma_grid(num_points, shift, start=1.0): + """`num_points` sigmas, float64, linear in the unshifted coordinate u from + `start` down to 0 and mapped through σ = shift·u / (1 + (shift−1)·u). At + `start` 1 this is the grid stock `set_timesteps(num_inference_steps)` builds - + the copy `refine_sigmas` rests on, pinned against it by + tests/test_h3_refine.py.""" + shift = float(shift) + u = torch.linspace(start, 0.0, int(num_points), dtype=torch.float64) + return shift * u / (1 + (shift - 1) * u) + + +def as_channels_samples(waveform): + """A waveform as a float32 `(channels, samples)` tensor - a bare mono + `(samples,)` one gains its channel axis.""" + waveform = torch.as_tensor(waveform).float() + if waveform.ndim == 1: + waveform = waveform[None] + return waveform + + +def fit_samples(waveform, num_samples): + """Crop a `(channels, samples)` waveform to `num_samples`, or pad it with + silence to that length.""" + missing = num_samples - waveform.shape[-1] + if missing <= 0: + return waveform[..., :num_samples] + return torch.nn.functional.pad(waveform, (0, missing)) + + +def fit_latents(latents, num_latents, silence): + """Crop `(channels, n, C)` audio latents to `num_latents` per channel, or pad + them with `silence` - one `(channels, 1, C)` latent of encoded silence, + repeated.""" + missing = num_latents - latents.shape[1] + if missing <= 0: + return latents[:, :num_latents] + padding = silence.to(latents.dtype).expand(-1, missing, -1) + return torch.cat([latents, padding], dim=1) + + +def encode_audio_latents(encoder, components, waveform): + """`(channels, n, C)` normalized audio latents of a VAE-rate stereo waveform, + through `encoder` - a MiniMaxH3Ref2VAReferenceEncoderStep, whose soundtrack + encode takes the posterior mode.""" + sub_state = PipelineState() + sub_state.set( + "normalized_references", + [ + MiniMaxH3AudioReference( + audio=waveform, sample_rate=components.audio_sampling_rate + ) + ], + ) + _, sub_state = encoder(components, sub_state) + rows = sub_state.get("audio_condition_latents")[0] + return rows.reshape(components.audio_channels, -1, components.audio_latent_channels) + + +def encode_audio_span(encoder, components, waveform, num_latents): + """`(channels, num_latents, C)` latents of a VAE-rate stereo waveform: cut or + padded with silence to `num_latents` latents of samples, encoded, and any + latent the encode falls short by filled with encoded silence.""" + samples_per_latent = ( + components.audio_sampling_rate // MINIMAX_H3_AUDIO_LATENTS_PER_SECOND + ) + waveform = fit_samples(waveform, num_latents * samples_per_latent) + latents = encode_audio_latents(encoder, components, waveform) + if latents.shape[1] >= num_latents: + return fit_latents(latents, num_latents, None) + silence = encode_audio_latents( + encoder, components, torch.zeros(2, SILENCE_LATENTS * samples_per_latent) + ) + middle = silence.shape[1] // 2 + return fit_latents(latents, num_latents, silence[:, middle : middle + 1]) + + +class DwH3HoldAudioStep(ModularPipelineBlocks): + model_name = "minimax-h3" + + def __init__(self): + super().__init__() + # Called, not copied: the encode a Ref2VA soundtrack reference takes + self._encoder = MiniMaxH3Ref2VAReferenceEncoderStep() + + @property + def description(self): + return ( + "dw: holds `hold_audio` as the target soundtrack - encodes it like a Ref2VA soundtrack reference, " + "writes it over the target audio rows and widens `num_condition_audio_rows` over them, so the " + "denoiser treats it as clean conditioning. Does nothing without `hold_audio`." + ) + + @property + def inputs(self): + return [ + InputParam( + name=HOLD_AUDIO_INPUT, + type_hint=MiniMaxH3AudioReference, + default=None, + description="The soundtrack to generate the video to, as a MiniMaxH3AudioReference.", + ), + InputParam(name="audio_latents", type_hint=torch.Tensor, required=True), + InputParam(name="num_audio_latents", type_hint=int, required=True), + InputParam(name="num_condition_audio_rows", type_hint=int, default=0), + InputParam(name="num_frames", type_hint=int, required=True), + ] + + @property + def intermediate_outputs(self): + return [ + OutputParam("audio_latents", type_hint=torch.Tensor), + OutputParam("num_condition_audio_rows", type_hint=int), + OutputParam(HELD_ROWS, type_hint=int), + OutputParam(HELD_AUDIO_OUTPUT, type_hint=torch.Tensor), + OutputParam(HELD_AUDIO_RATE_OUTPUT, type_hint=int), + ] + + @torch.no_grad() + def __call__(self, components, state): + block_state = self.get_block_state(state) + block_state.dw_held_audio_rows = 0 + block_state.dw_held_audio = None + block_state.dw_held_audio_sampling_rate = None + held = block_state.hold_audio + if held is None: + self.set_block_state(state, block_state) + return components, state + + vae_rate = components.audio_sampling_rate + duration = block_state.num_frames / components.fps + num_latents = block_state.num_audio_latents + original = as_channels_samples(held.audio) + rate = held.sample_rate or vae_rate + + # Onto the VAE's rate and channels the way a Ref2VA reference is, then + # padded with silence to the whole target - a short track encodes + # with its silence rather than having it appended in latent space + waveform = MiniMaxH3Ref2VASetupStep._normalize_audio_condition( + original, rate, vae_rate, max_duration=duration + ) + latents = encode_audio_span(self._encoder, components, waveform, num_latents) + + # Channel-major target rows, after any Ref2VA reference rows + audio_latents = block_state.audio_latents + start = block_state.num_condition_audio_rows or 0 + rows = latents.reshape(-1, components.audio_latent_channels) + audio_latents[start : start + rows.shape[0]] = rows.to( + audio_latents.device, audio_latents.dtype + ) + block_state.audio_latents = audio_latents + block_state.num_condition_audio_rows = start + rows.shape[0] + block_state.dw_held_audio_rows = rows.shape[0] + + # What the step's result plays: the caller's own track, not the VAE + # round trip of it, fitted to the video at the rate it came at + fitted = fit_samples(original, int(round(duration * rate))) + block_state.dw_held_audio = fitted[None] + block_state.dw_held_audio_sampling_rate = int(rate) + + logger.info( + f"Holding {original.shape[-1] / rate:.2f}s of audio as the " + f"{duration:.2f}s target soundtrack ({rows.shape[0]} rows)" + ) + self.set_block_state(state, block_state) + return components, state + + +class DwH3RefineScheduleStep(ModularPipelineBlocks): + model_name = "minimax-h3" + + @property + def description(self): + return ( + "dw: with `refine_strength`, re-denoises the passed `latents` from that sigma - replaces both " + "schedules with `num_inference_steps` shift-spaced points from it down to 0 and re-noises the " + "generated video rows to it. Needs every audio row held (`hold_audio`). Does nothing without " + "`refine_strength`." + ) + + @property + def inputs(self): + return [ + InputParam( + name=REFINE_STRENGTH_INPUT, + type_hint=float, + default=None, + description=( + "The sigma in (0, 1) to re-denoise a passed `latents` from - an upscaled take refined at " + "about 0.2. Runs num_inference_steps - 1 evaluations." + ), + ), + InputParam(name="num_inference_steps", type_hint=int, default=50), + InputParam.template("generator"), + InputParam(name="latents", type_hint=torch.Tensor, required=True), + InputParam(name="audio_latents", type_hint=torch.Tensor, required=True), + InputParam(name="video_indices", type_hint=torch.Tensor, required=True), + InputParam(name="audio_indices", type_hint=torch.Tensor, required=True), + InputParam(name="text_indices", type_hint=torch.Tensor, required=True), + InputParam(name="num_condition_video_rows", type_hint=int, default=0), + InputParam(name="num_condition_audio_rows", type_hint=int, default=0), + ] + + @property + def intermediate_outputs(self): + return [ + OutputParam("latents", type_hint=torch.Tensor), + OutputParam("timesteps", type_hint=torch.Tensor), + OutputParam("audio_timesteps", type_hint=torch.Tensor), + OutputParam("row_timestep_plan", type_hint=list), + ] + + @torch.no_grad() + def __call__(self, components, state): + block_state = self.get_block_state(state) + strength = block_state.refine_strength + if strength is None: + # Leaves the state as set_timesteps left it: the timesteps are + # outputs here, not inputs, so set_block_state would refuse them + return components, state + + audio_rows = block_state.audio_latents.shape[0] + held = block_state.num_condition_audio_rows or 0 + if held != audio_rows: + raise ValueError( + f"refine_strength re-denoises the video only, and {audio_rows - held} of " + f"{audio_rows} audio rows are not held - pass hold_audio with it" + ) + + device = components._execution_device + sigmas = refine_sigmas( + strength, block_state.num_inference_steps, components.scheduler.shift + ) + # Verbatim: set_timesteps(sigmas=...) applies no shift. The audio + # schedule only has to match the video's for the loop's audio step, + # which steps no row when every one is held + components.scheduler.set_timesteps(sigmas=sigmas, device=device) + components.audio_scheduler.set_timesteps(sigmas=sigmas, device=device) + block_state.timesteps = components.scheduler.timesteps + block_state.audio_timesteps = components.audio_scheduler.timesteps + block_state.row_timestep_plan = [ + tuple( + tensor.to(device) + for tensor in MiniMaxH3SetTimestepsStep.build_row_timesteps( + block_state.video_indices, + block_state.audio_indices, + block_state.num_condition_video_rows, + block_state.num_condition_audio_rows, + block_state.text_indices.numel(), + float(timestep), + float(audio_timestep), + max(float(timestep), components.keyframe_noise_aug), + 1.0, + ) + ) + for timestep, audio_timestep in zip( + block_state.timesteps, block_state.audio_timesteps + ) + ] + + # Only the generated rows: FL2VA and Ref2VA conditioning rows lead + latents = block_state.latents + start = block_state.num_condition_video_rows or 0 + clean = latents[start:] + noise = randn_tensor( + clean.shape, + generator=block_state.generator, + device=clean.device, + dtype=clean.dtype, + ) + # The scheduler's own forward process, in H3's t = 1 - σ convention + block_state.latents = torch.cat( + [ + latents[:start], + components.scheduler.scale_noise(clean, 1 - strength, noise), + ] + ) + + logger.info( + f"Refining from sigma {strength} over {len(block_state.timesteps)} steps" + ) + self.set_block_state(state, block_state) + return components, state + + +class DwH3ReleaseAudioStep(ModularPipelineBlocks): + model_name = "minimax-h3" + + @property + def description(self): + return ( + "dw: restores `num_condition_audio_rows` to what it was before the hold, so the held rows decode " + "as the generated soundtrack." + ) + + @property + def inputs(self): + return [ + InputParam(name="num_condition_audio_rows", type_hint=int, default=0), + InputParam(name=HELD_ROWS, type_hint=int, default=0), + ] + + @property + def intermediate_outputs(self): + return [ + OutputParam("num_condition_audio_rows", type_hint=int), + OutputParam(HELD_ROWS, type_hint=int), + ] + + @torch.no_grad() + def __call__(self, components, state): + block_state = self.get_block_state(state) + held = block_state.dw_held_audio_rows or 0 + if held: + block_state.num_condition_audio_rows -= held + block_state.dw_held_audio_rows = 0 + self.set_block_state(state, block_state) + return components, state diff --git a/tests/test_h3_guides.py b/tests/test_h3_guides.py index 60636bd0..cac60ce4 100644 --- a/tests/test_h3_guides.py +++ b/tests/test_h3_guides.py @@ -15,20 +15,12 @@ from dw.pipeline_processors.h3_guides import ( LAYOUT_ANCHORS, default_num_frames, - fit_guide_frames, - guide_audio_latents, - guide_audio_positions, - guide_audio_span, guide_audio_waveform, guide_blocks, guide_frames_array, - guide_position_ids, guides_refusal, insert_guides, layout_anchor_problem, - pad_guide_audio, - splice_guide_audio_rows, - splice_guide_rows, takes_guides, ) from dw.pipeline_processors.h3_hold import insert_audio_hold @@ -52,6 +44,17 @@ minimax = pytest.importorskip("diffusers.modular_pipelines.minimax_h3") from diffusers.modular_pipelines.minimax_h3 import before_denoise # noqa: E402 from diffusers.modular_pipelines.modular_pipeline import PipelineState # noqa: E402 +from dw.pipeline_processors import h3_hold_steps # noqa: E402 +from dw.pipeline_processors.h3_guide_steps import ( # noqa: E402 + fit_guide_frames, + guide_audio_latents, + guide_audio_positions, + guide_audio_span, + guide_position_ids, + pad_guide_audio, + splice_guide_audio_rows, + splice_guide_rows, +) Stock = before_denoise.MiniMaxH3PrepareLayoutStep @@ -598,23 +601,19 @@ def encode(vae, pixels, mean, std, seed): return encode def make_block(self, monkeypatch, seen): - # The block binds the encoder when the guide blocks are built - monkeypatch.setattr(h3_guides, "_GUIDE_BLOCKS", None) + # The block reads the encoder off diffusers' module when it runs self.encoder(monkeypatch, seen) return h3_guides.guide_blocks()[0] def test_one_guide_adds_its_rows(self, monkeypatch): seen = [] block = self.make_block(monkeypatch, seen) - try: - video = np.zeros((22, 32, 32, 3), np.uint8) - state = drive( - block(), - layout_state([{"video": video, "frame": 17}]), - layout_components(), - ) - finally: - monkeypatch.setattr(h3_guides, "_GUIDE_BLOCKS", None) + video = np.zeros((22, 32, 32, 3), np.uint8) + state = drive( + block(), + layout_state([{"video": video, "frame": 17}]), + layout_components(), + ) assert seen == [(1, 3, 22, SIDE, SIDE)] rows_per_frame = (SIDE // 16 // 2) ** 2 guide_rows = 7 * rows_per_frame @@ -635,15 +634,12 @@ def test_keyframe_latents_stay_first(self, monkeypatch): seen = [] block = self.make_block(monkeypatch, seen) marker = torch.full((1, 4, 1, SIDE // 16, SIDE // 16), 3.0) - try: - state = layout_state( - [{"video": np.zeros((1, 64, 64, 3), np.uint8), "frame": 0}], - condition_latents=[marker], - ) - state.set("keyframe_anchors", ("first",)) - state = drive(block(), state, layout_components()) - finally: - monkeypatch.setattr(h3_guides, "_GUIDE_BLOCKS", None) + state = layout_state( + [{"video": np.zeros((1, 64, 64, 3), np.uint8), "frame": 0}], + condition_latents=[marker], + ) + state.set("keyframe_anchors", ("first",)) + state = drive(block(), state, layout_components()) latents = state.get("condition_latents") assert len(latents) == 2 and latents[0] is marker assert latents[1].abs().sum() == 0 @@ -651,16 +647,13 @@ def test_keyframe_latents_stay_first(self, monkeypatch): def test_past_the_end_raises(self, monkeypatch): seen = [] block = self.make_block(monkeypatch, seen) - try: - video = np.zeros((22, 64, 64, 3), np.uint8) - with pytest.raises(ValueError, match="past the end"): - drive( - block(), - layout_state([{"video": video, "frame": 119 // 17 * 17 + 17}]), - layout_components(), - ) - finally: - monkeypatch.setattr(h3_guides, "_GUIDE_BLOCKS", None) + video = np.zeros((22, 64, 64, 3), np.uint8) + with pytest.raises(ValueError, match="past the end"): + drive( + block(), + layout_state([{"video": video, "frame": 119 // 17 * 17 + 17}]), + layout_components(), + ) assert seen == [] @@ -770,16 +763,13 @@ def encode_span(encoder, components, waveform, num_latents): encoded.append((tuple(waveform.shape), num_latents)) return torch.full((2, num_latents, AUDIO_C), 5.0) - monkeypatch.setattr(h3_hold, "encode_audio_span", encode_span) + monkeypatch.setattr(h3_hold_steps, "encode_audio_span", encode_span) return self.make_block(monkeypatch, seen) def run_guides(self, monkeypatch, guides): seen, encoded = [], [] block = self.make_audio_block(monkeypatch, seen, encoded) - try: - state = drive(block(), layout_state(guides), audio_layout_components()) - finally: - monkeypatch.setattr(h3_guides, "_GUIDE_BLOCKS", None) + state = drive(block(), layout_state(guides), audio_layout_components()) return state, encoded def test_audio_guide_adds_its_audio_rows(self, monkeypatch): diff --git a/tests/test_h3_hold_audio.py b/tests/test_h3_hold_audio.py index 3a504e85..91b24183 100644 --- a/tests/test_h3_hold_audio.py +++ b/tests/test_h3_hold_audio.py @@ -16,11 +16,8 @@ from dw.output_extraction import modular_artifacts from dw.pipeline_processors import h3_rules from dw.pipeline_processors.h3_hold import ( - as_channels_samples, blocks, core_denoise_sequences, - fit_latents, - fit_samples, hold_audio_reference, holds_audio, insert_audio_hold, @@ -43,6 +40,11 @@ minimax = pytest.importorskip("diffusers.modular_pipelines.minimax_h3") from diffusers.modular_pipelines.modular_pipeline import PipelineState # noqa: E402 +from dw.pipeline_processors.h3_hold_steps import ( # noqa: E402 + as_channels_samples, + fit_latents, + fit_samples, +) from diffusers.modular_pipelines.minimax_h3.modular_pipeline import ( # noqa: E402 MINIMAX_H3_AUDIO_LATENTS_PER_SECOND, ) diff --git a/tests/test_h3_refine.py b/tests/test_h3_refine.py index 2fa74625..9306452b 100644 --- a/tests/test_h3_refine.py +++ b/tests/test_h3_refine.py @@ -16,9 +16,7 @@ blocks, core_denoise_sequences, insert_audio_hold, - refine_sigmas, refines, - shifted_sigma_grid, ) from dw.pipeline_processors.h3_rules import ( HOLD_BLOCK, @@ -36,6 +34,10 @@ ) from diffusers.schedulers import MiniMaxH3Scheduler # noqa: E402 from diffusers.utils.torch_utils import randn_tensor # noqa: E402 +from dw.pipeline_processors.h3_hold_steps import ( # noqa: E402 + refine_sigmas, + shifted_sigma_grid, +) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) WORKFLOWS = ("t2va", "fl2va", "ref2va") diff --git a/tests/test_h3_rules.py b/tests/test_h3_rules.py index 628dfb0a..1be47c17 100644 --- a/tests/test_h3_rules.py +++ b/tests/test_h3_rules.py @@ -1,4 +1,5 @@ -"""dw/pipeline_processors/h3_rules.py stays torch- and diffusers-free (#771). +"""dw/pipeline_processors/h3_rules.py stays torch- and diffusers-free (#771), and +h3_hold and h3_guides diffusers-free until a block is asked for (#790). `dw/__init__` imports torch on purpose, so the import runs in a fresh interpreter with `dw` and `dw.pipeline_processors` registered as bare packages @@ -10,6 +11,8 @@ import sys from pathlib import Path +import pytest + REPO = Path(__file__).resolve().parent.parent PROBE = """ @@ -20,15 +23,17 @@ package.__path__ = [path] sys.modules[name] = package -import dw.pipeline_processors.h3_rules # noqa: F401 +import dw.pipeline_processors.{module} # noqa: F401 print(",".join(sorted(m for m in ("torch", "diffusers") if m in sys.modules))) """ -def _imported_heavy_modules(): +def _imported_heavy_modules(module="h3_rules"): probe = PROBE.format( - dw=str(REPO / "dw"), pp=str(REPO / "dw" / "pipeline_processors") + dw=str(REPO / "dw"), + pp=str(REPO / "dw" / "pipeline_processors"), + module=module, ) result = subprocess.run( [sys.executable, "-I", "-c", probe], @@ -43,3 +48,10 @@ def _imported_heavy_modules(): def test_h3_rules_imports_neither_torch_nor_diffusers(): assert _imported_heavy_modules() == "" + + +@pytest.mark.parametrize("module", ["h3_hold", "h3_guides"]) +def test_the_block_owners_import_no_diffusers(module): + # Their blocks are h3_hold_steps and h3_guide_steps, which import diffusers + # when they load: validation imports h3_guides, so those wait for a block + assert "diffusers" not in _imported_heavy_modules(module).split(",") From dc3fdd82b0326e2a7e7999eba8d9728fc2d5c5bb Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 9 Oct 2026 20:22:14 -0500 Subject: [PATCH 09/46] refactor(task_domains): move the per-family rules to dw/task_problems.py (#790) task_domains.py was 1,452 lines, the only module over the ratchet's 1,100-line ceiling. The cross-argument rules of the families that need more than a domain - ingredients_grid, crop/paste_face_track, analyze_beats, plan_cuts, apply_lut, check_script's lines - move to a new dw/task_problems.py (631 lines); the generic domain machinery, choice_errors and the media-timing rules (window, slice, fit, dissolve, frame size, select) stay in task_domains.py (857 lines). The dependency runs one way: task_problems reads as_number and choice_errors, and task_argument_errors reaches its rules only through the registered static_check table, so import_cycles stays 0. Importers are updated directly, with no re-export shim; the registry-rules test scans both modules for static checks. Behaviour unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 4 +- docs/TASKS.md | 6 +- dw/script_lines.py | 4 +- dw/task_domains.py | 619 +---------------------------- dw/task_problems.py | 631 ++++++++++++++++++++++++++++++ dw/tasks/beats.py | 8 +- dw/tasks/cuts.py | 9 +- dw/tasks/face_track.py | 2 +- dw/tasks/finish_handlers.py | 2 +- dw/tasks/image_utils.py | 5 +- dw/tasks/lut.py | 4 +- dw/tasks/model_handlers.py | 3 +- dw/tasks/registry.py | 2 +- dw/tasks/video_handlers.py | 12 +- tests/test_analyze_beats.py | 3 +- tests/test_face_track.py | 2 +- tests/test_plan_cuts.py | 3 +- tests/test_script_check.py | 3 +- tests/test_task_domains.py | 14 +- tests/test_task_registry_rules.py | 22 +- 20 files changed, 702 insertions(+), 656 deletions(-) create mode 100644 dw/task_problems.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 207b49f4..4bcdb2ad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,7 +32,7 @@ one to open. | 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*). 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` | 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` | +| 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` | @@ -122,7 +122,7 @@ 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` | ## Security and architecture guardrails diff --git a/docs/TASKS.md b/docs/TASKS.md index c1911661..3d0cc778 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 diff --git a/dw/script_lines.py b/dw/script_lines.py index 472061a1..e74fe268 100644 --- a/dw/script_lines.py +++ b/dw/script_lines.py @@ -1,10 +1,10 @@ """The check_script `lines` and `shots` arguments, parsed (#609). -Kept apart from `dw/tasks/script_check.py` so that `dw/task_domains.py`, +Kept apart from `dw/tasks/script_check.py` so that `dw/task_problems.py`, which refuses a literal `lines` or `shots` at validation, can call the same parser the task runs without importing the task - that import closed a cycle through `tasks.assess`, `tasks.audio_utils` and `tasks.joins` back -into `task_domains`. The task imports these names back, so a caller that +into `task_domains` (where the check lived until #790). The task imports these names back, so a caller that reads them off `script_check` still finds them. """ diff --git a/dw/task_domains.py b/dw/task_domains.py index ba3170d6..4956c299 100644 --- a/dw/task_domains.py +++ b/dw/task_domains.py @@ -27,22 +27,25 @@ but a value's documented range is not a judgement call either: grade's temperature and tint are only defined from -1.0 to 1.0, and a value outside it is not a bolder version of the effect, just an unmodelled one (#349). + +The cross-argument rules of the families that need more than a domain - +face tracks, beats, cuts, LUTs, script lines, ingredients grids - live in +dw/task_problems.py, which reads the helpers here; this module never imports +it, so the dependency runs one way (#790). The media-timing rules below +(window, slice, fit, dissolve, frame size, select) stay here. """ import logging import math import numbers -import re from fractions import Fraction from .references import ( DEFERRED, - GATHER, is_ref, iter_steps, render_path, ) -from .script_lines import parse_lines, parse_shots, shot_names_error from .tasks.registry import RegistryTable logger = logging.getLogger("dw") @@ -115,39 +118,23 @@ # The literal values an argument accepts, for the commands whose run-time -# refusal and static pass read one list -INGREDIENTS_LAYOUTS = ("auto", "rows", "panels") -INGREDIENTS_FITS = ("contain", "cover") -# How fit_to_model puts a source into the model's frame (#602) +# refusal and static pass read one list (ingredients_grid's live beside its +# rules, in dw/task_problems.py). FIT_MODES is how fit_to_model puts a source +# into the model's frame (#602) FIT_MODES = ("letterbox", "stretch", "crop") JOIN_WINDOWS_CURVES = ("cosine", "smoothstep", "linear") # Declared on each registration (`register_command(choices=...)`) TASK_ARGUMENT_CHOICES = RegistryTable("choices") # command -> its cross-argument check, `arguments -> [(argument, message)]` -# (`register_command(static_check=...)`) - the rules below, which -# task_argument_errors runs on each step of the command +# (`register_command(static_check=...)`) - the rules below and the +# per-family ones in dw/task_problems.py, which task_argument_errors runs on +# each step of the command TASK_STATIC_CHECKS = RegistryTable("static_check") # command -> the arguments it reads as whole numbers # (`register_command(whole_numbers=...)`); every other argument with a domain # is a real number. `whole_number` and `real_number` below read each kind at # run time, and the static pass refuses what they would TASK_WHOLE_NUMBER_ARGUMENTS = RegistryTable("whole_numbers") -INGREDIENTS_DEFAULT_MAX_IMAGES = 12 - - -def ingredients_background(background): - """The RGB tuple for an ingredients_grid `background`, or ValueError worded - as the command's refusal. The one parse, shared by the static check and - the run.""" - from PIL import ImageColor - - try: - return ImageColor.getrgb(background) - except (ValueError, AttributeError): - raise ValueError( - "ingredients_grid needs 'background' as a colour name or " - f"#hex, got {background!r}" - ) def choice_errors(command, arguments): @@ -168,588 +155,6 @@ def choice_errors(command, arguments): return errors -def ingredients_grid_errors(arguments): - """[(argument, message)] for the ingredients_grid rules a literal - workflow can break before it runs: a bad `layout`/`fit`/`background`, and - a literal `images` list longer than a literal `max_images`.""" - errors = choice_errors("ingredients_grid", arguments) - background = arguments.get("background") - if isinstance(background, str) and not is_ref(DEFERRED, background): - try: - ingredients_background(background) - except ValueError as error: - errors.append(("background", str(error))) - images = arguments.get("images") - limit = arguments.get("max_images", INGREDIENTS_DEFAULT_MAX_IMAGES) - if isinstance(images, list) and not any(is_ref(GATHER, item) for item in images): - number = as_number(limit) - if number is not None and number > 0 and len(images) > number: - errors.append( - ( - "images", - f"ingredients_grid was given {len(images)} images but " - f"'max_images' is {limit} - raise it, or pass fewer images", - ) - ) - return errors - - -# crop_face_track's rules past a plain domain. The crop feeds a video model -# whose frame size and count move on a grid the workflow declares (modulus, -# remainder and multiple; the defaults are LTX's 8n+1 and 32 pixels), the -# padding is a multiple of the face's own size added on each side, and the gate -# is a ramp from gate_full to gate_zero, so the two must be in that order. -# Owned here so the static pass and the command's run-time refusal read one rule -FACE_CROP_MODULUS = 8 -FACE_CROP_REMAINDER = 1 -FACE_CROP_MULTIPLE = 32 -FACE_PADDING_MAX = 3.0 -FACE_DETECTOR_SUFFIX = ".onnx" - - -def face_track_problems( - crop_size=None, - padding=None, - gate_full=None, - gate_zero=None, - min_confidence=None, - modulus=FACE_CROP_MODULUS, - remainder=FACE_CROP_REMAINDER, - multiple=FACE_CROP_MULTIPLE, -): - """[(argument, message)] for each crop_face_track rule these values break. - - A value that is None or not a number is skipped - the domain check, or - another pass, owns that complaint. - """ - problems = [] - size = as_number(crop_size) - step = as_number(multiple) - if step is not None and (step <= 0 or step != int(step)): - step = None - if size is not None and size > 0 and step is not None: - step = int(step) - if size != int(size) or int(size) % step: - problems.append( - ( - "crop_size", - f"crop_face_track needs 'crop_size' as a multiple of " - f"{step}, got {crop_size!r} - the crops feed a " - f"video model whose frame size moves in steps of " - f"{step}", - ) - ) - mod, rem = as_number(modulus), as_number(remainder) - if ( - mod is not None - and rem is not None - and mod == int(mod) - and rem == int(rem) - and 0 < mod <= rem - ): - problems.append( - ( - "remainder", - f"crop_face_track needs 'remainder' ({int(rem)}) below " - f"'modulus' ({int(mod)})", - ) - ) - pad = as_number(padding) - if pad is not None and pad > FACE_PADDING_MAX: - problems.append( - ( - "padding", - f"crop_face_track needs 'padding' from 0 to {FACE_PADDING_MAX} " - f"(the face's size added on each side), got {padding!r}", - ) - ) - full, zero = as_number(gate_full), as_number(gate_zero) - for name, value, number in ( - ("gate_full", gate_full, full), - ("gate_zero", gate_zero, zero), - ): - if number is not None and number > 1: - problems.append( - ( - name, - f"crop_face_track needs '{name}' as a fraction of the frame " - f"width, at most 1.0, got {value!r}", - ) - ) - if full is not None and zero is not None and zero <= full: - problems.append( - ( - "gate_zero", - f"crop_face_track needs 'gate_zero' above 'gate_full' - " - f"strength ramps from full at gate_full down to 0 at gate_zero " - f"- got gate_full {gate_full!r} and gate_zero {gate_zero!r}", - ) - ) - confidence = as_number(min_confidence) - if confidence is not None and confidence >= 1: - problems.append( - ( - "min_confidence", - f"crop_face_track needs 'min_confidence' below 1.0, got " - f"{min_confidence!r}", - ) - ) - return problems - - -def paste_feather_problems(feather=None): - """[(argument, message)] when paste_face_track's feather is past 1 - the - fraction of the mask's radius that fades, so more than all of it is none.""" - number = as_number(feather) - if number is not None and number > 1: - return [ - ( - "feather", - f"paste_face_track needs 'feather' from 0 to 1 (the fraction " - f"of the paste's radius that fades out), got {feather!r}", - ) - ] - return [] - - -def paste_face_track_errors(arguments): - """[(argument, message)] for the paste_face_track rules a literal - workflow can break before it runs.""" - feather = arguments.get("feather") - if is_ref(DEFERRED, feather): - return [] - return paste_feather_problems(feather) - - -def check_face_detector_source(repo, filename): - """Refuse a detector source that is not a Hub repo id and a bare .onnx - file name in it - before anything is downloaded.""" - from .locations import validate_hub_repo_id, validate_weight_name - - validate_hub_repo_id(repo, what="detector_repo") - validate_weight_name( - filename, (FACE_DETECTOR_SUFFIX,), what="detector_file", subfolders=False - ) - - -def face_track_errors(arguments): - """[(argument, message)] for the crop_face_track rules a literal workflow - can break before it runs: the crop, padding, gate and frame-grid rules, and - a detector source that is not a Hub repo and an .onnx file.""" - # A deferred value is passed through, not dropped: as_number reads it as - # no number, so its rules are skipped instead of run against a default - literal = { - name: arguments[name] - for name in ( - "crop_size", - "padding", - "gate_full", - "gate_zero", - "min_confidence", - "modulus", - "remainder", - "multiple", - ) - if name in arguments - } - errors = face_track_problems(**literal) - from .locations import validate_hub_repo_id, validate_weight_name - from .security import SecurityError - - for name, check in ( - ("detector_repo", lambda v: validate_hub_repo_id(v, what=name)), - ( - "detector_file", - lambda v: validate_weight_name( - v, (FACE_DETECTOR_SUFFIX,), what=name, subfolders=False - ), - ), - ): - value = arguments.get(name) - if name not in arguments or is_ref(DEFERRED, value): - continue - try: - check(value) - except (SecurityError, ValueError) as error: - errors.append((name, str(error).rstrip("."))) - return errors - - -def beats_problems(min_bpm=None, max_bpm=None, anchors=None): - """[(argument, message)] for each analyze_beats rule these values break: - a tempo range that is empty, and anchors that are not one kind of mark - in time order. - - An anchor is a time in seconds, or {beat_index, seconds} placing the - beat at that index; a list holds one kind or the other. A value that is - a reference, or not a number, is skipped - the domain check or the run - owns that complaint. An anchor past the song's end is the run's to find: - only it has the song. - """ - problems = [] - low, high = as_number(min_bpm), as_number(max_bpm) - if low is not None and high is not None and low >= high: - problems.append( - ( - "min_bpm", - f"analyze_beats needs 'min_bpm' ({low:g}) below 'max_bpm' " - f"({high:g}) - the range is where the tempo is searched for", - ) - ) - if anchors is None or is_ref(DEFERRED, anchors): - return problems - if not isinstance(anchors, (list, tuple)): - return problems + [ - ( - "anchors", - "analyze_beats' 'anchors' is a list of times in seconds or of " - f"{{beat_index, seconds}} marks, not {type(anchors).__name__}", - ) - ] - kinds, marks = set(), [] - for index, anchor in enumerate(anchors): - if is_ref(DEFERRED, anchor): - marks.append(None) - continue - if isinstance(anchor, dict): - kinds.add("dict") - beat, seconds = anchor.get("beat_index"), anchor.get("seconds") - if ( - isinstance(beat, bool) - or not isinstance(beat, (int, float, str)) - or as_number(beat) is None - or as_number(beat) != int(as_number(beat)) - or as_number(beat) < 0 - ): - if not is_ref(DEFERRED, beat): - problems.append( - ( - "anchors", - f"analyze_beats' anchors[{index}] needs a whole " - f"'beat_index' at or above zero, not {beat!r}", - ) - ) - beat = None - else: - beat = as_number(beat) - else: - kinds.add("seconds") - beat, seconds = None, anchor - number = as_number(seconds) - if number is None or number < 0: - if not is_ref(DEFERRED, seconds): - problems.append( - ( - "anchors", - f"analyze_beats' anchors[{index}] needs a time in " - f"seconds at or above zero, not {seconds!r}", - ) - ) - number = None - marks.append((index, beat, number)) - if len(kinds) > 1: - problems.append( - ( - "anchors", - "analyze_beats' 'anchors' mixes bare times with " - "{beat_index, seconds} marks - give one kind or the other", - ) - ) - return problems - known = [mark for mark in marks if mark is not None] - for (_, beat_a, time_a), (index, beat_b, time_b) in zip(known, known[1:]): - if time_a is not None and time_b is not None and time_b <= time_a: - problems.append( - ( - "anchors", - f"analyze_beats' anchors[{index}] ({time_b:g} s) is not " - f"after the anchor before it ({time_a:g} s) - anchors are " - "given in time order, each at a different time", - ) - ) - elif beat_a is not None and beat_b is not None and beat_b <= beat_a: - problems.append( - ( - "anchors", - f"analyze_beats' anchors[{index}] places beat {beat_b:g} " - f"after beat {beat_a:g} - beat indexes rise with time", - ) - ) - return problems - - -def beats_errors(arguments): - """[(argument, message)] for the analyze_beats rules a literal workflow - can break before it runs (`beats_problems`).""" - literal = { - name: arguments.get(name) - for name in ("min_bpm", "max_bpm", "anchors") - if not is_ref(DEFERRED, arguments.get(name)) - } - return beats_problems(**literal) - - -def transcript_problem(transcript): - """Why this transcript can't be planned from, or None. A bare string is - text without timings - the commonest mistake, so it is named.""" - if isinstance(transcript, str): - return ( - "plan_cuts needs a timestamped transcript - {text, chunks: " - "[{start, end, text}]}, not plain text: run transcribe_audio with " - "'timestamps': \"segment\" (Whisper's return_timestamps), its " - "result's content_type application/json" - ) - if isinstance(transcript, dict): - chunks = transcript.get("chunks") - if not isinstance(chunks, list): - return ( - "plan_cuts's 'transcript' has no 'chunks' list - run " - "transcribe_audio with 'timestamps' set (Whisper's " - "return_timestamps) for the {text, chunks} shape" - ) - return None - if isinstance(transcript, list): - return None - return ( - "plan_cuts's 'transcript' is a {text, chunks} dict from " - "transcribe_audio with 'timestamps' set (Whisper's return_timestamps), " - f"not {type(transcript).__name__}" - ) - - -def cuts_problems( - transcript=None, - segment_by=None, - min_scene_s=None, - max_scene_s=None, - beats=None, - modulus=None, - remainder=None, - min_frames=None, - max_frames=None, -): - """[(argument, message)] for each plan_cuts rule these values break: a - transcript that is plain text (no timings), a scene range that is empty, - cutting by beat with no beats, and a render grid (modulus, remainder, - min_frames, max_frames) that has a remainder with no modulus or past it, or leaves no length a shot could render at. A - reference is skipped - only the run has its value.""" - problems = [] - if transcript is not None and not is_ref(DEFERRED, transcript): - problem = transcript_problem(transcript) - if problem: - problems.append(("transcript", problem)) - low, high = as_number(min_scene_s), as_number(max_scene_s) - if low is not None and high is not None and low > high: - problems.append( - ( - "min_scene_s", - f"plan_cuts needs 'min_scene_s' ({low:g}) at or below " - f"'max_scene_s' ({high:g}) - no shot could be both", - ) - ) - if segment_by == "beat" and beats is None: - problems.append( - ( - "beats", - "plan_cuts with 'segment_by': \"beat\" needs 'beats' - " - "analyze_beats' result or a list of beat times", - ) - ) - return problems + grid_problems(modulus, remainder, min_frames, max_frames) - - -def grid_problems(modulus, remainder, min_frames, max_frames): - """[(argument, message)] for the plan_cuts render grid rules these values - break. A value that is absent or not a whole number is skipped - - `domain_violation` refuses the second.""" - problems = [] - whole = {} - modulus_given = modulus is not None - for name, value in ( - ("modulus", modulus), - ("remainder", remainder), - ("min_frames", min_frames), - ("max_frames", max_frames), - ): - # A fractional value is the shared whole-number check's to report - number = as_number(value) - if number is not None and number == int(number): - whole[name] = int(number) - modulus, remainder = whole.get("modulus"), whole.get("remainder") - low, high = whole.get("min_frames"), whole.get("max_frames") - if remainder is not None and not modulus_given: - problems.append( - ( - "remainder", - "plan_cuts needs 'modulus' with 'remainder' - a remainder is " - "of a modulus", - ) - ) - if modulus is not None and remainder is not None and remainder >= modulus > 0: - problems.append( - ( - "remainder", - f"plan_cuts needs 'remainder' ({remainder}) below 'modulus' " - f"({modulus})", - ) - ) - if low is not None and high is not None and low > high: - problems.append( - ( - "min_frames", - f"plan_cuts needs 'min_frames' ({low}) at or below " - f"'max_frames' ({high}) - no shot could be both", - ) - ) - elif ( - high is not None - and not problems - and (modulus is None or modulus > 0) - and (remainder or 0) >= 0 - and (low or 1) > 0 - ): - from .variable_constraints import aligned - - floor = max(low or 0, 1) - smallest = aligned(floor, {"modulus": modulus, "remainder": remainder or 0}) - if smallest is None: - smallest = floor - if smallest > high: - problems.append( - ( - "max_frames", - f"plan_cuts needs 'max_frames' ({high}) at or above the " - f"smallest render length on the grid ({smallest}) - no " - "shot could fit", - ) - ) - return problems - - -def cuts_errors(arguments): - """[(argument, message)] for the plan_cuts rules a literal workflow can - break before it runs (`cuts_problems`). An absent 'beats' counts - a - reference to one does not.""" - literal = { - name: arguments.get(name) - for name in ("transcript", "segment_by", "min_scene_s", "max_scene_s") - if not is_ref(DEFERRED, arguments.get(name)) - } - beats = arguments.get("beats") - literal["beats"] = "deferred" if is_ref(DEFERRED, beats) else beats - grid = ("modulus", "remainder", "min_frames", "max_frames") - if not is_ref(DEFERRED, arguments.get("modulus")): - # A grid with a value yet to come can't be judged; a deferred modulus - # hides what a remainder or the frame bounds are measured against - for name in grid: - if not is_ref(DEFERRED, arguments.get(name)): - literal[name] = arguments.get(name) - return choice_errors("plan_cuts", arguments) + cuts_problems(**literal) - - -# apply_lut's palette: 2..16 colours, each #rrggbb (#603 stage D) -MIN_PALETTE_COLOURS = 2 -MAX_PALETTE_COLOURS = 16 -_HEX_COLOUR = re.compile(r"#[0-9a-fA-F]{6}\Z") - - -def lut_source_problem(lut=None, palette=None): - """The refusal when apply_lut is given both or neither of 'lut' and - 'palette', else None. A null value counts as not given.""" - if (lut is None) == (palette is None): - given = "both" if lut is not None else "neither" - return ( - f"apply_lut takes exactly one of 'lut' (a .cube file) or " - f"'palette' (a list of #rrggbb colours) - {given} given" - ) - return None - - -def palette_problem(palette): - """(entry index or None, message) for the first thing wrong with an - apply_lut palette, else None. The index points at the bad entry; None - means the palette as a whole (not a list, or too few colours).""" - if not isinstance(palette, (list, tuple)): - return None, ( - f"apply_lut needs 'palette' as a list of {MIN_PALETTE_COLOURS} " - f"to {MAX_PALETTE_COLOURS} #rrggbb colours, dark to light, not " - f"{type(palette).__name__}" - ) - for index, colour in enumerate(palette): - if not isinstance(colour, str) or not _HEX_COLOUR.match(colour): - return index, ( - f"apply_lut 'palette' entry {index} ({str(colour)[:40]!r}) " - f"is not a #rrggbb colour" - ) - if len(palette) < MIN_PALETTE_COLOURS: - listed = f" ({palette[0]!r})" if palette else "" - return None, ( - f"apply_lut 'palette' has {len(palette)} colour{listed}; it " - f"takes {MIN_PALETTE_COLOURS} to {MAX_PALETTE_COLOURS}, dark to light" - ) - if len(palette) > MAX_PALETTE_COLOURS: - index = MAX_PALETTE_COLOURS - return index, ( - f"apply_lut 'palette' has {len(palette)} colours; it takes at " - f"most {MAX_PALETTE_COLOURS}, so entry {index} " - f"({palette[index]!r}) onward is too many" - ) - return None - - -def check_lut_source(lut=None, palette=None): - """lut_source_problem and palette_problem at run time, as ValueError.""" - problem = lut_source_problem(lut, palette) - if problem is not None: - raise ValueError(problem) - if palette is not None: - found = palette_problem(palette) - if found is not None: - raise ValueError(found[1]) - - -def lut_errors(arguments): - """[(argument, message)] for the apply_lut source rules a literal - workflow can break before it runs. A reference counts as given - which - one was named is known now - but a palette's colours are judged only - when they are literal.""" - lut = arguments.get("lut") - palette = arguments.get("palette") - problem = lut_source_problem(lut, palette) - if problem is not None: - return [("palette" if palette is not None else "lut", problem)] - if palette is None or is_ref(DEFERRED, palette): - return [] - found = palette_problem(palette) - return [] if found is None else [("palette", found[1])] - - -def script_lines_errors(arguments): - """[(argument, message)] for a literal check_script `lines` or `shots` - the task's `parse_lines`/`parse_shots` would refuse, and for a literal - line naming a shot a literal `shots` lacks. A reference is left to the - run, which refuses one that resolves to no list, and so is a shot map the - take carries: only the run can read it.""" - errors = [] - parsed_lines = parsed_shots = None - if "lines" in arguments and not is_ref(DEFERRED, arguments["lines"]): - try: - parsed_lines = parse_lines(arguments["lines"]) - except ValueError as error: - errors.append(("lines", str(error))) - shots = arguments.get("shots") - if shots is not None and not is_ref(DEFERRED, shots): - try: - parsed_shots = parse_shots(shots) - except ValueError as error: - errors.append(("shots", str(error))) - if parsed_lines and parsed_shots: - refusal = shot_names_error(parsed_lines, parsed_shots) - if refusal: - errors.append(("lines", refusal)) - return errors - - def in_domain(value, domain): """Whether a number satisfies a domain. Anything unmeasurable is True - a value this cannot read is not this check's to refuse.""" diff --git a/dw/task_problems.py b/dw/task_problems.py new file mode 100644 index 00000000..43f08b5b --- /dev/null +++ b/dw/task_problems.py @@ -0,0 +1,631 @@ +"""The cross-argument rules of the task families that need more than a domain. + +A domain (dw/task_domains.py) is one number against one range. What lives here +is everything a single range cannot say: crop_face_track's crop size on the +video model's frame grid, gate_zero above gate_full; analyze_beats' tempo range +and anchors that are one kind and in time order; plan_cuts' transcript shape +and render grid; apply_lut's one-of-two source and its palette; check_script's +`lines` and `shots`; ingredients_grid's choices and image count. Each family's +`*_problems` function is the rule, and both sides call it - its `*_errors` +static check (registered as the command's `static_check`, run by +`task_argument_errors` at validate) for the literal values a workflow carries, +and the task itself at run time for the values it was actually handed - so a +value is refused at both or at neither, in the same words (#692). + +They were split out of task_domains.py when it passed the architecture +ratchet's size ceiling (#790). The direction is one way: these rules read the +generic helpers there (`as_number`, `choice_errors`), and task_domains never +imports this module - `task_argument_errors` reaches a rule only through the +registry's `static_check` table - so the `import_cycles` ratchet stays at 0. +The media-timing rules (window, slice, fit, dissolve, frame size, select) stay +in task_domains.py, beside the comment that explains why they have one home. +""" + +import re + +from .references import DEFERRED, GATHER, is_ref +from .script_lines import parse_lines, parse_shots, shot_names_error +from .task_domains import as_number, choice_errors + +# ingredients_grid's literal choices, read by its registration and by +# ingredients_grid_errors below, and its default image cap +INGREDIENTS_LAYOUTS = ("auto", "rows", "panels") +INGREDIENTS_FITS = ("contain", "cover") +INGREDIENTS_DEFAULT_MAX_IMAGES = 12 + + +def ingredients_background(background): + """The RGB tuple for an ingredients_grid `background`, or ValueError worded + as the command's refusal. The one parse, shared by the static check and + the run.""" + from PIL import ImageColor + + try: + return ImageColor.getrgb(background) + except (ValueError, AttributeError): + raise ValueError( + "ingredients_grid needs 'background' as a colour name or " + f"#hex, got {background!r}" + ) + + +def ingredients_grid_errors(arguments): + """[(argument, message)] for the ingredients_grid rules a literal + workflow can break before it runs: a bad `layout`/`fit`/`background`, and + a literal `images` list longer than a literal `max_images`.""" + errors = choice_errors("ingredients_grid", arguments) + background = arguments.get("background") + if isinstance(background, str) and not is_ref(DEFERRED, background): + try: + ingredients_background(background) + except ValueError as error: + errors.append(("background", str(error))) + images = arguments.get("images") + limit = arguments.get("max_images", INGREDIENTS_DEFAULT_MAX_IMAGES) + if isinstance(images, list) and not any(is_ref(GATHER, item) for item in images): + number = as_number(limit) + if number is not None and number > 0 and len(images) > number: + errors.append( + ( + "images", + f"ingredients_grid was given {len(images)} images but " + f"'max_images' is {limit} - raise it, or pass fewer images", + ) + ) + return errors + + +# crop_face_track's rules past a plain domain. The crop feeds a video model +# whose frame size and count move on a grid the workflow declares (modulus, +# remainder and multiple; the defaults are LTX's 8n+1 and 32 pixels), the +# padding is a multiple of the face's own size added on each side, and the gate +# is a ramp from gate_full to gate_zero, so the two must be in that order. +# Owned here so the static pass and the command's run-time refusal read one rule +FACE_CROP_MODULUS = 8 +FACE_CROP_REMAINDER = 1 +FACE_CROP_MULTIPLE = 32 +FACE_PADDING_MAX = 3.0 +FACE_DETECTOR_SUFFIX = ".onnx" + + +def face_track_problems( + crop_size=None, + padding=None, + gate_full=None, + gate_zero=None, + min_confidence=None, + modulus=FACE_CROP_MODULUS, + remainder=FACE_CROP_REMAINDER, + multiple=FACE_CROP_MULTIPLE, +): + """[(argument, message)] for each crop_face_track rule these values break. + + A value that is None or not a number is skipped - the domain check, or + another pass, owns that complaint. + """ + problems = [] + size = as_number(crop_size) + step = as_number(multiple) + if step is not None and (step <= 0 or step != int(step)): + step = None + if size is not None and size > 0 and step is not None: + step = int(step) + if size != int(size) or int(size) % step: + problems.append( + ( + "crop_size", + f"crop_face_track needs 'crop_size' as a multiple of " + f"{step}, got {crop_size!r} - the crops feed a " + f"video model whose frame size moves in steps of " + f"{step}", + ) + ) + mod, rem = as_number(modulus), as_number(remainder) + if ( + mod is not None + and rem is not None + and mod == int(mod) + and rem == int(rem) + and 0 < mod <= rem + ): + problems.append( + ( + "remainder", + f"crop_face_track needs 'remainder' ({int(rem)}) below " + f"'modulus' ({int(mod)})", + ) + ) + pad = as_number(padding) + if pad is not None and pad > FACE_PADDING_MAX: + problems.append( + ( + "padding", + f"crop_face_track needs 'padding' from 0 to {FACE_PADDING_MAX} " + f"(the face's size added on each side), got {padding!r}", + ) + ) + full, zero = as_number(gate_full), as_number(gate_zero) + for name, value, number in ( + ("gate_full", gate_full, full), + ("gate_zero", gate_zero, zero), + ): + if number is not None and number > 1: + problems.append( + ( + name, + f"crop_face_track needs '{name}' as a fraction of the frame " + f"width, at most 1.0, got {value!r}", + ) + ) + if full is not None and zero is not None and zero <= full: + problems.append( + ( + "gate_zero", + f"crop_face_track needs 'gate_zero' above 'gate_full' - " + f"strength ramps from full at gate_full down to 0 at gate_zero " + f"- got gate_full {gate_full!r} and gate_zero {gate_zero!r}", + ) + ) + confidence = as_number(min_confidence) + if confidence is not None and confidence >= 1: + problems.append( + ( + "min_confidence", + f"crop_face_track needs 'min_confidence' below 1.0, got " + f"{min_confidence!r}", + ) + ) + return problems + + +def paste_feather_problems(feather=None): + """[(argument, message)] when paste_face_track's feather is past 1 - the + fraction of the mask's radius that fades, so more than all of it is none.""" + number = as_number(feather) + if number is not None and number > 1: + return [ + ( + "feather", + f"paste_face_track needs 'feather' from 0 to 1 (the fraction " + f"of the paste's radius that fades out), got {feather!r}", + ) + ] + return [] + + +def paste_face_track_errors(arguments): + """[(argument, message)] for the paste_face_track rules a literal + workflow can break before it runs.""" + feather = arguments.get("feather") + if is_ref(DEFERRED, feather): + return [] + return paste_feather_problems(feather) + + +def check_face_detector_source(repo, filename): + """Refuse a detector source that is not a Hub repo id and a bare .onnx + file name in it - before anything is downloaded.""" + from .locations import validate_hub_repo_id, validate_weight_name + + validate_hub_repo_id(repo, what="detector_repo") + validate_weight_name( + filename, (FACE_DETECTOR_SUFFIX,), what="detector_file", subfolders=False + ) + + +def face_track_errors(arguments): + """[(argument, message)] for the crop_face_track rules a literal workflow + can break before it runs: the crop, padding, gate and frame-grid rules, and + a detector source that is not a Hub repo and an .onnx file.""" + # A deferred value is passed through, not dropped: as_number reads it as + # no number, so its rules are skipped instead of run against a default + literal = { + name: arguments[name] + for name in ( + "crop_size", + "padding", + "gate_full", + "gate_zero", + "min_confidence", + "modulus", + "remainder", + "multiple", + ) + if name in arguments + } + errors = face_track_problems(**literal) + from .locations import validate_hub_repo_id, validate_weight_name + from .security import SecurityError + + for name, check in ( + ("detector_repo", lambda v: validate_hub_repo_id(v, what=name)), + ( + "detector_file", + lambda v: validate_weight_name( + v, (FACE_DETECTOR_SUFFIX,), what=name, subfolders=False + ), + ), + ): + value = arguments.get(name) + if name not in arguments or is_ref(DEFERRED, value): + continue + try: + check(value) + except (SecurityError, ValueError) as error: + errors.append((name, str(error).rstrip("."))) + return errors + + +def beats_problems(min_bpm=None, max_bpm=None, anchors=None): + """[(argument, message)] for each analyze_beats rule these values break: + a tempo range that is empty, and anchors that are not one kind of mark + in time order. + + An anchor is a time in seconds, or {beat_index, seconds} placing the + beat at that index; a list holds one kind or the other. A value that is + a reference, or not a number, is skipped - the domain check or the run + owns that complaint. An anchor past the song's end is the run's to find: + only it has the song. + """ + problems = [] + low, high = as_number(min_bpm), as_number(max_bpm) + if low is not None and high is not None and low >= high: + problems.append( + ( + "min_bpm", + f"analyze_beats needs 'min_bpm' ({low:g}) below 'max_bpm' " + f"({high:g}) - the range is where the tempo is searched for", + ) + ) + if anchors is None or is_ref(DEFERRED, anchors): + return problems + if not isinstance(anchors, (list, tuple)): + return problems + [ + ( + "anchors", + "analyze_beats' 'anchors' is a list of times in seconds or of " + f"{{beat_index, seconds}} marks, not {type(anchors).__name__}", + ) + ] + kinds, marks = set(), [] + for index, anchor in enumerate(anchors): + if is_ref(DEFERRED, anchor): + marks.append(None) + continue + if isinstance(anchor, dict): + kinds.add("dict") + beat, seconds = anchor.get("beat_index"), anchor.get("seconds") + if ( + isinstance(beat, bool) + or not isinstance(beat, (int, float, str)) + or as_number(beat) is None + or as_number(beat) != int(as_number(beat)) + or as_number(beat) < 0 + ): + if not is_ref(DEFERRED, beat): + problems.append( + ( + "anchors", + f"analyze_beats' anchors[{index}] needs a whole " + f"'beat_index' at or above zero, not {beat!r}", + ) + ) + beat = None + else: + beat = as_number(beat) + else: + kinds.add("seconds") + beat, seconds = None, anchor + number = as_number(seconds) + if number is None or number < 0: + if not is_ref(DEFERRED, seconds): + problems.append( + ( + "anchors", + f"analyze_beats' anchors[{index}] needs a time in " + f"seconds at or above zero, not {seconds!r}", + ) + ) + number = None + marks.append((index, beat, number)) + if len(kinds) > 1: + problems.append( + ( + "anchors", + "analyze_beats' 'anchors' mixes bare times with " + "{beat_index, seconds} marks - give one kind or the other", + ) + ) + return problems + known = [mark for mark in marks if mark is not None] + for (_, beat_a, time_a), (index, beat_b, time_b) in zip(known, known[1:]): + if time_a is not None and time_b is not None and time_b <= time_a: + problems.append( + ( + "anchors", + f"analyze_beats' anchors[{index}] ({time_b:g} s) is not " + f"after the anchor before it ({time_a:g} s) - anchors are " + "given in time order, each at a different time", + ) + ) + elif beat_a is not None and beat_b is not None and beat_b <= beat_a: + problems.append( + ( + "anchors", + f"analyze_beats' anchors[{index}] places beat {beat_b:g} " + f"after beat {beat_a:g} - beat indexes rise with time", + ) + ) + return problems + + +def beats_errors(arguments): + """[(argument, message)] for the analyze_beats rules a literal workflow + can break before it runs (`beats_problems`).""" + literal = { + name: arguments.get(name) + for name in ("min_bpm", "max_bpm", "anchors") + if not is_ref(DEFERRED, arguments.get(name)) + } + return beats_problems(**literal) + + +def transcript_problem(transcript): + """Why this transcript can't be planned from, or None. A bare string is + text without timings - the commonest mistake, so it is named.""" + if isinstance(transcript, str): + return ( + "plan_cuts needs a timestamped transcript - {text, chunks: " + "[{start, end, text}]}, not plain text: run transcribe_audio with " + "'timestamps': \"segment\" (Whisper's return_timestamps), its " + "result's content_type application/json" + ) + if isinstance(transcript, dict): + chunks = transcript.get("chunks") + if not isinstance(chunks, list): + return ( + "plan_cuts's 'transcript' has no 'chunks' list - run " + "transcribe_audio with 'timestamps' set (Whisper's " + "return_timestamps) for the {text, chunks} shape" + ) + return None + if isinstance(transcript, list): + return None + return ( + "plan_cuts's 'transcript' is a {text, chunks} dict from " + "transcribe_audio with 'timestamps' set (Whisper's return_timestamps), " + f"not {type(transcript).__name__}" + ) + + +def cuts_problems( + transcript=None, + segment_by=None, + min_scene_s=None, + max_scene_s=None, + beats=None, + modulus=None, + remainder=None, + min_frames=None, + max_frames=None, +): + """[(argument, message)] for each plan_cuts rule these values break: a + transcript that is plain text (no timings), a scene range that is empty, + cutting by beat with no beats, and a render grid (modulus, remainder, + min_frames, max_frames) that has a remainder with no modulus or past it, or leaves no length a shot could render at. A + reference is skipped - only the run has its value.""" + problems = [] + if transcript is not None and not is_ref(DEFERRED, transcript): + problem = transcript_problem(transcript) + if problem: + problems.append(("transcript", problem)) + low, high = as_number(min_scene_s), as_number(max_scene_s) + if low is not None and high is not None and low > high: + problems.append( + ( + "min_scene_s", + f"plan_cuts needs 'min_scene_s' ({low:g}) at or below " + f"'max_scene_s' ({high:g}) - no shot could be both", + ) + ) + if segment_by == "beat" and beats is None: + problems.append( + ( + "beats", + "plan_cuts with 'segment_by': \"beat\" needs 'beats' - " + "analyze_beats' result or a list of beat times", + ) + ) + return problems + grid_problems(modulus, remainder, min_frames, max_frames) + + +def grid_problems(modulus, remainder, min_frames, max_frames): + """[(argument, message)] for the plan_cuts render grid rules these values + break. A value that is absent or not a whole number is skipped - + `domain_violation` refuses the second.""" + problems = [] + whole = {} + modulus_given = modulus is not None + for name, value in ( + ("modulus", modulus), + ("remainder", remainder), + ("min_frames", min_frames), + ("max_frames", max_frames), + ): + # A fractional value is the shared whole-number check's to report + number = as_number(value) + if number is not None and number == int(number): + whole[name] = int(number) + modulus, remainder = whole.get("modulus"), whole.get("remainder") + low, high = whole.get("min_frames"), whole.get("max_frames") + if remainder is not None and not modulus_given: + problems.append( + ( + "remainder", + "plan_cuts needs 'modulus' with 'remainder' - a remainder is " + "of a modulus", + ) + ) + if modulus is not None and remainder is not None and remainder >= modulus > 0: + problems.append( + ( + "remainder", + f"plan_cuts needs 'remainder' ({remainder}) below 'modulus' " + f"({modulus})", + ) + ) + if low is not None and high is not None and low > high: + problems.append( + ( + "min_frames", + f"plan_cuts needs 'min_frames' ({low}) at or below " + f"'max_frames' ({high}) - no shot could be both", + ) + ) + elif ( + high is not None + and not problems + and (modulus is None or modulus > 0) + and (remainder or 0) >= 0 + and (low or 1) > 0 + ): + from .variable_constraints import aligned + + floor = max(low or 0, 1) + smallest = aligned(floor, {"modulus": modulus, "remainder": remainder or 0}) + if smallest is None: + smallest = floor + if smallest > high: + problems.append( + ( + "max_frames", + f"plan_cuts needs 'max_frames' ({high}) at or above the " + f"smallest render length on the grid ({smallest}) - no " + "shot could fit", + ) + ) + return problems + + +def cuts_errors(arguments): + """[(argument, message)] for the plan_cuts rules a literal workflow can + break before it runs (`cuts_problems`). An absent 'beats' counts - a + reference to one does not.""" + literal = { + name: arguments.get(name) + for name in ("transcript", "segment_by", "min_scene_s", "max_scene_s") + if not is_ref(DEFERRED, arguments.get(name)) + } + beats = arguments.get("beats") + literal["beats"] = "deferred" if is_ref(DEFERRED, beats) else beats + grid = ("modulus", "remainder", "min_frames", "max_frames") + if not is_ref(DEFERRED, arguments.get("modulus")): + # A grid with a value yet to come can't be judged; a deferred modulus + # hides what a remainder or the frame bounds are measured against + for name in grid: + if not is_ref(DEFERRED, arguments.get(name)): + literal[name] = arguments.get(name) + return choice_errors("plan_cuts", arguments) + cuts_problems(**literal) + + +# apply_lut's palette: 2..16 colours, each #rrggbb (#603 stage D) +MIN_PALETTE_COLOURS = 2 +MAX_PALETTE_COLOURS = 16 +_HEX_COLOUR = re.compile(r"#[0-9a-fA-F]{6}\Z") + + +def lut_source_problem(lut=None, palette=None): + """The refusal when apply_lut is given both or neither of 'lut' and + 'palette', else None. A null value counts as not given.""" + if (lut is None) == (palette is None): + given = "both" if lut is not None else "neither" + return ( + f"apply_lut takes exactly one of 'lut' (a .cube file) or " + f"'palette' (a list of #rrggbb colours) - {given} given" + ) + return None + + +def palette_problem(palette): + """(entry index or None, message) for the first thing wrong with an + apply_lut palette, else None. The index points at the bad entry; None + means the palette as a whole (not a list, or too few colours).""" + if not isinstance(palette, (list, tuple)): + return None, ( + f"apply_lut needs 'palette' as a list of {MIN_PALETTE_COLOURS} " + f"to {MAX_PALETTE_COLOURS} #rrggbb colours, dark to light, not " + f"{type(palette).__name__}" + ) + for index, colour in enumerate(palette): + if not isinstance(colour, str) or not _HEX_COLOUR.match(colour): + return index, ( + f"apply_lut 'palette' entry {index} ({str(colour)[:40]!r}) " + f"is not a #rrggbb colour" + ) + if len(palette) < MIN_PALETTE_COLOURS: + listed = f" ({palette[0]!r})" if palette else "" + return None, ( + f"apply_lut 'palette' has {len(palette)} colour{listed}; it " + f"takes {MIN_PALETTE_COLOURS} to {MAX_PALETTE_COLOURS}, dark to light" + ) + if len(palette) > MAX_PALETTE_COLOURS: + index = MAX_PALETTE_COLOURS + return index, ( + f"apply_lut 'palette' has {len(palette)} colours; it takes at " + f"most {MAX_PALETTE_COLOURS}, so entry {index} " + f"({palette[index]!r}) onward is too many" + ) + return None + + +def check_lut_source(lut=None, palette=None): + """lut_source_problem and palette_problem at run time, as ValueError.""" + problem = lut_source_problem(lut, palette) + if problem is not None: + raise ValueError(problem) + if palette is not None: + found = palette_problem(palette) + if found is not None: + raise ValueError(found[1]) + + +def lut_errors(arguments): + """[(argument, message)] for the apply_lut source rules a literal + workflow can break before it runs. A reference counts as given - which + one was named is known now - but a palette's colours are judged only + when they are literal.""" + lut = arguments.get("lut") + palette = arguments.get("palette") + problem = lut_source_problem(lut, palette) + if problem is not None: + return [("palette" if palette is not None else "lut", problem)] + if palette is None or is_ref(DEFERRED, palette): + return [] + found = palette_problem(palette) + return [] if found is None else [("palette", found[1])] + + +def script_lines_errors(arguments): + """[(argument, message)] for a literal check_script `lines` or `shots` + the task's `parse_lines`/`parse_shots` would refuse, and for a literal + line naming a shot a literal `shots` lacks. A reference is left to the + run, which refuses one that resolves to no list, and so is a shot map the + take carries: only the run can read it.""" + errors = [] + parsed_lines = parsed_shots = None + if "lines" in arguments and not is_ref(DEFERRED, arguments["lines"]): + try: + parsed_lines = parse_lines(arguments["lines"]) + except ValueError as error: + errors.append(("lines", str(error))) + shots = arguments.get("shots") + if shots is not None and not is_ref(DEFERRED, shots): + try: + parsed_shots = parse_shots(shots) + except ValueError as error: + errors.append(("shots", str(error))) + if parsed_lines and parsed_shots: + refusal = shot_names_error(parsed_lines, parsed_shots) + if refusal: + errors.append(("lines", refusal)) + return errors diff --git a/dw/tasks/beats.py b/dw/tasks/beats.py index f010c03d..695b0292 100644 --- a/dw/tasks/beats.py +++ b/dw/tasks/beats.py @@ -20,10 +20,8 @@ import types from .registry import register_command -from ..task_domains import ( - POSITIVE, - beats_errors, -) +from ..task_domains import POSITIVE +from ..task_problems import beats_errors logger = logging.getLogger("dw") @@ -59,11 +57,11 @@ def _parse_anchors(anchors, duration): def _coerce_arguments(sample_rate, tempo_bpm, min_bpm, max_bpm, anchors): from ..task_domains import ( - beats_problems, check_arguments, real_number, whole_number, ) + from ..task_problems import beats_problems values = { "sample_rate": whole_number(sample_rate, "sample_rate", COMMAND), diff --git a/dw/tasks/cuts.py b/dw/tasks/cuts.py index 00a82269..0ea6556a 100644 --- a/dw/tasks/cuts.py +++ b/dw/tasks/cuts.py @@ -39,11 +39,13 @@ from ..task_domains import ( NON_NEGATIVE, POSITIVE, - cuts_errors, real_number, - transcript_problem, whole_number, ) +from ..task_problems import ( + cuts_errors, + transcript_problem, +) logger = logging.getLogger("dw") @@ -608,7 +610,8 @@ def _parsed_arguments(transcript, beats, segment_by, **raw): planning starts.""" import types - from ..task_domains import check_arguments, cuts_problems + from ..task_domains import check_arguments + from ..task_problems import cuts_problems # An explicit 0 is a value (every silence counts); only an absent gap takes 2.0. gap = _number(raw["min_gap_seconds"], "min_gap_seconds") diff --git a/dw/tasks/face_track.py b/dw/tasks/face_track.py index 6906b05a..3536f75c 100644 --- a/dw/tasks/face_track.py +++ b/dw/tasks/face_track.py @@ -38,7 +38,7 @@ from ..events import emit_log, emit_warning from ..media_types import AudioVideo, JsonRecord from ..shots import carried_shots -from ..task_domains import ( +from ..task_problems import ( FACE_CROP_MODULUS, FACE_CROP_MULTIPLE, FACE_CROP_REMAINDER, diff --git a/dw/tasks/finish_handlers.py b/dw/tasks/finish_handlers.py index 416b0d9c..87ce72b0 100644 --- a/dw/tasks/finish_handlers.py +++ b/dw/tasks/finish_handlers.py @@ -23,8 +23,8 @@ POSITIVE, SEED, UNIT, - lut_errors, ) +from ..task_problems import lut_errors logger = logging.getLogger("dw") diff --git a/dw/tasks/image_utils.py b/dw/tasks/image_utils.py index f1a61921..fec31ead 100644 --- a/dw/tasks/image_utils.py +++ b/dw/tasks/image_utils.py @@ -3,13 +3,12 @@ from PIL import Image, ImageOps import numpy as np from ..argument_media import fetch_image -from ..task_domains import ( +from ..task_domains import check_arguments, whole_number +from ..task_problems import ( INGREDIENTS_DEFAULT_MAX_IMAGES, INGREDIENTS_FITS, INGREDIENTS_LAYOUTS, - check_arguments, ingredients_background, - whole_number, ) from .borders import add_border_and_mask, add_border_and_mask_with_size from .model_cache import cached_model diff --git a/dw/tasks/lut.py b/dw/tasks/lut.py index 3d5c0861..b7f6067d 100644 --- a/dw/tasks/lut.py +++ b/dw/tasks/lut.py @@ -238,7 +238,7 @@ def color_lut(table): def _palette_colours(palette): """The palette as a (n, 3) float array in 0..1, refused if malformed.""" - from ..task_domains import check_lut_source + from ..task_problems import check_lut_source check_lut_source(palette=palette) return np.array( @@ -284,7 +284,7 @@ def palette_table(palette, size=PALETTE_LUT_SIZE): def lookup_for(lut=None, palette=None): """The Pillow lookup filter for exactly one of a .cube `lut` or a `palette`, refusing both or neither.""" - from ..task_domains import check_lut_source + from ..task_problems import check_lut_source check_lut_source(lut, palette) if palette is not None: diff --git a/dw/tasks/model_handlers.py b/dw/tasks/model_handlers.py index b852cedc..50849db1 100644 --- a/dw/tasks/model_handlers.py +++ b/dw/tasks/model_handlers.py @@ -17,7 +17,8 @@ from .image_ops import per_frame from .registry import register_command -from ..task_domains import POSITIVE, UNIT, script_lines_errors +from ..task_domains import POSITIVE, UNIT +from ..task_problems import script_lines_errors logger = logging.getLogger("dw") diff --git a/dw/tasks/registry.py b/dw/tasks/registry.py index 7cd5efc8..8e88a317 100644 --- a/dw/tasks/registry.py +++ b/dw/tasks/registry.py @@ -149,7 +149,7 @@ def register_command( accepts, refused at validation and listed by `get_task` static_check: `arguments -> [(argument, message)]` - the command's cross-argument rules a literal workflow can break before it runs - (`task_domains.cuts_errors`), called by `task_argument_errors` + (`task_problems.cuts_errors`), called by `task_argument_errors` with the step's expanded arguments media_arguments: Names of arguments that name a file to read but not by the media-key convention (`dw/locations.py`) - confined like diff --git a/dw/tasks/video_handlers.py b/dw/tasks/video_handlers.py index 066fcaf6..5f2c60fa 100644 --- a/dw/tasks/video_handlers.py +++ b/dw/tasks/video_handlers.py @@ -18,19 +18,21 @@ from ..task_domains import ( FINITE, FIT_MODES, - INGREDIENTS_FITS, - INGREDIENTS_LAYOUTS, JOIN_WINDOWS_CURVES, NON_NEGATIVE, NON_POSITIVE, POSITIVE, - face_track_errors, fit_to_model_errors, - ingredients_grid_errors, join_windows_errors, - paste_face_track_errors, window_video_errors, ) +from ..task_problems import ( + INGREDIENTS_FITS, + INGREDIENTS_LAYOUTS, + face_track_errors, + ingredients_grid_errors, + paste_face_track_errors, +) logger = logging.getLogger("dw") diff --git a/tests/test_analyze_beats.py b/tests/test_analyze_beats.py index 8e5a69bf..9ce2d939 100644 --- a/tests/test_analyze_beats.py +++ b/tests/test_analyze_beats.py @@ -6,7 +6,8 @@ import pytest from dw import dsp -from dw.task_domains import beats_problems, task_argument_errors +from dw.task_domains import task_argument_errors +from dw.task_problems import beats_problems from dw.tasks.beats import QUIET_DBFS, analyze_beats SR = 44100 diff --git a/tests/test_face_track.py b/tests/test_face_track.py index 690148aa..8b376444 100644 --- a/tests/test_face_track.py +++ b/tests/test_face_track.py @@ -1,6 +1,6 @@ """ Unit tests for crop_face_track (dw/tasks/face_track.py) and its rules in -dw/task_domains.py. The YuNet detector is stubbed: a coloured square drawn +dw/task_problems.py. The YuNet detector is stubbed: a coloured square drawn into synthetic frames is the "face", and the stub finds that colour's bounding box in whatever array it is handed (the whole frame or an enlarged tile). """ diff --git a/tests/test_plan_cuts.py b/tests/test_plan_cuts.py index b5d60d35..ccc87c2a 100644 --- a/tests/test_plan_cuts.py +++ b/tests/test_plan_cuts.py @@ -4,7 +4,8 @@ import pytest -from dw.task_domains import cuts_errors, task_argument_errors +from dw.task_domains import task_argument_errors +from dw.task_problems import cuts_errors from dw.tasks.cuts import plan_cuts FPS = 24 diff --git a/tests/test_script_check.py b/tests/test_script_check.py index cf49aff9..5728204c 100644 --- a/tests/test_script_check.py +++ b/tests/test_script_check.py @@ -11,7 +11,8 @@ import numpy from dw.introspection import list_tasks -from dw.task_domains import script_lines_errors, task_argument_errors +from dw.task_domains import task_argument_errors +from dw.task_problems import script_lines_errors from dw.script_lines import ( normalize_words, parse_lines, diff --git a/tests/test_task_domains.py b/tests/test_task_domains.py index 11788555..9928d4ef 100644 --- a/tests/test_task_domains.py +++ b/tests/test_task_domains.py @@ -463,7 +463,7 @@ class TestFaceTrackGrid: """crop_face_track's frame grid is the workflow's to declare (#775).""" def test_the_default_multiple_keeps_todays_message(self): - from dw.task_domains import face_track_problems + from dw.task_problems import face_track_problems assert face_track_problems(crop_size=500) == [ ( @@ -475,19 +475,19 @@ def test_the_default_multiple_keeps_todays_message(self): @pytest.mark.parametrize("multiple", [32.0, "32"]) def test_a_whole_multiple_prints_without_a_fraction(self, multiple): - from dw.task_domains import face_track_problems + from dw.task_problems import face_track_problems [(_, message)] = face_track_problems(crop_size=48, multiple=multiple) assert "multiple of 32," in message and "steps of 32" in message def test_a_declared_multiple_replaces_the_default(self): - from dw.task_domains import face_track_problems + from dw.task_problems import face_track_problems assert face_track_problems(crop_size=48, multiple=16) == [] assert face_track_problems(crop_size=48) != [] def test_remainder_must_be_below_modulus(self): - from dw.task_domains import face_track_problems + from dw.task_problems import face_track_problems [(name, message)] = face_track_problems(modulus=4, remainder=4) assert name == "remainder" @@ -495,15 +495,15 @@ def test_remainder_must_be_below_modulus(self): assert face_track_problems(modulus=16, remainder=0) == [] def test_what_the_domain_check_owns_is_skipped(self): - from dw.task_domains import face_track_problems + from dw.task_problems import face_track_problems assert face_track_problems(modulus=0, remainder=3) == [] assert face_track_problems(modulus=8, remainder=-1) == [] assert face_track_problems(crop_size=48, multiple=0) == [] -def test_transcript_problem_lives_in_task_domains_and_names_plan_cuts(): - from dw.task_domains import transcript_problem +def test_transcript_problem_lives_in_task_problems_and_names_plan_cuts(): + from dw.task_problems import transcript_problem assert transcript_problem({"chunks": []}) is None assert transcript_problem("plain").startswith( diff --git a/tests/test_task_registry_rules.py b/tests/test_task_registry_rules.py index 013ab05e..5396c853 100644 --- a/tests/test_task_registry_rules.py +++ b/tests/test_task_registry_rules.py @@ -16,6 +16,7 @@ import pytest import dw.task_domains as task_domains +import dw.task_problems as task_problems import dw.tasks.task # noqa: F401 - registers every command, as validation does from dw.introspection import describe_task from dw.locations import TASK_MEDIA_ARGUMENTS @@ -76,17 +77,18 @@ def test_a_table_read_first_holds_the_self_registering_commands(): def _static_check_functions(): - """Every rule function in task_domains shaped as a static check: named - `*_errors`, taking a step's `arguments` first.""" + """Every rule function in task_domains and task_problems shaped as a + static check: named `*_errors`, taking a step's `arguments` first.""" found = [] - for name, value in vars(task_domains).items(): - if not (inspect.isfunction(value) and name.endswith("_errors")): - continue - if value.__module__ != task_domains.__name__: - continue - parameters = list(inspect.signature(value).parameters) - if parameters and parameters[0] == "arguments": - found.append(value) + for module in (task_domains, task_problems): + for name, value in vars(module).items(): + if not (inspect.isfunction(value) and name.endswith("_errors")): + continue + if value.__module__ != module.__name__: + continue + parameters = list(inspect.signature(value).parameters) + if parameters and parameters[0] == "arguments": + found.append(value) return found From 9d0c5bc42a30bcbeebe0fc45888143d8501d92c2 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 9 Oct 2026 20:24:19 -0500 Subject: [PATCH 10/46] fix(arch): leave .claude/ out of the metrics (#790) claude_md_lines counts every CLAUDE.md under the repo, and agent worktrees under .claude/worktrees are full checkouts: with six open, a local run read 903 lines against a baseline of 129. Co-Authored-By: Claude Opus 5.5 --- scripts/arch_metrics.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/arch_metrics.py b/scripts/arch_metrics.py index 9b15ddb2..cee47f60 100644 --- a/scripts/arch_metrics.py +++ b/scripts/arch_metrics.py @@ -61,7 +61,8 @@ ) # Modules allowed to spell a reference prefix: the one owner (Phase 2a) PREFIX_OWNERS = frozenset({"dw/references.py"}) -EXCLUDED = ("community_pipelines", "node_modules", "venv", ".git") +# .claude: agent worktrees live under .claude/worktrees, each a full checkout +EXCLUDED = ("community_pipelines", "node_modules", "venv", ".git", ".claude") PACKAGES = ("dw", "dw_mcp") REFERENCES_MODULE = "dw/references.py" FALLBACK_REFERENCE_NAMES = frozenset( From 4beadff635051bc7c29a35918ad39595bca89bbf Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 9 Oct 2026 20:24:34 -0500 Subject: [PATCH 11/46] chore(arch): re-baseline after the #790 payback Lowered: - modules_over_size_ceiling 2 -> 0: task.py's handlers split by family, task_domains.py's per-family rules moved to task_problems.py - functions_over_150_lines 7 -> 0 and complex_functions 16 -> 2: plan_cuts, the H3 block factories, run_chain, load_component, concat_videos, script_check.check and the rest; the two left are the parsers lut.parse_cube and prompt_weighting.parse_prompt_attention, accepted - duplicate_blocks 8 -> 0: references.iter_steps replaces the copied step walk in every validation pass Raised, approved by Don on #790: - modules 198 -> 205: the splits themselves - audio/video/finish/model task handler modules, h3_hold_steps and h3_guide_steps (the H3 block classes, imported lazily), task_problems Kept as accepted: test_dw_patch_targets 325 (the new tasks' tests), claude_md_lines 129. Co-Authored-By: Claude Opus 5.5 --- docs/stabilization/baseline.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/stabilization/baseline.json b/docs/stabilization/baseline.json index bddfce11..003b9e8f 100644 --- a/docs/stabilization/baseline.json +++ b/docs/stabilization/baseline.json @@ -1,13 +1,13 @@ { - "modules": 198, - "modules_over_size_ceiling": 2, - "functions_over_150_lines": 7, + "modules": 205, + "modules_over_size_ceiling": 0, + "functions_over_150_lines": 0, "prefix_literals": 0, "prefix_handling": 0, "test_dw_patch_targets": 325, "claude_md_lines": 129, - "duplicate_blocks": 8, - "complex_functions": 16, + "duplicate_blocks": 0, + "complex_functions": 2, "import_cycles": 0, "modules_in_import_cycles": 0 } From a43b527fbbbae1d37949558d4426c1c8138d0658 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 9 Oct 2026 20:36:57 -0500 Subject: [PATCH 12/46] fix(mcp): #792 #799 - client-timeout fallback in wait step; running jobs report finished_steps Co-Authored-By: Claude Sonnet 5.5 --- dw/server/job_record.py | 13 +++++++++++++ dw_mcp/diagnose.py | 12 ++++++++++-- dw_mcp/tools_jobs.py | 14 ++++++-------- plugins/dw/skills/kandinsky-6/SKILL.md | 2 +- plugins/dw/skills/ltx-2.5/SKILL.md | 2 +- plugins/dw/skills/minimax-h3/SKILL.md | 2 +- plugins/dw/skills/minimax-music3/SKILL.md | 2 +- plugins/dw/skills/script-to-video/SKILL.md | 2 +- tests/test_job_progress.py | 16 ++++++++++++++++ 9 files changed, 50 insertions(+), 15 deletions(-) diff --git a/dw/server/job_record.py b/dw/server/job_record.py index 88bb72cc..74742408 100644 --- a/dw/server/job_record.py +++ b/dw/server/job_record.py @@ -182,6 +182,19 @@ def _note_progress(self, event): named = f"{self.step_name}: {message}" if self.step_name else message if named not in self.warnings: self.warnings.append(named) + elif kind == "step_end": + # A finished step's files are on disk now; the manifest a poll + # reads should name them mid-run, not only once the job ends. + # record_manifest replaces this with the run's own at the end + # (#799) + if event.get("files"): + self.manifest.append( + { + "step": event.get("step"), + "files": event["files"], + "subfolder": event.get("subfolder"), + } + ) elif kind == "step_start": self.step_name = event.get("step") # A sub-workflow counts its own steps from zero; what a caller diff --git a/dw_mcp/diagnose.py b/dw_mcp/diagnose.py index 88262d2b..ea961999 100644 --- a/dw_mcp/diagnose.py +++ b/dw_mcp/diagnose.py @@ -245,11 +245,15 @@ def slim_job(job): The arguments of an H3 workflow are thousands of tokens of prompt text, repeated on every poll of a long render; get_job serves them once. The - manifest is kept only once the job is terminal, when it names files. + manifest is kept once the job is terminal, when it names every file; a + running job's carries the steps finished so far (#799), under + `finished_steps` so it is not read as the final list. """ slim = {key: job.get(key) for key in _SLIM_KEYS if key in job} if job.get("status") in TERMINAL_STATUSES: slim["manifest"] = job.get("manifest") + elif job.get("manifest"): + slim["finished_steps"] = job["manifest"] return slim @@ -272,7 +276,11 @@ def wait_for_job(client, job_id, timeout_seconds=20): than inferring it from wall clock. Returns as soon as the job's status is succeeded, failed or cancelled. If the timeout elapses first, returns the job's last-seen status with `still_running: true` instead - of hanging - call again to keep waiting. Returns a slim job - status, + of hanging - call again to keep waiting. If the call dies client-side + ("operation timed out") before the cap, the client's own timeout is + shorter: call again with `timeout_seconds` ~60 and loop; a short wait + still returns `progress`, which hand-polling `get_job` loses. A running + job also lists `finished_steps`, the manifest entries so far. Returns a slim job - status, warnings, error, and the manifest once finished - without the arguments; get_job has those. diff --git a/dw_mcp/tools_jobs.py b/dw_mcp/tools_jobs.py index 6d667653..c6ed5f43 100644 --- a/dw_mcp/tools_jobs.py +++ b/dw_mcp/tools_jobs.py @@ -137,7 +137,9 @@ def wait_for_job(self, job_id: str, timeout_seconds: int = 20) -> dict: budget one call per {cap}s of the job, and one call is enough when {cap} covers its runtime. Every reply says which happened: waited_seconds, timeout_requested_seconds, timeout_applied_seconds - and timeout_capped. + and timeout_capped. If the call dies client-side ("timed out"), call again + with timeout_seconds ~60 and loop; progress survives, get_job loses it. + A running job lists finished_steps. Returns a slim job - status, warnings, error, the manifest once finished - without the arguments (get_job has those). A running job @@ -147,13 +149,9 @@ def wait_for_job(self, job_id: str, timeout_seconds: int = 20) -> dict: `denoise_step` has moved since a poll minutes ago, not by silence: a video reference's lead-in can run many minutes emitting nothing, and denoise gaps are uneven under a transformer block cache - both - normal. If you're also reading get_job_events, a `phase_stall` - entry there is the same silence being narrated, not a fault or a - sign of progress - it repeats every ~30s the phase stays quiet, so - neither seeing one nor watching its event_count climb tells you - anything `denoise_step` doesn't already say better. Full diagnosis, - and why `denoise_total_steps` can read one less than asked, in - WORKFLOW_GUIDE's "The loop", step 5.""" + normal. A `phase_stall` entry in get_job_events is that silence + narrated, repeating every ~30s: not a fault, not progress. Full diagnosis + in WORKFLOW_GUIDE's "The loop", step 5.""" return diagnose.wait_for_job( self.client, job_id, timeout_seconds=timeout_seconds ) diff --git a/plugins/dw/skills/kandinsky-6/SKILL.md b/plugins/dw/skills/kandinsky-6/SKILL.md index b9028587..df61d432 100644 --- a/plugins/dw/skills/kandinsky-6/SKILL.md +++ b/plugins/dw/skills/kandinsky-6/SKILL.md @@ -96,7 +96,7 @@ version of the same instructions). No template uses it yet. takes about 9 minutes. Get the go-ahead, then `run_workflow` with `acknowledged_cost` set to the plan's `{fingerprint, minutes, downloads}`. 3. `wait_for_job`, `timeout_seconds` = estimate plus margin. Call again - while `still_running`. + while `still_running`. If the call dies client-side ("timed out") first, call again with `timeout_seconds` 60 and loop; a running job lists `finished_steps`. 4. A saving step carries a `subfolder`: the one shown to the user is `final`, the rest `intermediate`. `list_gallery(subfolder="final")` lists only deliverables, so keep both in anything you compose. diff --git a/plugins/dw/skills/ltx-2.5/SKILL.md b/plugins/dw/skills/ltx-2.5/SKILL.md index ae42a90a..c762e3d0 100644 --- a/plugins/dw/skills/ltx-2.5/SKILL.md +++ b/plugins/dw/skills/ltx-2.5/SKILL.md @@ -135,7 +135,7 @@ caption spec, verbatim from the pipeline, which the caption must follow. plan's `{fingerprint, minutes, downloads}`. 3. `wait_for_job`, `timeout_seconds` = estimate plus margin; `timeout_applied_seconds` is what you got (`timeout_capped`: the cap cut - it). Call again while `still_running`. + it). Call again while `still_running`. If the call dies client-side ("timed out") first, call again with `timeout_seconds` 60 and loop; a running job lists `finished_steps`. 4. Writing costs on a long chain: `"result": {"save": false}` on every step not worth keeping, as `two-stage` does for `base` and `upscale`; a miss is silent. A saving step carries a `subfolder` - the one shown to the user diff --git a/plugins/dw/skills/minimax-h3/SKILL.md b/plugins/dw/skills/minimax-h3/SKILL.md index da625e63..c566a5e7 100644 --- a/plugins/dw/skills/minimax-h3/SKILL.md +++ b/plugins/dw/skills/minimax-h3/SKILL.md @@ -168,7 +168,7 @@ Before writing lines, read `references/dialogue.md`: how long a line fits a clip plan's `{fingerprint, minutes, downloads}`. 3. `wait_for_job` with `timeout_seconds` = the estimate plus a margin (`timeout_capped` says the server's cap cut it; call again while - `still_running`), then `get_job` for the manifest. A cancelled H3 job runs + `still_running`; on a client "timed out", retry at `timeout_seconds` 60), then `get_job`. A cancelled H3 job runs to its next step boundary. Silence is no hang: `denoise_step` is null through the reference encode (~90 s; 629 s for a video reference on a 3090) and the block cache makes later steps uneven - diff --git a/plugins/dw/skills/minimax-music3/SKILL.md b/plugins/dw/skills/minimax-music3/SKILL.md index 1218c85d..56840291 100644 --- a/plugins/dw/skills/minimax-music3/SKILL.md +++ b/plugins/dw/skills/minimax-music3/SKILL.md @@ -158,7 +158,7 @@ Control" section. `{fingerprint, minutes, downloads}`. 3. `wait_for_job` with `timeout_seconds` = the estimate plus a margin (`timeout_capped` says the server's cap cut it; call again while - `still_running`), then `get_job` for the manifest. Each manifest entry + `still_running`). If the call dies client-side ("timed out") first, call again with `timeout_seconds` 60 and loop; a running job lists `finished_steps`. Then `get_job` for the manifest. Each manifest entry carries `subfolder`: `templates/minimax/music-video` puts the cut in `final` and the song, the singer's portrait and each shot in `intermediate`, and `list_gallery(subfolder="final")` lists only diff --git a/plugins/dw/skills/script-to-video/SKILL.md b/plugins/dw/skills/script-to-video/SKILL.md index ea0e32ee..23212217 100644 --- a/plugins/dw/skills/script-to-video/SKILL.md +++ b/plugins/dw/skills/script-to-video/SKILL.md @@ -85,7 +85,7 @@ must not do even when running unattended. Wait with `wait_for_job`, asking for the plan's estimate plus a margin as `timeout_seconds`; `timeout_capped` says the server's cap cut it short, and -a `still_running` reply means call again. After the job completes, read `get_job_events` / `get_gallery_metadata` for +a `still_running` reply means call again. If the call dies client-side ("timed out") first, call again with `timeout_seconds` 60 and loop; a running job lists `finished_steps`. After the job completes, read `get_job_events` / `get_gallery_metadata` for the warnings the engine already emits (`audio_no_headroom`, `audio_clipped`, an elision diagnostic) and `rerun_job(new_seed=True)` a shot that reads wrong - not by inventing a new heuristic, by reading what is already diff --git a/tests/test_job_progress.py b/tests/test_job_progress.py index a1e87c21..643dc302 100644 --- a/tests/test_job_progress.py +++ b/tests/test_job_progress.py @@ -213,3 +213,19 @@ def test_the_argument_warnings_a_job_was_queued_with_survive(self): assert job.warnings == ["unknown argument 'fsp'", "a spread"] # the spec is what a rerun is built from - appending must not edit it assert job.spec["warnings"] == ["unknown argument 'fsp'"] + + +def test_a_running_job_reports_finished_steps_mid_run(): + """#799: a step's files are named as soon as it ends, not at job end.""" + job = running_job( + {"event": "step_start", "step": "shot1"}, + {"event": "step_end", "step": "shot1", "files": ["a.mp4"], "subfolder": "final"}, + {"event": "step_start", "step": "shot2"}, + ) + + slim = slim_job(job.detail()) + + assert slim["finished_steps"] == [ + {"step": "shot1", "files": ["a.mp4"], "subfolder": "final"} + ] + assert "manifest" not in slim From e0b1c07189eb46371b78c007dac2a6ced51d7d62 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 9 Oct 2026 20:38:40 -0500 Subject: [PATCH 13/46] docs(mcp): #793 - point text-only clients from get_output_audio to get_gallery_metadata for levels Co-Authored-By: Claude Sonnet 5.5 --- docs/WORKFLOW_GUIDE.md | 4 +++- dw_mcp/tools_media.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index 8f11e6d9..3aba3c94 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -732,7 +732,9 @@ by the source, and `validate_workflow` names the entries to add or drop. 6. `get_output_image` to look at what was actually made, and say whether it answers the request. Nothing before this step establishes that it does. `get_output_frames` looks at a video and `get_output_audio` listens to a - soundtrack. + soundtrack. A text-only client reads levels and clipping from + `get_gallery_metadata` instead (`media` peak and mean, `findings`; + `envelope=true` for where). To confirm the words a clip speaks - a text-only client can't consume the `AudioContent` block `get_output_audio` returns - run diff --git a/dw_mcp/tools_media.py b/dw_mcp/tools_media.py index 0547996e..93ce960e 100644 --- a/dw_mcp/tools_media.py +++ b/dw_mcp/tools_media.py @@ -66,8 +66,9 @@ def get_output_audio( large is refused; ask for a part with `start`/`duration` in seconds, per `get_gallery_metadata`'s envelope. The text part says what was cut. To *see* a video, `get_output_frames`. A - text-only client confirms the *words* an output speaks by - transcribing it instead: WORKFLOW_GUIDE's "The loop", step 6, in + text-only client checks levels and clipping in + `get_gallery_metadata` (`media`, `findings`, `envelope=true`), + and the *words* by transcribing: "The loop", step 6, in `get_guide("workflows", section="Authoring a workflow from an agent")`. From 295dacaac64c8c321761592e3e0327c12f590b07 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 9 Oct 2026 20:41:05 -0500 Subject: [PATCH 14/46] docs(skills): #803 - text-only audio QA, save_prompt for large arguments, per-character references Co-Authored-By: Claude Sonnet 5.5 --- plugins/dw/skills/kandinsky-6/SKILL.md | 3 ++- plugins/dw/skills/ltx-2.5/SKILL.md | 3 ++- plugins/dw/skills/minimax-h3/SKILL.md | 5 ++--- .../skills/minimax-h3/references/per-character.md | 13 +++++++++++++ plugins/dw/skills/minimax-music3/SKILL.md | 7 +++++-- plugins/dw/skills/script-to-video/SKILL.md | 5 +++++ plugins/dw/skills/series-episodes/SKILL.md | 9 ++++++++- 7 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 plugins/dw/skills/minimax-h3/references/per-character.md diff --git a/plugins/dw/skills/kandinsky-6/SKILL.md b/plugins/dw/skills/kandinsky-6/SKILL.md index df61d432..9432aa94 100644 --- a/plugins/dw/skills/kandinsky-6/SKILL.md +++ b/plugins/dw/skills/kandinsky-6/SKILL.md @@ -104,7 +104,8 @@ version of the same instructions). No template uses it yet. - `get_output_frames(count=12)` for the clip's shape, and `at` 0 against the still for an image-to-video run, which should match it. - `seams=true` at each join of clips you cut together. - - `get_output_audio` for the soundtrack. The model follows the audio + - `get_output_audio` for the soundtrack (text-only client: `get_gallery_metadata` with `envelope=true` + for its level, `check-script` for speech). The model follows the audio sentence loosely: a sound asked for twice may come once. - Then `get_job` for the manifest and its warnings, and give the user the gallery `url`. diff --git a/plugins/dw/skills/ltx-2.5/SKILL.md b/plugins/dw/skills/ltx-2.5/SKILL.md index c762e3d0..f39a21de 100644 --- a/plugins/dw/skills/ltx-2.5/SKILL.md +++ b/plugins/dw/skills/ltx-2.5/SKILL.md @@ -144,7 +144,8 @@ caption spec, verbatim from the pipeline, which the caption must follow. 5. Judge it yourself: `get_output_frames(count=12)` for a clip's shape, `seams=true` for a chained clip's joins, `at` near the end for a scene cut where the prompt contradicted the image or softness where the refine pass - was skipped, and `get_output_audio` for a near-silent soundtrack. Then + was skipped, and `get_output_audio` for a near-silent soundtrack (text-only client: + `get_gallery_metadata` with `envelope=true`; speech via `check-script`). Then `get_job` for the manifest and its warnings, `get_gallery_metadata` for duration, size and audio presence, and give the user the gallery `url` (`list_gallery`, or the manifest's file name). diff --git a/plugins/dw/skills/minimax-h3/SKILL.md b/plugins/dw/skills/minimax-h3/SKILL.md index c566a5e7..e9a65c48 100644 --- a/plugins/dw/skills/minimax-h3/SKILL.md +++ b/plugins/dw/skills/minimax-h3/SKILL.md @@ -151,9 +151,8 @@ the rules are MiniMax's: built-in enhancer writes the format from those guides. Its `idea` is framed as `Task: T2VA. Duration: 5.17 seconds. Idea: ...`. -Either way: write the whole script before the first shot. Repeat a speaker's voice description verbatim across shots and when a -reference picture should fix identity but not framing, say so in the prompt - -or every shot inherits the portrait's composition. +Either way: write the whole script before the first shot. Repeat a speaker's voice description verbatim across shots. +Several characters: one cropped reference each, never a group picture, which imposes its framing on every shot (`references/per-character.md`). Before writing lines, read `references/dialogue.md`: how long a line fits a clip, and what not to name. ## Run and judge diff --git a/plugins/dw/skills/minimax-h3/references/per-character.md b/plugins/dw/skills/minimax-h3/references/per-character.md new file mode 100644 index 00000000..93be17d6 --- /dev/null +++ b/plugins/dw/skills/minimax-h3/references/per-character.md @@ -0,0 +1,13 @@ +# One reference per character + +A group reference picture imposes its composition on every shot that uses it, +and a prompt line saying the framing is not reused has not been enough to stop +that. Give each character their own reference instead. + +1. Generate or upload the group picture as an asset. +2. For each character, run `recenter_crop` (`list_tasks`; `center_x`, + `center_y`, `crop`, `width`, `height`, `fill`) centred on that character, + so each output holds one person. +3. Reference the crops, one per character, in the shot's reference list + (`keep_output` makes each an `asset:`), never the group picture. +4. Judge the first frames (`get_output_frames`) for the old composition. diff --git a/plugins/dw/skills/minimax-music3/SKILL.md b/plugins/dw/skills/minimax-music3/SKILL.md index 56840291..c7fa081f 100644 --- a/plugins/dw/skills/minimax-music3/SKILL.md +++ b/plugins/dw/skills/minimax-music3/SKILL.md @@ -170,11 +170,14 @@ Control" section. ceiling cut the track (raise it and rerun); well short of it means the song finished on its own. For loudness (`peak_dbfs`, `integrated_lufs`, matching a mix) read `references/loudness.md`. Then listen with - `get_output_audio` (a long track in `start`/`duration` excerpts) for the family's failure modes: a + `get_output_audio` (`start`/`duration` excerpts) for the family's failure modes. A text-only + client can't hear: take level and structure from `get_gallery_metadata` + (`envelope=true`) and `assess_output`, sung lines from `attribute_voices` + (Whisper invents words on music). The modes: a song gone instrumental (name the vocals in the caption), an ending cut mid-note (raise the ceiling, then trim), a structure ignoring the tags (fewer sections, plainer directions). Hand the user the gallery - `url` (`list_gallery`, or the manifest's file name). + `url`. 5. To use the track in a later workflow, `keep_output` makes it an `asset:`; to trim it in the same run, chain `templates/audio-trim-fade` on the output. To cut picture to the song, `analyze_beats` on it returns its bpm and beat diff --git a/plugins/dw/skills/script-to-video/SKILL.md b/plugins/dw/skills/script-to-video/SKILL.md index 23212217..fdb6272b 100644 --- a/plugins/dw/skills/script-to-video/SKILL.md +++ b/plugins/dw/skills/script-to-video/SKILL.md @@ -51,6 +51,11 @@ let the family template draw its own portrait. This is the step that goes missing silently - nothing validates "the same character looks the same" across separately generated shots, so it has to be deliberate. +Large `run_workflow` arguments: `save_prompt` each shot prompt under a +script-prefixed name (the library is shared across workspaces) and pass +`prompt: