Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20be3204b3 | |||
| fe8f91b477 | |||
| 55900e7c43 | |||
| 062f7880a6 | |||
| cf8496ec08 | |||
| ca26da303c | |||
| 29be286eb1 | |||
| f97f8a0616 | |||
| 4b51d3c95d | |||
| 281c04dd2e | |||
| 31406cb092 | |||
| b31faa4274 | |||
| 80aff2ba43 | |||
| c1c929722c | |||
| d3becdc598 | |||
| 4f31d792df | |||
| 67c40c1ebe | |||
| 74b57f71ac |
+35
-18
@@ -1,16 +1,18 @@
|
|||||||
"""REST API endpoints for ComfyUI to query project data from SQLite.
|
"""REST API endpoints for ComfyUI to query project data from JSON files.
|
||||||
|
|
||||||
All endpoints are read-only. Mounted on the NiceGUI/FastAPI server.
|
All endpoints are read-only. Mounted on the NiceGUI/FastAPI server.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, Query
|
from fastapi import HTTPException, Query
|
||||||
from nicegui import app
|
from nicegui import app
|
||||||
|
|
||||||
from db import ProjectDB
|
from db import ProjectDB
|
||||||
|
from utils import load_json, KEY_BATCH_DATA, KEY_SEQUENCE_NUMBER
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -54,34 +56,49 @@ def _list_sequences(name: str, file_name: str) -> dict[str, Any]:
|
|||||||
return {"sequences": seqs}
|
return {"sequences": seqs}
|
||||||
|
|
||||||
|
|
||||||
def _get_data(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]:
|
def _load_sequences(name: str, file_name: str) -> list[dict]:
|
||||||
t0 = time.perf_counter()
|
"""Load the batch_data list directly from the JSON file."""
|
||||||
db = _get_db()
|
db = _get_db()
|
||||||
proj = db.get_project(name)
|
proj = db.get_project(name)
|
||||||
if not proj:
|
if not proj:
|
||||||
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
||||||
df = db.get_data_file_by_names(name, file_name)
|
json_path = Path(proj["folder_path"]) / f"{file_name}.json"
|
||||||
if not df:
|
if not json_path.exists():
|
||||||
raise HTTPException(status_code=404, detail=f"File '{file_name}' not found in project '{name}'")
|
raise HTTPException(status_code=404, detail=f"File '{file_name}' not found in project '{name}'")
|
||||||
data = db.get_sequence(df["id"], seq)
|
data, _ = load_json(json_path)
|
||||||
if data is None:
|
return data.get(KEY_BATCH_DATA, [])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_data(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
sequences = _load_sequences(name, file_name)
|
||||||
|
match = next((s for s in sequences if int(s.get(KEY_SEQUENCE_NUMBER, 0)) == seq), None)
|
||||||
|
if match is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Sequence {seq} not found")
|
raise HTTPException(status_code=404, detail=f"Sequence {seq} not found")
|
||||||
logger.info("API _get_data %s/%s seq=%d (%d keys): %.3fs",
|
logger.info("API _get_data %s/%s seq=%d (%d keys): %.3fs",
|
||||||
name, file_name, seq, len(data), time.perf_counter() - t0)
|
name, file_name, seq, len(match), time.perf_counter() - t0)
|
||||||
return data
|
return match
|
||||||
|
|
||||||
|
|
||||||
def _get_keys(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]:
|
def _get_keys(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]:
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
db = _get_db()
|
sequences = _load_sequences(name, file_name)
|
||||||
proj = db.get_project(name)
|
match = next((s for s in sequences if int(s.get(KEY_SEQUENCE_NUMBER, 0)) == seq), None)
|
||||||
if not proj:
|
if match is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
raise HTTPException(status_code=404, detail=f"Sequence {seq} not found")
|
||||||
df = db.get_data_file_by_names(name, file_name)
|
keys = [k for k in match.keys() if k != KEY_SEQUENCE_NUMBER]
|
||||||
if not df:
|
types = []
|
||||||
raise HTTPException(status_code=404, detail=f"File '{file_name}' not found in project '{name}'")
|
for k in keys:
|
||||||
keys, types = db.get_sequence_keys(df["id"], seq)
|
v = match[k]
|
||||||
total = db.count_sequences(df["id"])
|
if isinstance(v, bool):
|
||||||
|
types.append("BOOLEAN")
|
||||||
|
elif isinstance(v, int):
|
||||||
|
types.append("INT")
|
||||||
|
elif isinstance(v, float):
|
||||||
|
types.append("FLOAT")
|
||||||
|
else:
|
||||||
|
types.append("STRING")
|
||||||
|
total = len(sequences)
|
||||||
logger.info("API _get_keys %s/%s seq=%d (%d keys): %.3fs",
|
logger.info("API _get_keys %s/%s seq=%d (%d keys): %.3fs",
|
||||||
name, file_name, seq, len(keys), time.perf_counter() - t0)
|
name, file_name, seq, len(keys), time.perf_counter() - t0)
|
||||||
return {"keys": keys, "types": types, "total_sequences": total}
|
return {"keys": keys, "types": types, "total_sequences": total}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Resolution Series Design
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When running ComfyUI loop nodes for multi-step upscaling (e.g. 3+ resolutions at different sizes),
|
||||||
|
managing portrait vs landscape width/height per iteration is tedious. Users need a structured way
|
||||||
|
to define N resolution pairs in the manager UI and retrieve them by loop index in ComfyUI.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
Resolution series are stored as a JSON array under a user-chosen key in the sequence data:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"upscale_resolutions": [[512, 512], [768, 1344], [1344, 768], [2048, 2048]]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Each element is `[width, height]` (both INT)
|
||||||
|
- Key name is chosen by the user (any string)
|
||||||
|
- Number of entries is configurable (add/remove rows)
|
||||||
|
- Stored in the same project JSON file and sequence — no schema change required
|
||||||
|
- Index out of bounds → clamp to last entry
|
||||||
|
|
||||||
|
### NiceGUI UI (tab_batch_ng.py)
|
||||||
|
|
||||||
|
A resolution series editor is rendered in the left column of the sequence card, directly below
|
||||||
|
the "Specific Negative" textarea.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
── Resolution Series ──────────────────
|
||||||
|
key name: [upscale_resolutions ]
|
||||||
|
# Width Height
|
||||||
|
1 [2048] [2048] [x]
|
||||||
|
2 [768 ] [1344] [x]
|
||||||
|
3 [1344] [768 ] [x]
|
||||||
|
[+ Add row]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Key name is editable (defaults to `resolutions`)
|
||||||
|
- Rows added/removed inline; each change calls `commit()` immediately
|
||||||
|
- Hidden behind an "Add Resolution Series" button when no resolution key exists yet
|
||||||
|
- A value is detected as a resolution series if it is a list of `[int, int]` pairs
|
||||||
|
|
||||||
|
### ComfyUI Node (`ProjectResolution`)
|
||||||
|
|
||||||
|
New node class in `project_loader.py`, sibling to `ProjectKey`.
|
||||||
|
|
||||||
|
**Inputs:**
|
||||||
|
- `source_label` (STRING) — references a `ProjectSource` by label
|
||||||
|
- `key_name` (STRING) — the resolution series key name
|
||||||
|
- `index` (INT, min 0) — wired from loop node's current index output
|
||||||
|
- `manager_url`, `project_name`, `file_name`, `sequence_number` — optional, synced from `ProjectSource` via JS
|
||||||
|
|
||||||
|
**Outputs:** `width` (INT), `height` (INT)
|
||||||
|
|
||||||
|
**Execution:** fetches the sequence data, reads `data[key_name]`, indexes into the array with
|
||||||
|
clamp-to-last on out-of-bounds, returns `(width, height)`.
|
||||||
|
|
||||||
|
**JS (`web/project_resolution.js`):**
|
||||||
|
- Same `_syncFromSource` mechanism as `project_key.js`
|
||||||
|
- `key_name` widget is replaced with a combo dropdown populated with keys whose value is a
|
||||||
|
resolution series (list of `[int, int]` pairs), detected via the existing keys API
|
||||||
|
- Registered in `PROJECT_NODE_CLASS_MAPPINGS` and `PROJECT_NODE_DISPLAY_NAME_MAPPINGS`
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
No new endpoints. Uses existing:
|
||||||
|
- `/json_manager/get_project_keys` — for key discovery (JS combo population)
|
||||||
|
- `_fetch_data()` — for execution-time data fetch
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `project_loader.py` | Add `ProjectResolution` class + register in mappings |
|
||||||
|
| `web/project_resolution.js` | New JS extension for the node |
|
||||||
|
| `tab_batch_ng.py` | Resolution series editor below Specific Negative |
|
||||||
|
| `__init__.py` | Register new JS file if needed |
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
# Resolution Series Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add a `ProjectResolution` ComfyUI node and NiceGUI editor that let users define N `(width, height)` pairs per sequence and retrieve them by loop index.
|
||||||
|
|
||||||
|
**Architecture:** Resolution series are stored as a JSON array of `[width, height]` pairs under a user-chosen key in sequence data (e.g. `"upscale_resolutions": [[512,512],[768,1344]]`). A new `ProjectResolution` ComfyUI node (sibling of `ProjectKey`) accepts a `source_label`, `key_name`, and `index` INT from a loop node, and returns `width` + `height`. The NiceGUI sequence card gets an inline table editor placed directly below the "Specific Negative" textarea.
|
||||||
|
|
||||||
|
**Tech Stack:** Python (ComfyUI node), NiceGUI (UI), JavaScript (ComfyUI frontend extension), pytest
|
||||||
|
|
||||||
|
**Branch:** Create and work on `feat/resolution-series` branched from `main`:
|
||||||
|
```bash
|
||||||
|
git checkout main && git checkout -b feat/resolution-series
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 0: Fix pre-existing test failures on `main`
|
||||||
|
|
||||||
|
When `file_name` was added as a second output to `ProjectSource`, two tests were not updated.
|
||||||
|
They fail on `main` before any new code is written.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_project_loader.py` (`TestProjectSource` class, lines ~216-231)
|
||||||
|
|
||||||
|
**Step 1: Update the two broken tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_outputs_sequence_number(self):
|
||||||
|
from project_loader import ProjectSource
|
||||||
|
assert ProjectSource.RETURN_TYPES == ("INT", "STRING",)
|
||||||
|
assert ProjectSource.RETURN_NAMES == ("sequence_number", "file_name",)
|
||||||
|
|
||||||
|
def test_hold_config_returns_sequence_number(self):
|
||||||
|
from project_loader import ProjectSource
|
||||||
|
node = ProjectSource()
|
||||||
|
result = node.hold_config(
|
||||||
|
manager_url="http://localhost:8080",
|
||||||
|
project_name="proj1",
|
||||||
|
file_name="batch_i2v",
|
||||||
|
sequence_number=42,
|
||||||
|
label="my_source"
|
||||||
|
)
|
||||||
|
assert result == (42, "batch_i2v")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify they now pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_project_loader.py::TestProjectSource -v
|
||||||
|
```
|
||||||
|
Expected: all 4 PASS
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/test_project_loader.py
|
||||||
|
git commit -m "fix: update ProjectSource tests for file_name output"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Python node — `ProjectResolution`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `project_loader.py` (after the `ProjectKey` class, before `# --- Mappings ---`)
|
||||||
|
- Modify: `tests/test_project_loader.py` (add `TestProjectResolution` class)
|
||||||
|
|
||||||
|
**Step 1: Write failing tests**
|
||||||
|
|
||||||
|
Add this class to `tests/test_project_loader.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TestProjectResolution:
|
||||||
|
def test_input_types(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
inputs = ProjectResolution.INPUT_TYPES()
|
||||||
|
assert "source_label" in inputs["required"]
|
||||||
|
assert "key_name" in inputs["required"]
|
||||||
|
assert "index" in inputs["required"]
|
||||||
|
assert inputs["required"]["index"][0] == "INT"
|
||||||
|
|
||||||
|
def test_two_outputs(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
assert ProjectResolution.RETURN_TYPES == ("INT", "INT")
|
||||||
|
assert ProjectResolution.RETURN_NAMES == ("width", "height")
|
||||||
|
|
||||||
|
def test_fetch_resolution_basic(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
node = ProjectResolution()
|
||||||
|
data = {"resolutions": [[512, 512], [768, 1344], [1344, 768]]}
|
||||||
|
with patch("project_loader._fetch_data", return_value=data):
|
||||||
|
result = node.fetch_resolution(
|
||||||
|
source_label="src", key_name="resolutions", index=1,
|
||||||
|
manager_url="http://localhost:8080", project_name="p",
|
||||||
|
file_name="f", sequence_number=1,
|
||||||
|
)
|
||||||
|
assert result == (768, 1344)
|
||||||
|
|
||||||
|
def test_fetch_resolution_index_zero(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
node = ProjectResolution()
|
||||||
|
data = {"resolutions": [[512, 512], [1024, 1024]]}
|
||||||
|
with patch("project_loader._fetch_data", return_value=data):
|
||||||
|
result = node.fetch_resolution(
|
||||||
|
source_label="src", key_name="resolutions", index=0,
|
||||||
|
manager_url="http://localhost:8080", project_name="p",
|
||||||
|
file_name="f", sequence_number=1,
|
||||||
|
)
|
||||||
|
assert result == (512, 512)
|
||||||
|
|
||||||
|
def test_fetch_resolution_clamps_on_out_of_bounds(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
node = ProjectResolution()
|
||||||
|
data = {"resolutions": [[512, 512], [1024, 1024]]}
|
||||||
|
with patch("project_loader._fetch_data", return_value=data):
|
||||||
|
result = node.fetch_resolution(
|
||||||
|
source_label="src", key_name="resolutions", index=99,
|
||||||
|
manager_url="http://localhost:8080", project_name="p",
|
||||||
|
file_name="f", sequence_number=1,
|
||||||
|
)
|
||||||
|
assert result == (1024, 1024) # last entry
|
||||||
|
|
||||||
|
def test_fetch_resolution_missing_key_returns_defaults(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
node = ProjectResolution()
|
||||||
|
with patch("project_loader._fetch_data", return_value={}):
|
||||||
|
result = node.fetch_resolution(
|
||||||
|
source_label="src", key_name="nonexistent", index=0,
|
||||||
|
manager_url="http://localhost:8080", project_name="p",
|
||||||
|
file_name="f", sequence_number=1,
|
||||||
|
)
|
||||||
|
assert result == (512, 512)
|
||||||
|
|
||||||
|
def test_fetch_resolution_network_error_returns_defaults(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
node = ProjectResolution()
|
||||||
|
error_resp = {"error": "network_error", "message": "Connection refused"}
|
||||||
|
with patch("project_loader._fetch_data", return_value=error_resp):
|
||||||
|
result = node.fetch_resolution(
|
||||||
|
source_label="src", key_name="resolutions", index=0,
|
||||||
|
manager_url="http://localhost:8080", project_name="p",
|
||||||
|
file_name="f", sequence_number=1,
|
||||||
|
)
|
||||||
|
assert result == (512, 512)
|
||||||
|
|
||||||
|
def test_category(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
assert ProjectResolution.CATEGORY == "utils/json/project"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_project_loader.py::TestProjectResolution -v
|
||||||
|
```
|
||||||
|
Expected: `ImportError: cannot import name 'ProjectResolution'`
|
||||||
|
|
||||||
|
**Step 3: Implement `ProjectResolution` in `project_loader.py`**
|
||||||
|
|
||||||
|
Insert this class after `ProjectKey` (line ~294), before `# --- Mappings ---`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ProjectResolution:
|
||||||
|
"""Fetches a (width, height) pair from a resolution series by loop index."""
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(s):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"source_label": ("STRING", {"default": "", "multiline": False}),
|
||||||
|
"key_name": ("STRING", {"default": "resolutions", "multiline": False}),
|
||||||
|
"index": ("INT", {"default": 0, "min": 0, "max": 9999}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}),
|
||||||
|
"project_name": ("STRING", {"default": "", "multiline": False}),
|
||||||
|
"file_name": ("STRING", {"default": "", "multiline": False}),
|
||||||
|
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("INT", "INT")
|
||||||
|
RETURN_NAMES = ("width", "height")
|
||||||
|
FUNCTION = "fetch_resolution"
|
||||||
|
CATEGORY = "utils/json/project"
|
||||||
|
OUTPUT_NODE = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def IS_CHANGED(cls, **kwargs):
|
||||||
|
return float("nan")
|
||||||
|
|
||||||
|
def fetch_resolution(self, source_label, key_name, index,
|
||||||
|
manager_url="http://localhost:8080", project_name="",
|
||||||
|
file_name="", sequence_number=1):
|
||||||
|
sequence_number = int(sequence_number)
|
||||||
|
data = _fetch_data(manager_url, project_name, file_name, sequence_number)
|
||||||
|
if data.get("error") in ("http_error", "network_error", "parse_error"):
|
||||||
|
logger.warning("ProjectResolution.fetch_resolution failed: %s", data.get("message"))
|
||||||
|
return (512, 512)
|
||||||
|
|
||||||
|
series = data.get(key_name)
|
||||||
|
if not isinstance(series, list) or len(series) == 0:
|
||||||
|
logger.warning("ProjectResolution: key '%s' is not a resolution series", key_name)
|
||||||
|
return (512, 512)
|
||||||
|
|
||||||
|
clamped = min(index, len(series) - 1)
|
||||||
|
entry = series[clamped]
|
||||||
|
if not isinstance(entry, (list, tuple)) or len(entry) < 2:
|
||||||
|
logger.warning("ProjectResolution: entry at index %d is malformed: %r", clamped, entry)
|
||||||
|
return (512, 512)
|
||||||
|
|
||||||
|
return (to_int(entry[0]), to_int(entry[1]))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_project_loader.py::TestProjectResolution -v
|
||||||
|
```
|
||||||
|
Expected: all 7 tests PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add project_loader.py tests/test_project_loader.py
|
||||||
|
git commit -m "feat: add ProjectResolution node"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Register `ProjectResolution` in mappings + fix mapping tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `project_loader.py` (mappings section, lines ~297-307)
|
||||||
|
- Modify: `tests/test_project_loader.py` (`TestNodeMappings` class)
|
||||||
|
|
||||||
|
**Step 1: Update mappings in `project_loader.py`**
|
||||||
|
|
||||||
|
Change the mappings at the bottom of the file:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PROJECT_NODE_CLASS_MAPPINGS = {
|
||||||
|
"ProjectLoaderDynamic": ProjectLoaderDynamic,
|
||||||
|
"ProjectSource": ProjectSource,
|
||||||
|
"ProjectKey": ProjectKey,
|
||||||
|
"ProjectResolution": ProjectResolution,
|
||||||
|
}
|
||||||
|
|
||||||
|
PROJECT_NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"ProjectLoaderDynamic": "Project Loader (Dynamic)",
|
||||||
|
"ProjectSource": "Project Source",
|
||||||
|
"ProjectKey": "Project Key",
|
||||||
|
"ProjectResolution": "Project Resolution",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update the mapping test**
|
||||||
|
|
||||||
|
In `tests/test_project_loader.py`, update `TestNodeMappings.test_mappings_exist`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TestNodeMappings:
|
||||||
|
def test_mappings_exist(self):
|
||||||
|
from project_loader import PROJECT_NODE_CLASS_MAPPINGS, PROJECT_NODE_DISPLAY_NAME_MAPPINGS
|
||||||
|
assert "ProjectLoaderDynamic" in PROJECT_NODE_CLASS_MAPPINGS
|
||||||
|
assert "ProjectSource" in PROJECT_NODE_CLASS_MAPPINGS
|
||||||
|
assert "ProjectKey" in PROJECT_NODE_CLASS_MAPPINGS
|
||||||
|
assert "ProjectResolution" in PROJECT_NODE_CLASS_MAPPINGS
|
||||||
|
assert len(PROJECT_NODE_CLASS_MAPPINGS) == 4
|
||||||
|
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run all project_loader tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_project_loader.py -v
|
||||||
|
```
|
||||||
|
Expected: all tests PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add project_loader.py tests/test_project_loader.py
|
||||||
|
git commit -m "feat: register ProjectResolution in node mappings"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: NiceGUI resolution series editor in `tab_batch_ng.py`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tab_batch_ng.py`
|
||||||
|
|
||||||
|
The resolution series editor goes inside `splitter.before`, directly after the "Specific Negative" textarea (currently line ~552-553). No new file needed.
|
||||||
|
|
||||||
|
**Step 1: Add the helper function**
|
||||||
|
|
||||||
|
Add this function near the other helper functions at the top of the render section (before `_render_sequence_card`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _is_resolution_series(val) -> bool:
|
||||||
|
"""Return True if val is a list of [width, height] int pairs."""
|
||||||
|
if not isinstance(val, list) or len(val) == 0:
|
||||||
|
return False
|
||||||
|
return all(
|
||||||
|
isinstance(entry, (list, tuple)) and len(entry) == 2
|
||||||
|
and all(isinstance(v, (int, float)) for v in entry)
|
||||||
|
for entry in val
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `Any` is intentionally omitted — `tab_batch_ng.py` does not import `typing.Any`.
|
||||||
|
|
||||||
|
**Step 2: Add the resolution series render section**
|
||||||
|
|
||||||
|
After the "Specific Negative" textarea in `splitter.before` (after line ~553), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# --- Resolution Series ---
|
||||||
|
res_keys = [k for k, v in seq.items() if _is_resolution_series(v)]
|
||||||
|
if res_keys:
|
||||||
|
ui.label('Resolution Series').classes('text-caption text-weight-bold q-mt-md')
|
||||||
|
for res_key in res_keys:
|
||||||
|
series: list = seq[res_key]
|
||||||
|
with ui.card().classes('w-full q-pa-sm q-mt-xs').props('flat bordered'):
|
||||||
|
with ui.row().classes('items-center q-mb-xs'):
|
||||||
|
ui.label(res_key).classes('text-caption col')
|
||||||
|
def del_series(k=res_key):
|
||||||
|
del seq[k]
|
||||||
|
commit()
|
||||||
|
ui.button(icon='delete', on_click=del_series).props(
|
||||||
|
'flat dense round size=xs color=negative')
|
||||||
|
with ui.row().classes('text-caption text-grey q-mb-xs'):
|
||||||
|
ui.label('#').style('width:24px')
|
||||||
|
ui.label('Width').classes('col')
|
||||||
|
ui.label('Height').classes('col')
|
||||||
|
ui.label('').style('width:28px')
|
||||||
|
for idx, entry in enumerate(series):
|
||||||
|
with ui.row().classes('items-center w-full'):
|
||||||
|
ui.label(str(idx + 1)).classes('text-caption').style('width:24px')
|
||||||
|
w_inp = ui.number(value=int(entry[0]), min=1, step=1).classes(
|
||||||
|
'col').props('outlined dense hide-bottom-space')
|
||||||
|
h_inp = ui.number(value=int(entry[1]), min=1, step=1).classes(
|
||||||
|
'col').props('outlined dense hide-bottom-space')
|
||||||
|
|
||||||
|
def _sync_wh(i=idx, k=res_key, wi=w_inp, hi=h_inp):
|
||||||
|
seq[k][i] = [
|
||||||
|
int(wi.value) if wi.value else 512,
|
||||||
|
int(hi.value) if hi.value else 512,
|
||||||
|
]
|
||||||
|
commit()
|
||||||
|
|
||||||
|
w_inp.on('blur', lambda _, s=_sync_wh: s())
|
||||||
|
h_inp.on('blur', lambda _, s=_sync_wh: s())
|
||||||
|
|
||||||
|
def del_row(i=idx, k=res_key):
|
||||||
|
seq[k].pop(i)
|
||||||
|
commit()
|
||||||
|
ui.button(icon='remove', on_click=del_row).props(
|
||||||
|
'flat dense round size=xs')
|
||||||
|
|
||||||
|
def add_row(k=res_key):
|
||||||
|
seq[k].append([512, 512])
|
||||||
|
commit()
|
||||||
|
ui.button('+ Add row', icon='add', on_click=add_row).props(
|
||||||
|
'flat dense size=sm').classes('q-mt-xs')
|
||||||
|
|
||||||
|
with ui.expansion('Add Resolution Series', icon='straighten').classes('w-full q-mt-sm'):
|
||||||
|
new_res_key = ui.input('Key name', value='resolutions').props('outlined dense')
|
||||||
|
def add_res_series():
|
||||||
|
k = new_res_key.value.strip()
|
||||||
|
if k and k not in seq:
|
||||||
|
seq[k] = [[512, 512], [1024, 1024]]
|
||||||
|
commit()
|
||||||
|
ui.button('Add', icon='add', on_click=add_res_series).props('outlined dense')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/ -q
|
||||||
|
```
|
||||||
|
Expected: all tests PASS (no Python tests cover the NiceGUI render path, but no regressions)
|
||||||
|
|
||||||
|
**Important:** Also update the `custom_keys` filter in `_render_sequence_card` (line ~648) to exclude
|
||||||
|
resolution series keys — otherwise they'd render in both the resolution editor AND "Custom Parameters":
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Find this line:
|
||||||
|
custom_keys = [k for k in seq.keys() if k not in standard_keys]
|
||||||
|
# Replace with:
|
||||||
|
custom_keys = [k for k in seq.keys() if k not in standard_keys and not _is_resolution_series(seq.get(k))]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tab_batch_ng.py
|
||||||
|
git commit -m "feat: resolution series editor in sequence card"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: JS extension `web/project_resolution.js`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/project_resolution.js`
|
||||||
|
|
||||||
|
This file mirrors `web/project_key.js` exactly, with two differences:
|
||||||
|
1. It targets `"ProjectResolution"` instead of `"ProjectKey"`
|
||||||
|
2. `_refreshKeys` filters to only show keys whose value is a resolution series (list of `[int, int]` pairs) — but since the keys API only returns key names (not values), the filter is done by naming convention or we just show all keys and let the user pick. For simplicity, show all keys (same as ProjectKey) and let the user pick.
|
||||||
|
3. The `index` widget is **not** hidden — the user wires it from a loop node
|
||||||
|
4. The node has two outputs (`width`, `height`) so no output slot name update is needed
|
||||||
|
|
||||||
|
**Step 1: Create `web/project_resolution.js`**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "json.manager.project.resolution",
|
||||||
|
|
||||||
|
async beforeQueuePrompt() {
|
||||||
|
if (!app.graph?._nodes) return;
|
||||||
|
for (const node of app.graph._nodes) {
|
||||||
|
if (node.type === "ProjectResolution" && node._syncFromSource) {
|
||||||
|
node._syncFromSource();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
|
if (nodeData.name !== "ProjectResolution") return;
|
||||||
|
|
||||||
|
function hideWidget(widget) {
|
||||||
|
if (widget.origType === undefined) widget.origType = widget.type;
|
||||||
|
widget.type = "hidden";
|
||||||
|
widget.hidden = true;
|
||||||
|
widget.computeSize = () => [0, -4];
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceWithCombo(node, name, values, callback) {
|
||||||
|
const idx = node.widgets?.findIndex(w => w.name === name);
|
||||||
|
if (idx === -1 || idx === undefined) return null;
|
||||||
|
const oldWidget = node.widgets[idx];
|
||||||
|
const savedValue = oldWidget.value || "";
|
||||||
|
const comboValues = values.length > 0 ? values : [""];
|
||||||
|
if (savedValue && !comboValues.includes(savedValue)) {
|
||||||
|
comboValues.unshift(savedValue);
|
||||||
|
}
|
||||||
|
const defaultValue = savedValue || comboValues[0];
|
||||||
|
node.widgets.splice(idx, 1);
|
||||||
|
const combo = node.addWidget("combo", name, defaultValue, callback, { values: comboValues });
|
||||||
|
if (node.widgets.length > 1) {
|
||||||
|
node.widgets.splice(node.widgets.length - 1, 1);
|
||||||
|
node.widgets.splice(idx, 0, combo);
|
||||||
|
}
|
||||||
|
return combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
|
origOnNodeCreated?.apply(this, arguments);
|
||||||
|
this._configured = false;
|
||||||
|
|
||||||
|
// Hide synced config widgets (index stays visible — user wires it)
|
||||||
|
for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) {
|
||||||
|
const w = this.widgets?.find(w => w.name === name);
|
||||||
|
if (w) hideWidget(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = this;
|
||||||
|
const sourceLabels = this._getSourceLabels?.() || [];
|
||||||
|
const srcCombo = replaceWithCombo(this, "source_label", sourceLabels, function (value) {
|
||||||
|
node._syncFromSource();
|
||||||
|
node._refreshKeys();
|
||||||
|
});
|
||||||
|
if (srcCombo) srcCombo.value = sourceLabels[0] || "";
|
||||||
|
|
||||||
|
const keyCombo = replaceWithCombo(this, "key_name", [], function (value) {
|
||||||
|
node.title = value ? `Resolution: ${value}` : "Project Resolution";
|
||||||
|
app.graph?.setDirtyCanvas(true, true);
|
||||||
|
});
|
||||||
|
if (keyCombo) keyCombo.value = "";
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!this._configured) {
|
||||||
|
this.setSize(this.computeSize());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeType.prototype._getSourceLabels = function () {
|
||||||
|
const seen = new Set();
|
||||||
|
const labels = [];
|
||||||
|
if (!this.graph) return labels;
|
||||||
|
for (const node of this.graph._nodes) {
|
||||||
|
if (node.type === "ProjectSource") {
|
||||||
|
const lw = node.widgets?.find(w => w.name === "label");
|
||||||
|
if (lw?.value && !seen.has(lw.value)) {
|
||||||
|
seen.add(lw.value);
|
||||||
|
labels.push(lw.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeType.prototype._findSource = function (label) {
|
||||||
|
if (!this.graph || !label) return null;
|
||||||
|
for (const node of this.graph._nodes) {
|
||||||
|
if (node.type === "ProjectSource") {
|
||||||
|
const lw = node.widgets?.find(w => w.name === "label");
|
||||||
|
if (lw?.value === label) return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeType.prototype._syncFromSource = function () {
|
||||||
|
const srcWidget = this.widgets?.find(w => w.name === "source_label");
|
||||||
|
const source = this._findSource(srcWidget?.value);
|
||||||
|
if (!source) return;
|
||||||
|
for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) {
|
||||||
|
const dst = this.widgets?.find(w => w.name === name);
|
||||||
|
const src = source.widgets?.find(w => w.name === name);
|
||||||
|
if (dst && src) dst.value = src.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeType.prototype._refreshKeys = async function () {
|
||||||
|
const urlW = this.widgets?.find(w => w.name === "manager_url");
|
||||||
|
const projW = this.widgets?.find(w => w.name === "project_name");
|
||||||
|
const fileW = this.widgets?.find(w => w.name === "file_name");
|
||||||
|
const seqW = this.widgets?.find(w => w.name === "sequence_number");
|
||||||
|
if (!urlW?.value || !projW?.value || !fileW?.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await api.fetchApi(
|
||||||
|
`/json_manager/get_project_keys?url=${encodeURIComponent(urlW.value)}&project=${encodeURIComponent(projW.value)}&file=${encodeURIComponent(fileW.value)}&seq=${seqW?.value || 1}`
|
||||||
|
);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error || !Array.isArray(data.keys)) return;
|
||||||
|
|
||||||
|
const keyWidget = this.widgets?.find(w => w.name === "key_name");
|
||||||
|
if (keyWidget) {
|
||||||
|
keyWidget.options.values = data.keys.length > 0 ? data.keys : [""];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[ProjectResolution] Failed to refresh keys:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const origOnMouseDown = nodeType.prototype.onMouseDown;
|
||||||
|
nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) {
|
||||||
|
origOnMouseDown?.apply(this, arguments);
|
||||||
|
const srcWidget = this.widgets?.find(w => w.name === "source_label");
|
||||||
|
if (srcWidget) srcWidget.options.values = this._getSourceLabels();
|
||||||
|
this._syncFromSource();
|
||||||
|
};
|
||||||
|
|
||||||
|
const origOnConfigure = nodeType.prototype.onConfigure;
|
||||||
|
nodeType.prototype.onConfigure = function (info) {
|
||||||
|
origOnConfigure?.apply(this, arguments);
|
||||||
|
this._configured = true;
|
||||||
|
|
||||||
|
for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) {
|
||||||
|
const w = this.widgets?.find(w => w.name === name);
|
||||||
|
if (w) hideWidget(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcWidget = this.widgets?.find(w => w.name === "source_label");
|
||||||
|
if (srcWidget && srcWidget.type !== "combo") {
|
||||||
|
const node = this;
|
||||||
|
replaceWithCombo(this, "source_label", this._getSourceLabels(), function (value) {
|
||||||
|
node._syncFromSource();
|
||||||
|
node._refreshKeys();
|
||||||
|
});
|
||||||
|
} else if (srcWidget) {
|
||||||
|
srcWidget.options.values = this._getSourceLabels();
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyWidget = this.widgets?.find(w => w.name === "key_name");
|
||||||
|
if (keyWidget && keyWidget.type !== "combo") {
|
||||||
|
const node = this;
|
||||||
|
replaceWithCombo(this, "key_name", [], function (value) {
|
||||||
|
node.title = value ? `Resolution: ${value}` : "Project Resolution";
|
||||||
|
app.graph?.setDirtyCanvas(true, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalKeyWidget = this.widgets?.find(w => w.name === "key_name");
|
||||||
|
if (finalKeyWidget?.value) {
|
||||||
|
this.title = `Resolution: ${finalKeyWidget.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setSize(this.computeSize());
|
||||||
|
|
||||||
|
const node = this;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
node._syncFromSource();
|
||||||
|
node._refreshKeys();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/ -q
|
||||||
|
```
|
||||||
|
Expected: all tests PASS (JS has no Python tests)
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/project_resolution.js
|
||||||
|
git commit -m "feat: ProjectResolution JS extension for ComfyUI frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Final verification and push
|
||||||
|
|
||||||
|
**Step 1: Run full test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/ -v
|
||||||
|
```
|
||||||
|
Expected: all tests PASS
|
||||||
|
|
||||||
|
**Step 2: Push branch**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin HEAD
|
||||||
|
```
|
||||||
+64
-6
@@ -150,7 +150,7 @@ class ProjectLoaderDynamic:
|
|||||||
RETURN_TYPES = ("INT",) + tuple(any_type for _ in range(MAX_DYNAMIC_OUTPUTS))
|
RETURN_TYPES = ("INT",) + tuple(any_type for _ in range(MAX_DYNAMIC_OUTPUTS))
|
||||||
RETURN_NAMES = ("total_sequences",) + tuple(f"output_{i}" for i in range(MAX_DYNAMIC_OUTPUTS))
|
RETURN_NAMES = ("total_sequences",) + tuple(f"output_{i}" for i in range(MAX_DYNAMIC_OUTPUTS))
|
||||||
FUNCTION = "load_dynamic"
|
FUNCTION = "load_dynamic"
|
||||||
CATEGORY = "utils/json/project"
|
CATEGORY = "JSON Manager/project"
|
||||||
OUTPUT_NODE = False
|
OUTPUT_NODE = False
|
||||||
|
|
||||||
def load_dynamic(self, manager_url, project_name, file_name, sequence_number,
|
def load_dynamic(self, manager_url, project_name, file_name, sequence_number,
|
||||||
@@ -221,14 +221,14 @@ class ProjectSource:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("INT",)
|
RETURN_TYPES = ("INT", "STRING",)
|
||||||
RETURN_NAMES = ("sequence_number",)
|
RETURN_NAMES = ("sequence_number", "file_name",)
|
||||||
FUNCTION = "hold_config"
|
FUNCTION = "hold_config"
|
||||||
CATEGORY = "utils/json/project"
|
CATEGORY = "JSON Manager/project"
|
||||||
OUTPUT_NODE = True
|
OUTPUT_NODE = True
|
||||||
|
|
||||||
def hold_config(self, manager_url, project_name, file_name, sequence_number, label):
|
def hold_config(self, manager_url, project_name, file_name, sequence_number, label):
|
||||||
return (sequence_number,)
|
return (sequence_number, file_name,)
|
||||||
|
|
||||||
|
|
||||||
class ProjectKey:
|
class ProjectKey:
|
||||||
@@ -252,7 +252,7 @@ class ProjectKey:
|
|||||||
RETURN_TYPES = (any_type,)
|
RETURN_TYPES = (any_type,)
|
||||||
RETURN_NAMES = ("value",)
|
RETURN_NAMES = ("value",)
|
||||||
FUNCTION = "fetch_key"
|
FUNCTION = "fetch_key"
|
||||||
CATEGORY = "utils/json/project"
|
CATEGORY = "JSON Manager/project"
|
||||||
OUTPUT_NODE = False
|
OUTPUT_NODE = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -293,15 +293,73 @@ class ProjectKey:
|
|||||||
return (str(val),)
|
return (str(val),)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectResolution:
|
||||||
|
"""Fetches a (width, height) pair from a resolution series by loop index."""
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(s):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"source_label": ("STRING", {"default": "", "multiline": False}),
|
||||||
|
"key_name": ("STRING", {"default": "resolutions", "multiline": False}),
|
||||||
|
"index": ("INT", {"default": 0, "min": 0, "max": 9999}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}),
|
||||||
|
"project_name": ("STRING", {"default": "", "multiline": False}),
|
||||||
|
"file_name": ("STRING", {"default": "", "multiline": False}),
|
||||||
|
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("INT", "INT", "INT")
|
||||||
|
RETURN_NAMES = ("width", "height", "seed")
|
||||||
|
FUNCTION = "fetch_resolution"
|
||||||
|
CATEGORY = "JSON Manager/project"
|
||||||
|
OUTPUT_NODE = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def IS_CHANGED(cls, **kwargs):
|
||||||
|
return float("nan")
|
||||||
|
|
||||||
|
def fetch_resolution(self, source_label, key_name, index,
|
||||||
|
manager_url="http://localhost:8080", project_name="",
|
||||||
|
file_name="", sequence_number=1):
|
||||||
|
sequence_number = int(sequence_number)
|
||||||
|
logger.info("ProjectResolution.fetch_resolution: source=%s key=%s url=%s project=%s file=%s seq=%s index=%s",
|
||||||
|
source_label, key_name, manager_url, project_name, file_name, sequence_number, index)
|
||||||
|
# source_label is used by JS to identify which ProjectSource to sync
|
||||||
|
# config from. The actual config arrives via the optional widgets below.
|
||||||
|
data = _fetch_data(manager_url, project_name, file_name, sequence_number)
|
||||||
|
if data.get("error") in ("http_error", "network_error", "parse_error"):
|
||||||
|
logger.warning("ProjectResolution.fetch_resolution failed: %s", data.get("message"))
|
||||||
|
return (512, 512, 0)
|
||||||
|
|
||||||
|
series = data.get(key_name)
|
||||||
|
if not isinstance(series, list) or len(series) == 0:
|
||||||
|
logger.warning("ProjectResolution: key '%s' is not a resolution series", key_name)
|
||||||
|
return (512, 512, 0)
|
||||||
|
|
||||||
|
clamped = max(0, min(index, len(series) - 1))
|
||||||
|
entry = series[clamped]
|
||||||
|
if not isinstance(entry, (list, tuple)) or len(entry) < 2:
|
||||||
|
logger.warning("ProjectResolution: entry at index %d is malformed: %r", clamped, entry)
|
||||||
|
return (512, 512, 0)
|
||||||
|
|
||||||
|
seed = to_int(entry[2]) if len(entry) >= 3 else 0
|
||||||
|
return (to_int(entry[0]), to_int(entry[1]), seed)
|
||||||
|
|
||||||
|
|
||||||
# --- Mappings ---
|
# --- Mappings ---
|
||||||
PROJECT_NODE_CLASS_MAPPINGS = {
|
PROJECT_NODE_CLASS_MAPPINGS = {
|
||||||
"ProjectLoaderDynamic": ProjectLoaderDynamic,
|
"ProjectLoaderDynamic": ProjectLoaderDynamic,
|
||||||
"ProjectSource": ProjectSource,
|
"ProjectSource": ProjectSource,
|
||||||
"ProjectKey": ProjectKey,
|
"ProjectKey": ProjectKey,
|
||||||
|
"ProjectResolution": ProjectResolution,
|
||||||
}
|
}
|
||||||
|
|
||||||
PROJECT_NODE_DISPLAY_NAME_MAPPINGS = {
|
PROJECT_NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
"ProjectLoaderDynamic": "Project Loader (Dynamic)",
|
"ProjectLoaderDynamic": "Project Loader (Dynamic)",
|
||||||
"ProjectSource": "Project Source",
|
"ProjectSource": "Project Source",
|
||||||
"ProjectKey": "Project Key",
|
"ProjectKey": "Project Key",
|
||||||
|
"ProjectResolution": "Project Resolution",
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-16
@@ -409,6 +409,7 @@ def render_batch_processor(state: AppState):
|
|||||||
# Single sequence card
|
# Single sequence card
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
||||||
src_cache, src_seq_select, standard_keys,
|
src_cache, src_seq_select, standard_keys,
|
||||||
refresh_list):
|
refresh_list):
|
||||||
@@ -469,11 +470,11 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
|||||||
)
|
)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
s['name'] = result
|
s['name'] = result
|
||||||
commit('Renamed!')
|
await commit('Renamed!')
|
||||||
|
|
||||||
ui.button('Rename', icon='edit', on_click=rename).props('outline')
|
ui.button('Rename', icon='edit', on_click=rename).props('outline')
|
||||||
# Copy from source
|
# Copy from source
|
||||||
def copy_source(idx=i, sn=seq_num):
|
async def copy_source(idx=i, sn=seq_num):
|
||||||
item = copy.deepcopy(DEFAULTS)
|
item = copy.deepcopy(DEFAULTS)
|
||||||
src_batch = src_cache['batch']
|
src_batch = src_cache['batch']
|
||||||
sel_idx = src_seq_select.value
|
sel_idx = src_seq_select.value
|
||||||
@@ -485,12 +486,12 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
|||||||
item.pop(KEY_PROMPT_HISTORY, None)
|
item.pop(KEY_PROMPT_HISTORY, None)
|
||||||
item.pop(KEY_HISTORY_TREE, None)
|
item.pop(KEY_HISTORY_TREE, None)
|
||||||
batch_list[idx] = item
|
batch_list[idx] = item
|
||||||
commit('Copied!')
|
await commit('Copied!')
|
||||||
|
|
||||||
ui.button('Copy Src', icon='file_download', on_click=copy_source).props('outline')
|
ui.button('Copy Src', icon='file_download', on_click=copy_source).props('outline')
|
||||||
|
|
||||||
# Clone Next
|
# Clone Next
|
||||||
def clone_next(idx=i, sn=seq_num, s=seq):
|
async def clone_next(idx=i, sn=seq_num, s=seq):
|
||||||
new_seq = copy.deepcopy(s)
|
new_seq = copy.deepcopy(s)
|
||||||
new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1
|
new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1
|
||||||
if not is_subsegment(sn):
|
if not is_subsegment(sn):
|
||||||
@@ -498,21 +499,21 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
|||||||
else:
|
else:
|
||||||
pos = idx + 1
|
pos = idx + 1
|
||||||
batch_list.insert(pos, new_seq)
|
batch_list.insert(pos, new_seq)
|
||||||
commit('Cloned to Next!')
|
await commit('Cloned to Next!')
|
||||||
|
|
||||||
ui.button('Clone Next', icon='content_copy', on_click=clone_next).props('outline')
|
ui.button('Clone Next', icon='content_copy', on_click=clone_next).props('outline')
|
||||||
|
|
||||||
# Clone End
|
# Clone End
|
||||||
def clone_end(s=seq):
|
async def clone_end(s=seq):
|
||||||
new_seq = copy.deepcopy(s)
|
new_seq = copy.deepcopy(s)
|
||||||
new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1
|
new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1
|
||||||
batch_list.append(new_seq)
|
batch_list.append(new_seq)
|
||||||
commit('Cloned to End!')
|
await commit('Cloned to End!')
|
||||||
|
|
||||||
ui.button('Clone End', icon='vertical_align_bottom', on_click=clone_end).props('outline')
|
ui.button('Clone End', icon='vertical_align_bottom', on_click=clone_end).props('outline')
|
||||||
|
|
||||||
# Clone Sub
|
# Clone Sub
|
||||||
def clone_sub(idx=i, sn=seq_num, s=seq):
|
async def clone_sub(idx=i, sn=seq_num, s=seq):
|
||||||
new_seq = copy.deepcopy(s)
|
new_seq = copy.deepcopy(s)
|
||||||
p_seq = parent_of(sn)
|
p_seq = parent_of(sn)
|
||||||
p_idx = idx
|
p_idx = idx
|
||||||
@@ -524,17 +525,17 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
|||||||
new_seq[KEY_SEQUENCE_NUMBER] = next_sub_segment_number(batch_list, p_seq)
|
new_seq[KEY_SEQUENCE_NUMBER] = next_sub_segment_number(batch_list, p_seq)
|
||||||
pos = find_insert_position(batch_list, p_idx, p_seq)
|
pos = find_insert_position(batch_list, p_idx, p_seq)
|
||||||
batch_list.insert(pos, new_seq)
|
batch_list.insert(pos, new_seq)
|
||||||
commit(f'Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!')
|
await commit(f'Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!')
|
||||||
|
|
||||||
ui.button('Clone Sub', icon='link', on_click=clone_sub).props('outline')
|
ui.button('Clone Sub', icon='link', on_click=clone_sub).props('outline')
|
||||||
|
|
||||||
ui.element('div').classes('col')
|
ui.element('div').classes('col')
|
||||||
|
|
||||||
# Delete
|
# Delete
|
||||||
def delete(idx=i):
|
async def delete(idx=i):
|
||||||
if idx < len(batch_list):
|
if idx < len(batch_list):
|
||||||
batch_list.pop(idx)
|
batch_list.pop(idx)
|
||||||
commit()
|
await commit()
|
||||||
|
|
||||||
ui.button(icon='delete', on_click=delete).props('color=negative')
|
ui.button(icon='delete', on_click=delete).props('color=negative')
|
||||||
|
|
||||||
@@ -552,6 +553,49 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
|||||||
dict_textarea('Specific Negative', seq, 'negative').classes(
|
dict_textarea('Specific Negative', seq, 'negative').classes(
|
||||||
'w-full q-mt-sm').props('outlined rows=2')
|
'w-full q-mt-sm').props('outlined rows=2')
|
||||||
|
|
||||||
|
# --- Resolutions (8 fixed slots) ---
|
||||||
|
ui.label('Resolutions').classes('text-caption text-weight-bold q-mt-md')
|
||||||
|
resolutions = seq.setdefault('resolutions', [])
|
||||||
|
while len(resolutions) < 8:
|
||||||
|
resolutions.append([512, 512, 0])
|
||||||
|
# Migrate old [w, h] entries to [w, h, seed] (persisted on next real save)
|
||||||
|
for r_i in range(len(resolutions)):
|
||||||
|
if len(resolutions[r_i]) < 3:
|
||||||
|
resolutions[r_i] = list(resolutions[r_i]) + [0]
|
||||||
|
for idx in range(8):
|
||||||
|
entry = resolutions[idx]
|
||||||
|
with ui.row().classes('items-center w-full q-mt-xs no-wrap'):
|
||||||
|
ui.label(str(idx)).classes('text-caption').style('min-width:16px')
|
||||||
|
w_inp = ui.number(value=int(entry[0]), min=1, step=1, label='W').style(
|
||||||
|
'width:70px').props('outlined dense hide-bottom-space')
|
||||||
|
h_inp = ui.number(value=int(entry[1]), min=1, step=1, label='H').style(
|
||||||
|
'width:70px').props('outlined dense hide-bottom-space')
|
||||||
|
seed_inp = ui.number(value=int(entry[2]), min=0, step=1, label='Seed').style(
|
||||||
|
'flex:1; min-width:60px').props('outlined dense hide-bottom-space')
|
||||||
|
|
||||||
|
async def _sync_entry(r=idx, wi=w_inp, hi=h_inp, si=seed_inp):
|
||||||
|
seq['resolutions'][r] = [
|
||||||
|
int(wi.value) if wi.value else 512,
|
||||||
|
int(hi.value) if hi.value else 512,
|
||||||
|
int(si.value) if si.value else 0,
|
||||||
|
]
|
||||||
|
await commit()
|
||||||
|
|
||||||
|
async def _randomize(si=seed_inp, r=idx):
|
||||||
|
si.value = random.randint(0, 2**32 - 1)
|
||||||
|
seq['resolutions'][r][2] = int(si.value)
|
||||||
|
await commit()
|
||||||
|
|
||||||
|
ui.button(icon='casino', on_click=_randomize).props(
|
||||||
|
'flat dense round').classes('q-ml-xs')
|
||||||
|
|
||||||
|
w_inp.on('blur', lambda _, s=_sync_entry: s())
|
||||||
|
w_inp.on('update:model-value', lambda _, s=_sync_entry: s())
|
||||||
|
h_inp.on('blur', lambda _, s=_sync_entry: s())
|
||||||
|
h_inp.on('update:model-value', lambda _, s=_sync_entry: s())
|
||||||
|
seed_inp.on('blur', lambda _, s=_sync_entry: s())
|
||||||
|
seed_inp.on('update:model-value', lambda _, s=_sync_entry: s())
|
||||||
|
|
||||||
with splitter.after:
|
with splitter.after:
|
||||||
# Mode
|
# Mode
|
||||||
dict_number('Mode', seq, 'mode').props('outlined').classes('w-full')
|
dict_number('Mode', seq, 'mode').props('outlined').classes('w-full')
|
||||||
@@ -645,16 +689,16 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
|||||||
# --- Custom Parameters ---
|
# --- Custom Parameters ---
|
||||||
ui.label('Custom Parameters').classes('section-header q-mt-md')
|
ui.label('Custom Parameters').classes('section-header q-mt-md')
|
||||||
|
|
||||||
custom_keys = [k for k in seq.keys() if k not in standard_keys]
|
custom_keys = [k for k in seq.keys() if k not in standard_keys and k != 'resolutions']
|
||||||
if custom_keys:
|
if custom_keys:
|
||||||
for k in custom_keys:
|
for k in custom_keys:
|
||||||
with ui.row().classes('w-full items-center'):
|
with ui.row().classes('w-full items-center'):
|
||||||
ui.input('Key', value=k).props('readonly outlined dense').classes('w-32')
|
ui.input('Key', value=k).props('readonly outlined dense').classes('w-32')
|
||||||
dict_input(ui.input, 'Value', seq, k).props('outlined dense').classes('col')
|
dict_input(ui.input, 'Value', seq, k).props('outlined dense').classes('col')
|
||||||
|
|
||||||
def del_custom(key=k):
|
async def del_custom(key=k):
|
||||||
del seq[key]
|
del seq[key]
|
||||||
commit()
|
await commit()
|
||||||
|
|
||||||
ui.button(icon='delete', on_click=del_custom).props('flat dense color=negative')
|
ui.button(icon='delete', on_click=del_custom).props('flat dense color=negative')
|
||||||
|
|
||||||
@@ -662,14 +706,14 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
|||||||
new_k_input = ui.input('Key').props('outlined dense')
|
new_k_input = ui.input('Key').props('outlined dense')
|
||||||
new_v_input = ui.input('Value').props('outlined dense')
|
new_v_input = ui.input('Value').props('outlined dense')
|
||||||
|
|
||||||
def add_param():
|
async def add_param():
|
||||||
k = new_k_input.value
|
k = new_k_input.value
|
||||||
v = new_v_input.value
|
v = new_v_input.value
|
||||||
if k and k not in seq:
|
if k and k not in seq:
|
||||||
seq[k] = v
|
seq[k] = v
|
||||||
new_k_input.set_value('')
|
new_k_input.set_value('')
|
||||||
new_v_input.set_value('')
|
new_v_input.set_value('')
|
||||||
commit()
|
await commit()
|
||||||
|
|
||||||
ui.button('Add', on_click=add_param).props('flat')
|
ui.button('Add', on_click=add_param).props('flat')
|
||||||
|
|
||||||
|
|||||||
@@ -602,6 +602,35 @@ def _render_preview_fields(item_data: dict):
|
|||||||
ui.input('Video Path',
|
ui.input('Video Path',
|
||||||
value=str(item_data.get('video file path', ''))).props('readonly outlined')
|
value=str(item_data.get('video file path', ''))).props('readonly outlined')
|
||||||
|
|
||||||
|
resolutions = item_data.get('resolutions')
|
||||||
|
if isinstance(resolutions, list) and resolutions:
|
||||||
|
with ui.expansion('Resolutions'):
|
||||||
|
with ui.grid(columns=4).classes('w-full'):
|
||||||
|
for i, entry in enumerate(resolutions):
|
||||||
|
if isinstance(entry, (list, tuple)) and len(entry) >= 2:
|
||||||
|
w, h = entry[0], entry[1]
|
||||||
|
seed = entry[2] if len(entry) >= 3 else 0
|
||||||
|
ui.input(f'#{i} W', value=str(w)).props('readonly outlined dense')
|
||||||
|
ui.input(f'#{i} H', value=str(h)).props('readonly outlined dense')
|
||||||
|
ui.input(f'#{i} Seed', value=str(seed)).props('readonly outlined dense')
|
||||||
|
ui.label('') # grid spacer for 4th column
|
||||||
|
|
||||||
|
known_keys = {
|
||||||
|
'sequence_number', 'general_prompt', 'general_negative', 'current_prompt', 'prompt',
|
||||||
|
'negative', 'camera', 'flf', 'seed', 'resolutions',
|
||||||
|
'frame_to_skip', 'vace schedule', 'video file path',
|
||||||
|
}
|
||||||
|
# also skip lora keys
|
||||||
|
custom_keys = [
|
||||||
|
k for k in item_data
|
||||||
|
if k not in known_keys and not k.startswith('lora ')
|
||||||
|
]
|
||||||
|
if custom_keys:
|
||||||
|
with ui.expansion('Custom Fields'):
|
||||||
|
with ui.grid(columns=2).classes('w-full'):
|
||||||
|
for k in custom_keys:
|
||||||
|
ui.input(k, value=str(item_data[k])).props('readonly outlined dense')
|
||||||
|
|
||||||
|
|
||||||
def _truncate(val, max_len=60):
|
def _truncate(val, max_len=60):
|
||||||
"""Truncate a value for display."""
|
"""Truncate a value for display."""
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ class TestProjectLoaderDynamic:
|
|||||||
assert "sequence_number" in inputs["required"]
|
assert "sequence_number" in inputs["required"]
|
||||||
|
|
||||||
def test_category(self):
|
def test_category(self):
|
||||||
assert ProjectLoaderDynamic.CATEGORY == "utils/json/project"
|
assert ProjectLoaderDynamic.CATEGORY == "JSON Manager/project"
|
||||||
|
|
||||||
|
|
||||||
class TestProjectSource:
|
class TestProjectSource:
|
||||||
@@ -215,8 +215,8 @@ class TestProjectSource:
|
|||||||
|
|
||||||
def test_outputs_sequence_number(self):
|
def test_outputs_sequence_number(self):
|
||||||
from project_loader import ProjectSource
|
from project_loader import ProjectSource
|
||||||
assert ProjectSource.RETURN_TYPES == ("INT",)
|
assert ProjectSource.RETURN_TYPES == ("INT", "STRING",)
|
||||||
assert ProjectSource.RETURN_NAMES == ("sequence_number",)
|
assert ProjectSource.RETURN_NAMES == ("sequence_number", "file_name",)
|
||||||
|
|
||||||
def test_hold_config_returns_sequence_number(self):
|
def test_hold_config_returns_sequence_number(self):
|
||||||
from project_loader import ProjectSource
|
from project_loader import ProjectSource
|
||||||
@@ -228,11 +228,11 @@ class TestProjectSource:
|
|||||||
sequence_number=42,
|
sequence_number=42,
|
||||||
label="my_source"
|
label="my_source"
|
||||||
)
|
)
|
||||||
assert result == (42,)
|
assert result == (42, "batch_i2v")
|
||||||
|
|
||||||
def test_category(self):
|
def test_category(self):
|
||||||
from project_loader import ProjectSource
|
from project_loader import ProjectSource
|
||||||
assert ProjectSource.CATEGORY == "utils/json/project"
|
assert ProjectSource.CATEGORY == "JSON Manager/project"
|
||||||
|
|
||||||
|
|
||||||
class TestProjectKey:
|
class TestProjectKey:
|
||||||
@@ -341,7 +341,110 @@ class TestProjectKey:
|
|||||||
|
|
||||||
def test_category(self):
|
def test_category(self):
|
||||||
from project_loader import ProjectKey
|
from project_loader import ProjectKey
|
||||||
assert ProjectKey.CATEGORY == "utils/json/project"
|
assert ProjectKey.CATEGORY == "JSON Manager/project"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectResolution:
|
||||||
|
def test_input_types(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
inputs = ProjectResolution.INPUT_TYPES()
|
||||||
|
assert "source_label" in inputs["required"]
|
||||||
|
assert "key_name" in inputs["required"]
|
||||||
|
assert "index" in inputs["required"]
|
||||||
|
assert inputs["required"]["index"][0] == "INT"
|
||||||
|
|
||||||
|
def test_three_outputs(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
assert ProjectResolution.RETURN_TYPES == ("INT", "INT", "INT")
|
||||||
|
assert ProjectResolution.RETURN_NAMES == ("width", "height", "seed")
|
||||||
|
|
||||||
|
def test_fetch_resolution_basic(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
node = ProjectResolution()
|
||||||
|
data = {"resolutions": [[512, 512, 0], [768, 1344, 12345], [1344, 768, 99]]}
|
||||||
|
with patch("project_loader._fetch_data", return_value=data):
|
||||||
|
result = node.fetch_resolution(
|
||||||
|
source_label="src", key_name="resolutions", index=1,
|
||||||
|
manager_url="http://localhost:8080", project_name="p",
|
||||||
|
file_name="f", sequence_number=1,
|
||||||
|
)
|
||||||
|
assert result == (768, 1344, 12345)
|
||||||
|
|
||||||
|
def test_fetch_resolution_index_zero(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
node = ProjectResolution()
|
||||||
|
data = {"resolutions": [[512, 512, 42], [1024, 1024, 0]]}
|
||||||
|
with patch("project_loader._fetch_data", return_value=data):
|
||||||
|
result = node.fetch_resolution(
|
||||||
|
source_label="src", key_name="resolutions", index=0,
|
||||||
|
manager_url="http://localhost:8080", project_name="p",
|
||||||
|
file_name="f", sequence_number=1,
|
||||||
|
)
|
||||||
|
assert result == (512, 512, 42)
|
||||||
|
|
||||||
|
def test_fetch_resolution_clamps_on_out_of_bounds(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
node = ProjectResolution()
|
||||||
|
data = {"resolutions": [[512, 512, 0], [1024, 1024, 7]]}
|
||||||
|
with patch("project_loader._fetch_data", return_value=data):
|
||||||
|
result = node.fetch_resolution(
|
||||||
|
source_label="src", key_name="resolutions", index=99,
|
||||||
|
manager_url="http://localhost:8080", project_name="p",
|
||||||
|
file_name="f", sequence_number=1,
|
||||||
|
)
|
||||||
|
assert result == (1024, 1024, 7) # last entry
|
||||||
|
|
||||||
|
def test_fetch_resolution_old_format_no_seed(self):
|
||||||
|
"""Old [w, h] entries without seed should return seed=0."""
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
node = ProjectResolution()
|
||||||
|
data = {"resolutions": [[576, 384], [960, 640]]}
|
||||||
|
with patch("project_loader._fetch_data", return_value=data):
|
||||||
|
result = node.fetch_resolution(
|
||||||
|
source_label="src", key_name="resolutions", index=0,
|
||||||
|
manager_url="http://localhost:8080", project_name="p",
|
||||||
|
file_name="f", sequence_number=1,
|
||||||
|
)
|
||||||
|
assert result == (576, 384, 0)
|
||||||
|
|
||||||
|
def test_fetch_resolution_missing_key_returns_defaults(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
node = ProjectResolution()
|
||||||
|
with patch("project_loader._fetch_data", return_value={}):
|
||||||
|
result = node.fetch_resolution(
|
||||||
|
source_label="src", key_name="nonexistent", index=0,
|
||||||
|
manager_url="http://localhost:8080", project_name="p",
|
||||||
|
file_name="f", sequence_number=1,
|
||||||
|
)
|
||||||
|
assert result == (512, 512, 0)
|
||||||
|
|
||||||
|
def test_fetch_resolution_network_error_returns_defaults(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
node = ProjectResolution()
|
||||||
|
error_resp = {"error": "network_error", "message": "Connection refused"}
|
||||||
|
with patch("project_loader._fetch_data", return_value=error_resp):
|
||||||
|
result = node.fetch_resolution(
|
||||||
|
source_label="src", key_name="resolutions", index=0,
|
||||||
|
manager_url="http://localhost:8080", project_name="p",
|
||||||
|
file_name="f", sequence_number=1,
|
||||||
|
)
|
||||||
|
assert result == (512, 512, 0)
|
||||||
|
|
||||||
|
def test_fetch_resolution_malformed_entry_returns_defaults(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
node = ProjectResolution()
|
||||||
|
data = {"resolutions": [[512]]} # single-element, not a valid pair
|
||||||
|
with patch("project_loader._fetch_data", return_value=data):
|
||||||
|
result = node.fetch_resolution(
|
||||||
|
source_label="src", key_name="resolutions", index=0,
|
||||||
|
manager_url="http://localhost:8080", project_name="p",
|
||||||
|
file_name="f", sequence_number=1,
|
||||||
|
)
|
||||||
|
assert result == (512, 512, 0)
|
||||||
|
|
||||||
|
def test_category(self):
|
||||||
|
from project_loader import ProjectResolution
|
||||||
|
assert ProjectResolution.CATEGORY == "JSON Manager/project"
|
||||||
|
|
||||||
|
|
||||||
class TestNodeMappings:
|
class TestNodeMappings:
|
||||||
@@ -350,5 +453,6 @@ class TestNodeMappings:
|
|||||||
assert "ProjectLoaderDynamic" in PROJECT_NODE_CLASS_MAPPINGS
|
assert "ProjectLoaderDynamic" in PROJECT_NODE_CLASS_MAPPINGS
|
||||||
assert "ProjectSource" in PROJECT_NODE_CLASS_MAPPINGS
|
assert "ProjectSource" in PROJECT_NODE_CLASS_MAPPINGS
|
||||||
assert "ProjectKey" in PROJECT_NODE_CLASS_MAPPINGS
|
assert "ProjectKey" in PROJECT_NODE_CLASS_MAPPINGS
|
||||||
assert len(PROJECT_NODE_CLASS_MAPPINGS) == 3
|
assert "ProjectResolution" in PROJECT_NODE_CLASS_MAPPINGS
|
||||||
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 3
|
assert len(PROJECT_NODE_CLASS_MAPPINGS) == 4
|
||||||
|
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 4
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "json.manager.project.resolution",
|
||||||
|
|
||||||
|
async beforeQueuePrompt() {
|
||||||
|
if (!app.graph?._nodes) return;
|
||||||
|
for (const node of app.graph._nodes) {
|
||||||
|
if (node.type === "ProjectResolution" && node._syncFromSource) {
|
||||||
|
node._syncFromSource();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
|
if (nodeData.name !== "ProjectResolution") return;
|
||||||
|
|
||||||
|
function hideWidget(widget) {
|
||||||
|
if (widget.origType === undefined) widget.origType = widget.type;
|
||||||
|
widget.type = "hidden";
|
||||||
|
widget.hidden = true;
|
||||||
|
widget.computeSize = () => [0, -4];
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceWithCombo(node, name, values, callback) {
|
||||||
|
const idx = node.widgets?.findIndex(w => w.name === name);
|
||||||
|
if (idx === -1 || idx === undefined) return null;
|
||||||
|
const oldWidget = node.widgets[idx];
|
||||||
|
const savedValue = oldWidget.value || "";
|
||||||
|
const comboValues = values.length > 0 ? values : [""];
|
||||||
|
if (savedValue && !comboValues.includes(savedValue)) {
|
||||||
|
comboValues.unshift(savedValue);
|
||||||
|
}
|
||||||
|
const defaultValue = savedValue || comboValues[0];
|
||||||
|
node.widgets.splice(idx, 1);
|
||||||
|
const combo = node.addWidget("combo", name, defaultValue, callback, { values: comboValues });
|
||||||
|
if (node.widgets.length > 1) {
|
||||||
|
node.widgets.splice(node.widgets.length - 1, 1);
|
||||||
|
node.widgets.splice(idx, 0, combo);
|
||||||
|
}
|
||||||
|
return combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
|
origOnNodeCreated?.apply(this, arguments);
|
||||||
|
this._configured = false;
|
||||||
|
|
||||||
|
// Hide synced config widgets — index stays visible, user wires it from loop node
|
||||||
|
for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) {
|
||||||
|
const w = this.widgets?.find(w => w.name === name);
|
||||||
|
if (w) hideWidget(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = this;
|
||||||
|
const sourceLabels = this._getSourceLabels?.() || [];
|
||||||
|
const srcCombo = replaceWithCombo(this, "source_label", sourceLabels, function (value) {
|
||||||
|
node._syncFromSource();
|
||||||
|
node._refreshKeys();
|
||||||
|
});
|
||||||
|
if (srcCombo) srcCombo.value = sourceLabels[0] || "";
|
||||||
|
|
||||||
|
const keyCombo = replaceWithCombo(this, "key_name", [], function (value) {
|
||||||
|
node.title = value ? `Resolution: ${value}` : "Project Resolution";
|
||||||
|
app.graph?.setDirtyCanvas(true, true);
|
||||||
|
});
|
||||||
|
if (keyCombo && !keyCombo.value) keyCombo.value = "resolutions";
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!this._configured) {
|
||||||
|
this.setSize(this.computeSize());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeType.prototype._getSourceLabels = function () {
|
||||||
|
const seen = new Set();
|
||||||
|
const labels = [];
|
||||||
|
if (!this.graph) return labels;
|
||||||
|
for (const node of this.graph._nodes) {
|
||||||
|
if (node.type === "ProjectSource") {
|
||||||
|
const lw = node.widgets?.find(w => w.name === "label");
|
||||||
|
if (lw?.value && !seen.has(lw.value)) {
|
||||||
|
seen.add(lw.value);
|
||||||
|
labels.push(lw.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeType.prototype._findSource = function (label) {
|
||||||
|
if (!this.graph || !label) return null;
|
||||||
|
for (const node of this.graph._nodes) {
|
||||||
|
if (node.type === "ProjectSource") {
|
||||||
|
const lw = node.widgets?.find(w => w.name === "label");
|
||||||
|
if (lw?.value === label) return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeType.prototype._syncFromSource = function () {
|
||||||
|
const srcWidget = this.widgets?.find(w => w.name === "source_label");
|
||||||
|
const source = this._findSource(srcWidget?.value);
|
||||||
|
if (!source) return;
|
||||||
|
for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) {
|
||||||
|
const dst = this.widgets?.find(w => w.name === name);
|
||||||
|
const src = source.widgets?.find(w => w.name === name);
|
||||||
|
if (dst && src) dst.value = src.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeType.prototype._refreshKeys = async function () {
|
||||||
|
const urlW = this.widgets?.find(w => w.name === "manager_url");
|
||||||
|
const projW = this.widgets?.find(w => w.name === "project_name");
|
||||||
|
const fileW = this.widgets?.find(w => w.name === "file_name");
|
||||||
|
const seqW = this.widgets?.find(w => w.name === "sequence_number");
|
||||||
|
if (!urlW?.value || !projW?.value || !fileW?.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await api.fetchApi(
|
||||||
|
`/json_manager/get_project_keys?url=${encodeURIComponent(urlW.value)}&project=${encodeURIComponent(projW.value)}&file=${encodeURIComponent(fileW.value)}&seq=${seqW?.value || 1}`
|
||||||
|
);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error || !Array.isArray(data.keys)) return;
|
||||||
|
|
||||||
|
const keyWidget = this.widgets?.find(w => w.name === "key_name");
|
||||||
|
if (keyWidget) {
|
||||||
|
keyWidget.options.values = data.keys.length > 0 ? data.keys : [""];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[ProjectResolution] Failed to refresh keys:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const origOnMouseDown = nodeType.prototype.onMouseDown;
|
||||||
|
nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) {
|
||||||
|
origOnMouseDown?.apply(this, arguments);
|
||||||
|
const srcWidget = this.widgets?.find(w => w.name === "source_label");
|
||||||
|
if (srcWidget) srcWidget.options.values = this._getSourceLabels();
|
||||||
|
this._syncFromSource();
|
||||||
|
};
|
||||||
|
|
||||||
|
const origOnConfigure = nodeType.prototype.onConfigure;
|
||||||
|
nodeType.prototype.onConfigure = function (info) {
|
||||||
|
origOnConfigure?.apply(this, arguments);
|
||||||
|
this._configured = true;
|
||||||
|
|
||||||
|
for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) {
|
||||||
|
const w = this.widgets?.find(w => w.name === name);
|
||||||
|
if (w) hideWidget(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcWidget = this.widgets?.find(w => w.name === "source_label");
|
||||||
|
if (srcWidget && srcWidget.type !== "combo") {
|
||||||
|
const node = this;
|
||||||
|
replaceWithCombo(this, "source_label", this._getSourceLabels(), function (value) {
|
||||||
|
node._syncFromSource();
|
||||||
|
node._refreshKeys();
|
||||||
|
});
|
||||||
|
} else if (srcWidget) {
|
||||||
|
srcWidget.options.values = this._getSourceLabels();
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyWidget = this.widgets?.find(w => w.name === "key_name");
|
||||||
|
if (keyWidget && keyWidget.type !== "combo") {
|
||||||
|
const node = this;
|
||||||
|
replaceWithCombo(this, "key_name", [], function (value) {
|
||||||
|
node.title = value ? `Resolution: ${value}` : "Project Resolution";
|
||||||
|
app.graph?.setDirtyCanvas(true, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalKeyWidget = this.widgets?.find(w => w.name === "key_name");
|
||||||
|
if (finalKeyWidget?.value) {
|
||||||
|
this.title = `Resolution: ${finalKeyWidget.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setSize(this.computeSize());
|
||||||
|
|
||||||
|
const node = this;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
node._syncFromSource();
|
||||||
|
node._refreshKeys();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user