diff --git a/docs/superpowers/plans/2026-10-27-model-resolution-picker.md b/docs/superpowers/plans/2026-10-27-model-resolution-picker.md new file mode 100644 index 0000000..0cc3df5 --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-model-resolution-picker.md @@ -0,0 +1,670 @@ +# Model Resolution Picker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A new ComfyAssets node whose model dropdown drives a ratio-chip grid and size list, outputting width, height and an empty latent, with the toolkit Swap WΓH. + +**Architecture:** The family table lives in Python (`families.py`) and is served by a GET route; the Python node rounds to the family step and builds the latent with the existing `create_empty_latent_batch`. The frontend is a pure derivation module (`web/ui/resolution-picker.js`), a DOM view (`web/views/resolution-picker.js`) and an extension (`web/model_resolution_picker.js`) that projects the native `model`/`width`/`height` widgets into the panel and writes back through widget callbacks. No panel state is serialized. + +**Tech Stack:** Python 3.10β3.13, torch (CPU), aiohttp routes on `PromptServer`; ES modules on the `web/ui` toolkit; pytest, `node --test`, Playwright fixture host (`tests/browser`), pinned real ComfyUI (`tests/comfyui`). + +**Spec:** `docs/superpowers/specs/2026-10-27-model-resolution-picker-design.md` + +## Global Constraints + +- Node id `ModelResolutionPicker`, display name `Model Resolution Picker`, category `π«Ά ComfyAssets/πΌοΈ Resolution`. +- Combo value is the family **label**; labels are stable ids; each family carries `aliases`; unknown label β step 8, logged warning, no re-rounding to another family's step. +- Chips, in grid order: `1:1`, `4:5`, `5:4`, `3:4`, `4:3`, `2:3`, `3:2`, `9:16`, `16:9`, `21:9`, `9:21`, then `Custom ratio`. Mirror pairs: 4:5β5:4, 3:4β4:3, 2:3β3:2, 9:16β16:9, 21:9β9:21, 1:1β1:1. +- Chip tolerance for derived (non-listed) sizes: **7%** relative to the chip ratio. Authored list membership always wins. +- 1 MP = 1,048,576 px. Custom-ratio sizes use core Resolution Selector math: `scale = sqrt(mpΒ·1048576/(rwΒ·rh))`, each side `round(side/step)Β·step`, never below 64. +- Default family `SDXL`, default size 1024Γ1024, `batch_size` default 1, min 1, max 64. `width`/`height` widgets: default 1024, min 64, max 8192, step 8. +- Route: `GET /kikotools/model_resolution/families`, registered at module import like `local_image_loader/node.py`, guarded by `try/except ImportError`. +- Only `createScope()`, `mountTheme()`, `createButton()` and `controls.css` tokens for UI; new CSS classes are prefixed `.ca-resolution-`. +- Panel DOM widget is added with `serialize: false`; it mounts after the native widgets; new nodes start at 340 Γ 600. +- Never vendor Pixaroma code or assets. No external HTTP. +- Python coverage β₯ 85% overall; every new `web/*.js` file gets a row in `tools/testing/frontend-coverage-baseline.json`. +- Width Height Selector and Empty Latent Batch tests must keep passing unchanged. +- Commits: conventional type with scope, imperative subject, bullet body. No AI attribution lines. + +## Review Focus + +1. Typed width/height below 64 or not on the family step (e.g. 70Γ1000 on HunyuanImage 2.1): Python emits β₯ 64 on the step, the header shows the same effective size. Pinned in Task 1 (`round_to_step`) and Task 3 (`test_execution_rounds_to_family_step`). +2. Families route unavailable (network error or 500): panel shows one status line, native widgets and Swap still work, no page error. Pinned in Task 7 (`route failure keeps native widgets usable`). +3. Workflow saved with a former label or an unknown label: former label resolves through `aliases`; unknown label keeps the saved size (step 8). Pinned in Task 2 (`test_resolve_family_by_alias`) and Task 3 (`test_unknown_label_keeps_size_and_warns`). +4. `width` and `height` converted to inputs: chips and list disabled, header says "dimensions are wired in", Swap disabled. Pinned in Task 7 (`wired-in dimensions disable the picker`). +5. Custom ratio text such as `0:5`, `16:`, `a:b`, `99999999:1`: rejected with the inline error, previous list kept. Pinned in Task 5 (`parseRatio rejects malformed input`) and Task 7 (`custom ratio field`). + +--- + +## File structure + +| Path | Responsibility | +|---|---| +| `kikotools/tools/model_resolution_picker/logic.py` | step rounding, size generation, ratio label | +| `kikotools/tools/model_resolution_picker/families.py` | `Family` record, `CHIPS`, `MIRROR`, `FAMILIES`, lookup, JSON payload | +| `kikotools/tools/model_resolution_picker/node.py` | node class, route handler, module-level registration | +| `kikotools/tools/model_resolution_picker/__init__.py` | exports `ModelResolutionPickerNode` | +| `kikotools/__init__.py` | registration (modify) | +| `web/ui/dimensions.js` | swap without a `preset` widget (modify) | +| `web/ui/resolution-picker.js` | pure derivation and ratio math | +| `web/views/resolution-picker.js` | DOM view built from toolkit primitives | +| `web/model_resolution_picker.js` | extension: fetch families, mount view + swap, bind widgets | +| `web/ui/controls.css` | `.ca-resolution-*` rules (modify) | +| `tests/unit/tools/model_resolution_picker/` | `test_logic.py`, `test_families.py`, `test_node.py` | +| `tests/js/resolution-picker.test.mjs`, `tests/js/dimensions.test.mjs` | JS unit tests | +| `tests/browser/workflows.spec.mjs` | fixture-host workflows (modify) | +| `tests/comfyui/host-setup.mjs`, `tests/comfyui/resolution-picker.spec.mjs` | real-host coverage | +| `tools/testing/frontend-coverage-baseline.json` | floors for the 3 new JS files (modify) | +| `README.md`, `docs/ui-migration.md`, `examples/documentation/model_resolution_picker.md`, `docs/screenshots/resolution-picker/` | docs | + +All pytest runs use the scratch venv: `V=/tmp/claude-1000/-home-vito-code-personal-ComfyUI-KikoTools/f79925f2-d12d-4383-ad2e-85d80c922cf7/scratchpad/venv; $V/bin/python -m pytest β¦ -p no:cacheprovider`. Frontend: `npm test --prefix tools/testing`, `npm run --prefix tools/testing test:browser -- --project chromium`. + +--- + +### Task 1: Rounding and size generation (`logic.py`) + +**Files:** +- Create: `kikotools/tools/model_resolution_picker/__init__.py` (empty docstring for now), `kikotools/tools/model_resolution_picker/logic.py` +- Test: `tests/unit/tools/model_resolution_picker/__init__.py` (empty), `tests/unit/tools/model_resolution_picker/test_logic.py` + +**Interfaces:** +- Produces: `round_to_step(value: int, step: int, minimum: int = 64) -> int`; `generate_size(ratio_w: int, ratio_h: int, megapixels: float, step: int) -> tuple[int, int]`; `ratio_label(width: int, height: int) -> str` (reduced `W:H` with aliases `8:5β16:10`, `5:8β10:16`, `7:3β21:9`, `3:7β9:21`); `MEGAPIXEL = 1_048_576`. + +- [ ] **Step 1: Write the failing tests** + +```python +from kikotools.tools.model_resolution_picker.logic import ( + MEGAPIXEL, generate_size, ratio_label, round_to_step) + +def test_round_to_step_rounds_nearest_and_never_below_minimum(): + assert round_to_step(1000, 32) == 992 + assert round_to_step(1232, 16) == 1232 + assert round_to_step(70, 32) == 64 + assert round_to_step(10, 64) == 64 + assert round_to_step(1016, 64) == 1024 + +def test_generate_size_matches_core_resolution_selector(): + assert generate_size(1, 1, 1.0, 8) == (1024, 1024) + assert generate_size(16, 9, 2.0, 16) == (1936, 1088) + assert generate_size(3, 2, 4.0, 32) == (2496, 1664) + assert generate_size(21, 9, 0.25, 64) == (768, 320) + +def test_ratio_label_reduces_and_aliases(): + assert ratio_label(1024, 1024) == "1:1" + assert ratio_label(1216, 832) == "19:13" + assert ratio_label(1280, 800) == "16:10" + assert ratio_label(1344, 576) == "21:9" + assert MEGAPIXEL == 1024 * 1024 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker/test_logic.py -p no:cacheprovider -q` +Expected: FAIL with `ModuleNotFoundError` + +- [ ] **Step 3: Implement the three functions in `logic.py`** + +`round_to_step`: nearest multiple, then `max(result, ceil(minimum/step)*step)`. `generate_size`: the Global Constraints formula, each side through `round_to_step`. `ratio_label`: `math.gcd`, then the alias dict. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: same command. Expected: `3 passed` + +- [ ] **Step 5: Commit** + +```bash +git add kikotools/tools/model_resolution_picker/__init__.py kikotools/tools/model_resolution_picker/logic.py tests/unit/tools/model_resolution_picker/ +git commit -m "feat(model_resolution_picker): add step rounding and size generation" +``` + +--- + +### Task 2: The family table (`families.py`) + +**Files:** +- Create: `kikotools/tools/model_resolution_picker/families.py` +- Test: `tests/unit/tools/model_resolution_picker/test_families.py` + +**Interfaces:** +- Consumes: `generate_size` from Task 1 (used once, by hand, to author the generated lists; not imported by `families.py`). +- Produces: + - `CHIPS: tuple[str, ...]` in the Global Constraints order (11 chips, without `Custom ratio`). + - `MIRROR: dict[str, str]`. + - `class Family(NamedTuple)`: `label: str`, `aliases: tuple[str, ...]`, `models: str`, `group: str` (`"image"`|`"video"`), `vae_ratio: int`, `step: int`, `megapixels: tuple[float, ...]`, `default: tuple[int, int]`, `sizes: dict[str, tuple[tuple[int, int], ...]]`. + - `FAMILIES: tuple[Family, ...]`, `DEFAULT_FAMILY = "SDXL"`. + - `family_labels() -> list[str]`, `resolve_family(label: str) -> Family | None` (label or alias), `families_payload() -> dict` with keys `chips`, `mirror`, `default`, `families` (each: `label, aliases, models, group, vaeRatio, step, megapixels, default: [w, h], sizes: {chip: [[w, h], β¦]}`). + +Authored data (copy verbatim; order of `FAMILIES` is this order): + +| label | group | vae | step | MP tiers | default | official sizes to list verbatim; everything else generated with `generate_size` at each tier and chip | +|---|---|---|---|---|---|---| +| `SD 1.5` | image | 8 | 64 | 0.25, 0.5 | 512Γ512 | must include 512Β², 768Β², 512Γ768, 768Γ512 | +| `SDXL` (models: "SDXL, Pony and other SDXL finetunes") | image | 8 | 64 | 1.0 | 1024Γ1024 | 1:1 1024Β²; 4:5 832Γ1024; 5:4 1024Γ832; 3:4 896Γ1152; 4:3 1152Γ896; 2:3 832Γ1216; 3:2 1216Γ832; 9:16 768Γ1344; 16:9 1344Γ768; 21:9 1536Γ640; 9:21 640Γ1536. No generated entries. | +| `Illustrious XL` (aliases: none; models: "Illustrious XL v1.0 and later, NoobAI, WAI and other Illustrious finetunes") | image | 8 | 32 | 1.0, 1.5, 2.25 | 1024Γ1024 | all SDXL entries, plus 1:1 1536Β²; 4:5 1248Γ1536; 5:4 1536Γ1248; 3:4 1344Γ1792; 4:3 1792Γ1344; 2:3 1024Γ1536, 1248Γ1824; 3:2 1536Γ1024, 1824Γ1248; 9:16 1152Γ2048; 16:9 2048Γ1152. No generated entries. | +| `Anima` | image | 8 | 16 | 0.5, 1.0, 1.5 | 1024Γ1024 | generated; drop any entry with a side < 512 or > 1536 | +| `Flux.1` (models: "Flux.1 dev / schnell / Krea, Chroma, HiDream I1, SD3.5, Lumina 2") | image | 8 | 16 | 0.5, 1.0, 1.5, 2.0 | 1024Γ1024 | generated; must include 16:9 1920Γ1088 and 9:16 1088Γ1920 (replace the 2.0 MP generated entry) | +| `Z-Image` (models: "Z-Image Turbo and Z-Image Base") | image | 8 | 16 | 1.0, 1.6 | 1024Γ1024 | 1:1 1024Β², 1280Β²; 4:3 1152Γ896, 1152Γ864, 1440Γ1120, 1472Γ1104; 3:4 896Γ1152, 864Γ1152, 1120Γ1440, 1104Γ1472; 3:2 1248Γ832, 1536Γ1024; 2:3 832Γ1248, 1024Γ1536; 16:9 1280Γ720, 1600Γ896; 9:16 720Γ1280, 896Γ1600; 21:9 1344Γ576, 1680Γ720; 9:21 576Γ1344, 720Γ1680. 4:5 and 5:4 empty. | +| `Qwen-Image` (models: "Qwen-Image and Qwen-Image 2512") | image | 8 | 16 | 1.76 | 1328Γ1328 | 1:1 1328Β²; 16:9 1664Γ928; 9:16 928Γ1664; 4:3 1472Γ1104; 3:4 1104Γ1472; 3:2 1584Γ1056; 2:3 1056Γ1584. Other chips empty. | +| `Flux.2 / Klein / MOSS` (models: "Flux.2 dev, Flux.2 Klein, Flux.3 Klein, MOSS-Image, Ideogram 4") | image | 16 | 16 | 1.0, 2.0, 4.0 | 1024Γ1024 | generated; must include 3:2 1248Γ832, 2:3 832Γ1248, 1:1 2048Β², 16:9 1920Γ1088, 9:16 1088Γ1920 | +| `Qwen-Image 2.1` (models: "Qwen-Image 2.1 and 2.1 Turbo") | image | 16 | 32 | 1.0, 4.0 | 1024Γ1024 | 1:1 2048Β²; 4:3 2400Γ1792; 3:4 1792Γ2400; 3:2 2528Γ1696; 2:3 1696Γ2528; 16:9 2752Γ1536; 9:16 1536Γ2752; plus generated 1.0 MP entries for every chip | +| `HunyuanImage 2.1` | image | 32 | 32 | 4.0 | 2048Γ2048 | 1:1 2048Β²; 16:9 2560Γ1536; 9:16 1536Γ2560; 4:3 2304Γ1792; 3:4 1792Γ2304; 3:2 2496Γ1664; 2:3 1664Γ2496. Other chips empty. | +| `Wan 2.2 (keyframes)` | video | 16 | 16 | 0.4, 0.9 | 1280Γ720 | 16:9 832Γ480, 1280Γ720, 1280Γ704; 9:16 480Γ832, 720Γ1280, 704Γ1280; 1:1 640Γ640, 960Γ960; other chips generated | +| `LTX-2 (keyframes)` | video | 32 | 32 | 0.4, 0.9 | 768Γ512 | 3:2 768Γ512; 2:3 512Γ768; 16:9 960Γ544; 9:16 544Γ960; other chips generated | +| `HunyuanVideo 1.5 (keyframes)` | video | 16 | 16 | 0.4, 0.9 | 1280Γ720 | 16:9 848Γ480, 1280Γ720; 9:16 480Γ848, 720Γ1280; other chips generated | +| `MiniMax H3 (keyframes)` | video | 16 | 32 | 0.4, 1.0 | 864Γ480 | generated | + +Every list is sorted by pixel count ascending; mirrored chips hold mirrored entries. + +- [ ] **Step 1: Write the failing tests** + +```python +from kikotools.tools.model_resolution_picker.families import ( + CHIPS, DEFAULT_FAMILY, FAMILIES, MIRROR, families_payload, family_labels, resolve_family) + +CHIP_RATIO = {c: int(c.split(":")[0]) / int(c.split(":")[1]) for c in CHIPS} + +def test_chip_order_and_mirrors(): + assert CHIPS == ("1:1", "4:5", "5:4", "3:4", "4:3", "2:3", "3:2", "9:16", "16:9", "21:9", "9:21") + assert all(MIRROR[MIRROR[c]] == c for c in CHIPS) + +def test_every_size_is_on_step_and_within_seven_percent_of_its_chip(): + for family in FAMILIES: + for chip, entries in family.sizes.items(): + for w, h in entries: + assert w % family.step == 0 and h % family.step == 0, (family.label, w, h) + assert abs(w / h - CHIP_RATIO[chip]) / CHIP_RATIO[chip] <= 0.07, (family.label, chip, w, h) + +def test_every_entry_has_its_mirror_and_lists_are_sorted(): + for family in FAMILIES: + for chip, entries in family.sizes.items(): + assert list(entries) == sorted(entries, key=lambda s: s[0] * s[1]) + for w, h in entries: + assert (h, w) in family.sizes.get(MIRROR[chip], ()), (family.label, chip, w, h) + +def test_defaults_labels_and_must_includes(): + labels = family_labels() + assert labels[0] == "SD 1.5" and "SDXL" in labels and DEFAULT_FAMILY == "SDXL" + assert len(set(labels)) == len(labels) + for family in FAMILIES: + assert any(family.default in entries for entries in family.sizes.values()), family.label + must = {"SDXL": ("16:9", (1344, 768)), "Flux.1": ("16:9", (1920, 1088)), + "Flux.2 / Klein / MOSS": ("3:2", (1248, 832)), "Z-Image": ("21:9", (1680, 720)), + "Qwen-Image": ("16:9", (1664, 928)), "Qwen-Image 2.1": ("16:9", (2752, 1536)), + "HunyuanImage 2.1": ("16:9", (2560, 1536)), "Illustrious XL": ("2:3", (1248, 1824)), + "Wan 2.2 (keyframes)": ("16:9", (1280, 704)), "MiniMax H3 (keyframes)": ("16:9", (864, 480))} + for label, (chip, size) in must.items(): + assert size in resolve_family(label).sizes[chip], label + +def test_resolve_family_by_alias_and_unknown(): + assert resolve_family("SDXL").step == 64 + assert resolve_family("nope") is None + +def test_payload_is_json_shaped(): + payload = families_payload() + assert payload["default"] == "SDXL" and payload["chips"] == list(CHIPS) + sdxl = next(f for f in payload["families"] if f["label"] == "SDXL") + assert sdxl["sizes"]["3:2"] == [[1216, 832]] and sdxl["vaeRatio"] == 8 and sdxl["group"] == "image" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker/test_families.py -p no:cacheprovider -q` +Expected: FAIL with `ModuleNotFoundError` + +- [ ] **Step 3: Author `families.py`** + +Produce generated entries with a one-off snippet calling `generate_size` for each chip and tier, apply the "must include" replacements and the Anima range filter, sort, then paste the literals. `families_payload()` converts tuples to lists. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: same command. Expected: `6 passed` + +- [ ] **Step 5: Commit** + +```bash +git add kikotools/tools/model_resolution_picker/families.py tests/unit/tools/model_resolution_picker/test_families.py +git commit -m "feat(model_resolution_picker): add the model family resolution table" +``` + +--- + +### Task 3: Node, route and registration + +**Files:** +- Create: `kikotools/tools/model_resolution_picker/node.py` +- Modify: `kikotools/tools/model_resolution_picker/__init__.py`, `kikotools/__init__.py` (import + both mapping dicts, next to `EmptyLatentBatch`) +- Test: `tests/unit/tools/model_resolution_picker/test_node.py` + +**Interfaces:** +- Consumes: `resolve_family`, `family_labels`, `families_payload`, `DEFAULT_FAMILY` (Task 2); `round_to_step` (Task 1); `create_empty_latent_batch(width, height, batch_size)` from `kikotools/tools/empty_latent_batch/logic.py`. +- Produces: `class ModelResolutionPickerNode(ComfyAssetsBaseNode)` with `INPUT_TYPES` (`model` COMBO of `family_labels()` default `DEFAULT_FAMILY`, tooltip "Model family; the chips list its preferred sizes", `width`, `height`, `batch_size` per Global Constraints), `RETURN_TYPES = ("LATENT", "INT", "INT")`, `RETURN_NAMES = ("latent", "width", "height")`, `FUNCTION = "create"`, `create(self, model, width, height, batch_size) -> tuple[dict, int, int]`; `async def get_families(request) -> web.Response` (JSON of `families_payload()`); module-level registration of `GET /kikotools/model_resolution/families` in a `try: import server β¦ except ImportError: pass` block. + +- [ ] **Step 1: Write the failing tests** + +```python +import asyncio, json, logging +from kikotools.tools.model_resolution_picker.node import ModelResolutionPickerNode, get_families + +def test_inputs_and_outputs(): + inputs = ModelResolutionPickerNode.INPUT_TYPES()["required"] + assert inputs["model"][1]["default"] == "SDXL" and "HunyuanImage 2.1" in inputs["model"][0] + width = inputs["width"][1] + assert (width["default"], width["min"], width["max"], width["step"]) == (1024, 64, 8192, 8) + assert ModelResolutionPickerNode.RETURN_TYPES == ("LATENT", "INT", "INT") + assert ModelResolutionPickerNode.CATEGORY == "π«Ά ComfyAssets/πΌοΈ Resolution" + +def test_execution_rounds_to_family_step_and_marks_latent(): + latent, w, h = ModelResolutionPickerNode().create("HunyuanImage 2.1", 70, 1000, 2) + assert (w, h) == (64, 992) + assert latent["samples"].shape == (2, 4, 124, 8) and latent["downscale_ratio_spacial"] == 8 + +def test_exact_sizes_pass_through(): + _, w, h = ModelResolutionPickerNode().create("Flux.2 / Klein / MOSS", 1248, 832, 1) + assert (w, h) == (1248, 832) + +def test_unknown_label_keeps_size_and_warns(caplog): + with caplog.at_level(logging.WARNING): + _, w, h = ModelResolutionPickerNode().create("Renamed Family", 1000, 1000, 1) + assert (w, h) == (1000, 1000) and "Renamed Family" in caplog.text + +def test_families_route_returns_payload(): + response = asyncio.run(get_families(None)) + assert response.status == 200 and json.loads(response.text)["default"] == "SDXL" + +def test_registered_in_kikotools(): + from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + assert NODE_CLASS_MAPPINGS["ModelResolutionPicker"] is ModelResolutionPickerNode + assert NODE_DISPLAY_NAME_MAPPINGS["ModelResolutionPicker"] == "Model Resolution Picker" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker/test_node.py -p no:cacheprovider -q` +Expected: FAIL with `ImportError` + +- [ ] **Step 3: Implement `node.py`, export it, register it** + +`create`: resolve β `step = family.step if family else 8`, warning via `logger.warning` when `None`; round both sides; `log_info` when a value changed; build the latent; return. `get_families` returns `web.json_response(families_payload())`. + +- [ ] **Step 4: Run the new tests, then the whole Python suite with coverage** + +Run: `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker -p no:cacheprovider -q` β `6 passed` +Run: `$V/bin/python -m pytest tests -p no:cacheprovider -q --cov=kikotools | tail -3` β all passed, coverage β₯ 85% +Run: `$V/bin/black --check kikotools tests && $V/bin/flake8 kikotools/tools/model_resolution_picker` β clean + +- [ ] **Step 5: Commit** + +```bash +git add kikotools/tools/model_resolution_picker kikotools/__init__.py tests/unit/tools/model_resolution_picker/test_node.py +git commit -m "feat(model_resolution_picker): add node, families route and registration" +``` + +--- + +### Task 4: Swap without a preset widget (`web/ui/dimensions.js`) + +**Files:** +- Modify: `web/ui/dimensions.js:904-932` (`swapDimensions`) +- Test: `tests/js/dimensions.test.mjs` + +**Interfaces:** +- Produces: `swapDimensions(node, app)` now swaps when the node has `width` and `height` widgets but no `preset` widget, taking the existing custom path (direct swap, callbacks once each, dirty canvas). Behavior with a `preset` widget is unchanged. + +- [ ] **Step 1: Write the failing test** + +```js +test('nodes without a preset widget swap width and height directly', () => { + const calls = []; + const node = { widgets: [ + { name: 'width', value: 1344, callback(value) { calls.push(['width', value]); } }, + { name: 'height', value: 768, callback(value) { calls.push(['height', value]); } }, + ], graph: { setDirtyCanvas() { calls.push(['dirty']); } } }; + swapDimensions(node, app); + assert.deepEqual(node.widgets.map(widget => widget.value), [768, 1344]); + assert.deepEqual(calls, [['width', 768], ['height', 1344], ['dirty']]); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm test --prefix tools/testing -- ../../tests/js/dimensions.test.mjs` (or `node --test tests/js/dimensions.test.mjs`) +Expected: FAIL, values unchanged + +- [ ] **Step 3: Change the guard** + +`if (!width || !height || swapping.has(node)) return; const custom = !preset || preset.value === 'custom';` and leave every `preset.*` access behind `!custom`. + +- [ ] **Step 4: Run the JS unit tests** + +Run: `npm test --prefix tools/testing` Expected: all pass, including the existing dimensions tests + +- [ ] **Step 5: Commit** + +```bash +git add web/ui/dimensions.js tests/js/dimensions.test.mjs +git commit -m "feat(ui): let swapDimensions work on nodes without a preset widget" +``` + +--- + +### Task 5: Derivation module (`web/ui/resolution-picker.js`) + +**Files:** +- Create: `web/ui/resolution-picker.js` +- Test: `tests/js/resolution-picker.test.mjs` + +**Interfaces:** +- Consumes: the families payload shape from Task 2. +- Produces (all pure, no DOM): + - `parseRatio(text) -> [w, h] | null` β accepts `W:H`, `W/H`, `WxH`, `WΓH`, surrounding whitespace; integers 1β10000; else `null`. + - `reducedRatio(width, height) -> string` β same rule and aliases as Python `ratio_label`. + - `findFamily(payload, label) -> family | null` β label or alias. + - `deriveSelection(payload, label, width, height) -> { family, chip, entryIndex, custom, ratioText }` β `chip` is the chip whose list holds `[width, height]`; else nearest chip within 7%; else `null` with `custom: true` and `ratioText` from `reducedRatio`. `entryIndex` is the index in `family.sizes[chip]` or `-1`. + - `sizesForRatio(family, [rw, rh]) -> [[w, h], β¦]` β one per tier, Global Constraints formula, deduped, ascending. + - `defaultEntry(family, chip) -> [w, h] | null` β entry of `family.sizes[chip]` with pixel count closest to `family.default`'s. + - `effectiveSize(family, width, height) -> [w, h]` β rounded to `family.step` (8 when `family` is null), β₯ 64. + - `megapixels(width, height) -> string` β one decimal, e.g. `"1.0"`. + - `sizeOnModelChange(payload, newLabel, width, height, chip) -> [w, h]` β keep if listed in the new family; else `defaultEntry(newFamily, chip)` when non-null; else `newFamily.default`. + +- [ ] **Step 1: Write the failing tests** (use a fixture payload with `SDXL` (step 64, default 1024Β², sizes 1:1 1024Β², 3:2 1216Γ832, 2:3 832Γ1216, 16:9 1344Γ768, 9:16 768Γ1344) and `HunyuanImage 2.1` (step 32, default 2048Β², 1:1 2048Β², 16:9 2560Γ1536, 9:16 1536Γ2560), plus `aliases: ['SDXL 1.0']` on SDXL) + +```js +test('parseRatio accepts the documented forms and rejects malformed input', () => { + for (const text of ['16:9', ' 16 / 9 ', '16x9', '16Γ9']) assert.deepEqual(parseRatio(text), [16, 9]); + for (const text of ['0:5', '16:', 'a:b', '99999999:1', '', null]) assert.equal(parseRatio(text), null); +}); +test('deriveSelection prefers authored membership, then the 7% band, then custom', () => { + assert.deepEqual(deriveSelection(payload, 'HunyuanImage 2.1', 2560, 1536), { family: payload.families[1], chip: '16:9', entryIndex: 0, custom: false, ratioText: '5:3' }); + assert.equal(deriveSelection(payload, 'SDXL', 1200, 800).chip, '3:2'); // 1.5 exactly, not listed + assert.equal(deriveSelection(payload, 'SDXL', 1200, 800).entryIndex, -1); + const custom = deriveSelection(payload, 'SDXL', 1600, 800); // 2:1 is >7% from every chip + assert.deepEqual([custom.chip, custom.custom, custom.ratioText], [null, true, '2:1']); + assert.equal(deriveSelection(payload, 'SDXL 1.0', 1024, 1024).chip, '1:1'); // alias + assert.equal(deriveSelection(payload, 'nope', 1024, 1024).family, null); +}); +test('sizesForRatio follows the core formula per tier and effectiveSize rounds to the step', () => { + assert.deepEqual(sizesForRatio({ step: 16, megapixels: [1, 2] }, [16, 9]), [[1360, 768], [1936, 1088]]); + assert.deepEqual(effectiveSize(payload.families[1], 70, 1000), [64, 992]); + assert.deepEqual(effectiveSize(null, 1001, 1001), [1000, 1000]); + assert.equal(megapixels(1216, 832), '1.0'); +}); +test('model change keeps listed sizes, else the same chip default, else the family default', () => { + assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 2560, 1536, '16:9'), [2560, 1536]); + assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 1344, 768, '16:9'), [2560, 1536]); + assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 1216, 832, '3:2'), [2048, 2048]); + assert.deepEqual(defaultEntry(payload.families[0], '21:9'), null); +}); +``` + +- [ ] **Step 2: Run to verify failure** β `node --test tests/js/resolution-picker.test.mjs` β FAIL (module missing) + +- [ ] **Step 3: Implement the module** β plain functions; `sizesForRatio` uses `Math.round(side / step) * step` with the 64 floor; nearest-chip selection picks the smallest relative error. + +- [ ] **Step 4: Run** β `npm test --prefix tools/testing` β all pass + +- [ ] **Step 5: Commit** + +```bash +git add web/ui/resolution-picker.js tests/js/resolution-picker.test.mjs +git commit -m "feat(ui): add resolution picker derivation module" +``` + +--- + +### Task 6: View, extension and first browser workflows + +**Files:** +- Create: `web/views/resolution-picker.js`, `web/model_resolution_picker.js` +- Modify: `web/ui/controls.css` (append `.ca-resolution-*` rules), `tests/browser/workflows.spec.mjs` + +**Interfaces:** +- Consumes: Task 5 functions; `createScope`, `mountTheme`, `createButton` from `web/ui/core.js`; `mountDimensionSwap` from `web/ui/dimension-swap.js`; `swapDimensions` (Task 4); `api.fetchApi` from `../../scripts/api.js`. +- Produces: + - `createResolutionPickerView(scope, actions) -> { element, render(state), setStatus(text, isError) }` in `web/views/resolution-picker.js`. `actions = { selectChip(chip), openCustom(), commitRatio(text), selectSize(w, h) }`. `state = { disabled: boolean, header: string, chips: [{ id, label, active, disabled }], customOpen: boolean, ratioText: string, entries: [{ w, h, active }], error: string | null }`. Markup: `element.className = 'ca-tools ca-resolution-picker'`; header `
`; chips in `