51 Commits

Author SHA1 Message Date
Ethanfel c252d0b4e3 fix: hardcode INT/FLOAT dot colors — not defined in LGraphCanvas.link_type_colors 2026-04-04 13:33:41 +02:00
Ethanfel bc61033826 feat: color output dots by type/value on execution
ProjectKey: dot color matches ComfyUI's type color map (INT/FLOAT),
or green/grey for BOOLEAN based on actual value.
BinaryIndexDecoder: same green/grey per-output based on true/false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:29:10 +02:00
Ethanfel 4b19ad0a1d feat: display live value on ProjectKey output for INT/FLOAT/BOOL
Returns ui value alongside result for numeric/boolean types so JS
onExecuted can show e.g. '42  seed' on the output slot, matching
the BinaryIndexDecoder inline display style.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:24:13 +02:00
Ethanfel b3d7c3868d fix: show value on left of output label in BinaryIndexDecoder 2026-04-04 13:22:40 +02:00
Ethanfel 5d2f3bbf4f feat: display live boolean values on BinaryIndexDecoder outputs
Returns ui values alongside result so JS onExecuted can update the
output slot labels with the actual true/false values after execution,
matching the KJNodes style inline display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:18:06 +02:00
Ethanfel 3b700b099b fix: expose start_name/middle_name/end_name in _get_keys for ProjectKey dropdown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:45:37 +02:00
Ethanfel 91241b787c fix: fetch active project in hold_config when project_name is empty
Hidden widgets aren't serialized by ComfyUI on queue, so project_name
arrives empty. Fall back to /api/active-project directly from Python.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:41:35 +02:00
Ethanfel e9056457cd fix: ensure project_path always ends with / 2026-04-04 12:34:02 +02:00
Ethanfel 5c90a59d7e feat: add project_path output to ProjectSource node
ProjectSource now outputs a third value: the project's folder path,
fetched from the new /api/projects/{name} endpoint. Useful for
building paths to files relative to the project directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:28:41 +02:00
Ethanfel 111b37dc8d feat: inject start_name/middle_name/end_name as computed keys in API
Instead of a separate node, _get_data now appends three derived keys
to every sequence response: Path(start frame path).stem → start_name,
etc. Any ProjectKey node can use these directly as key_name.

Reverts ProjectFrameNames node (unnecessary).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:19:12 +02:00
Ethanfel f857485bc8 feat: add ProjectFrameNames node — outputs stem of 3 frame paths
New node in JSON Manager/project that fetches start/middle/end frame
path from the sequence and outputs Path(value).stem for each, so
e.g. '/path/to/keyframe8.png' → 'keyframe8'. Avoids manual string
filtering in large workflows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:16:23 +02:00
Ethanfel 410c80afc8 feat: ProjectSource auto-fills active project from Manager
Added /api/active-project endpoint that reads current_project from
config. ProjectSource now hides the project_name widget and fetches
the active project automatically on create, load, and click.
Title shows "Source: <label> [<project>]" for at-a-glance status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:07:37 +02:00
Ethanfel 2277e6e427 feat: highlight ProjectKey nodes sharing the same key_name on select
Clicking any ProjectKey node temporarily highlights all other nodes
in the workflow that share the same key_name with an amber color.
Deselecting restores their original colors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:04:32 +02:00
Ethanfel 3065dd7e71 fix: use raw <img> tags to bypass q-img dialog rendering bug
NiceGUI's ui.image (Quasar q-img) fails to display inside ui.dialog
regardless of URL type — shows alt text instead of image. Switched
both thumbnail and dialog content to plain HTML <img> tags which
the browser renders directly without component interference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:59:44 +02:00
Ethanfel 783da171e7 fix: serve images via FastAPI endpoint to fix dialog preview
NiceGUI's ui.image with a local file path fails to register static
files when inside a ui.dialog, showing alt text instead of the image.
Added /api/image-preview?path=... endpoint that streams the file via
FileResponse, and updated frame path thumbnails to use this URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:54:50 +02:00
Ethanfel 783f07e57a feat: make logic index read-only, driven solely by frame switches
Removed end_frame mirror. Logic index is now entirely computed
from the 3 frame switches (bit 0=start, bit 1=middle, bit 2=end).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:49:13 +02:00
Ethanfel c7ca3ae277 fix: replace broken hover tooltip with click-to-open dialog for image preview
Quasar tooltip size constraints prevent large images from rendering.
Thumbnail is now clickable (cursor:pointer) and opens a full-size dialog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:48:14 +02:00
Ethanfel f376fd5622 feat: show frame image preview on hover via thumbnail tooltip
Replaces click-to-dialog with a small thumbnail that reveals
the full image on hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:41:00 +02:00
Ethanfel fec843f804 feat: move frame paths to left column with strength + logic index switch
Each frame path row (start/middle/end) now has:
- path input with preview
- strength float (default 1.0)
- switch linked to the corresponding logic index bit

Switches and logic index are bidirectionally synced.
end_frame → logic index → switches mirror chain preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:38:30 +02:00
Ethanfel 2619d2c7e2 feat: move resolutions into collapsible expansion above VACE Settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:33:36 +02:00
Ethanfel 03dcb1c13a feat: add tooltip to Logic Index explaining binary flag mapping
Bit 0 = start frame, bit 1 = middle frame, bit 2 = end frame.
Tooltip shows the full 0-7 truth table on hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:41:03 +02:00
Ethanfel 9ffdf6287d fix: import folder scans project's folder_path not current_dir
Was scanning state.current_dir which could differ from the project's
actual folder, causing no JSON files to be found.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:26:17 +02:00
Ethanfel 735d905833 fix: move default DB path to project directory
projects.db now lives next to main.py instead of ~/.comfyui_json_manager/
so it survives Docker container updates when the project dir is mounted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:24:59 +02:00
Ethanfel 932295ed27 fix: replace direction:rtl with text-align:right on path inputs
direction:rtl caused path characters to render in wrong order.
text-align:right right-aligns the text (shows end of path) without
breaking the character display order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:21:55 +02:00
Ethanfel a5da8b26f4 feat: add 'logic index' field mirroring end_frame
Temporary field to ease node migration. Initializes to end_frame's
value and stays in sync whenever end_frame changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:14:25 +02:00
Ethanfel 5bc2838b21 feat: rename 'reference image path' to 'start frame path'
Updates DEFAULTS, standard_keys, UI label, timeline known_keys.
Migration auto-renames old key on load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:11:43 +02:00
Ethanfel a7a4794adb feat: rename 'flf image path' to 'end frame path'
Updates DEFAULTS, standard_keys, UI label, timeline known_keys.
Migration auto-renames old key on load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:10:09 +02:00
Ethanfel d33ce4da38 feat: rename 'reference path' to 'middle frame path'
Updates DEFAULTS, standard_keys, UI label, and timeline known_keys.
Adds _migrate_key_renames() called on load_json to auto-migrate
existing JSON files with the old key name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:09:08 +02:00
Ethanfel 4fe9a9c958 feat: add BinaryIndexDecoder node (INT index → 3 BOOLEANs, binary encoding)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:05:15 +02:00
Ethanfel 820cb426aa test: add missing index 5 and 6 cases for BinaryIndexDecoder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:04:15 +02:00
Ethanfel b75b177591 test: add failing tests for BinaryIndexDecoder node
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:02:43 +02:00
Ethanfel c8cc397cc6 docs: add BinaryIndexDecoder implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:01:36 +02:00
Ethanfel f0e785afab docs: add BinaryIndexDecoder node design
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:00:56 +02:00
Ethanfel 20be3204b3 fix: await all commit() calls in sequence card event handlers
commit() is async but was called without await from sync handlers
(clone_next, clone_end, clone_sub, delete, copy_source, del_custom,
add_param, _sync_entry, _randomize) — causing saves and UI refreshes
to silently never run. Made all handlers async and added await.

Also fixed for i,entry loop shadowing the card's i parameter,
which was causing _render_vace_settings to receive the wrong index.
Removed unawaited render-time commit() in resolutions init block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 12:59:25 +02:00
Ethanfel fe8f91b477 feat: show resolutions and custom fields in timeline preview
_render_preview_fields was only rendering hardcoded known keys.
Now adds a Resolutions section (W/H/Seed per slot) and a Custom Fields
catch-all for any other keys not in the standard set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 12:46:57 +02:00
Ethanfel 55900e7c43 feat: 8 resolution slots with per-slot seed + node outputs seed
- Resolution entries expanded from 6 to 8 fixed slots
- Each slot now stores [w, h, seed] (migrates old [w, h] entries to [w, h, 0])
- UI adds seed number input + casino randomize button per row
- ProjectResolution node now outputs (width, height, seed) instead of (width, height)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:27:11 +02:00
Ethanfel 062f7880a6 fix: read sequence data directly from JSON file in API endpoints
_get_data and _get_keys were querying the SQLite DB which only gets
populated when db_enabled is on. JSON file is always the source of
truth, so read from it directly — fixes missing keys (e.g. resolutions)
when DB hasn't been synced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 01:33:29 +02:00
Ethanfel cf8496ec08 fix: default key_name to 'resolutions' on new ProjectResolution node
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:24:58 +02:00
Ethanfel ca26da303c fix: persist resolutions on init and on every value change
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:22:08 +02:00
Ethanfel 29be286eb1 fix: move nodes to JSON Manager/project category
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:19:30 +02:00
Ethanfel f97f8a0616 feat: resolutions — 6 fixed slots, always visible
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:11:07 +02:00
Ethanfel 4b51d3c95d feat: simplify resolutions UI — fixed key, index-labeled rows, single add button
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:07:59 +02:00
Ethanfel 281c04dd2e feat: ProjectResolution JS extension for ComfyUI frontend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:42:46 +02:00
Ethanfel 31406cb092 feat: resolution series editor in sequence card
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:42:46 +02:00
Ethanfel b31faa4274 feat: add ProjectResolution node
Implements ProjectResolution with TDD: fetches a [width, height] pair
from a resolution series by loop index, clamping out-of-bounds indices
to the last entry and returning (512, 512) defaults on error or missing key.
Also registers the node in mappings and updates TestNodeMappings count to 4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:42:46 +02:00
Ethanfel 80aff2ba43 fix: update ProjectSource tests for file_name output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 00:42:16 +02:00
Ethanfel c1c929722c Merge branch 'feat/snapshot-timeline' 2026-04-03 00:41:44 +02:00
Ethanfel d3becdc598 docs: fix 4 bugs in resolution series plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 00:21:32 +02:00
Ethanfel 4f31d792df docs: resolution series implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 00:19:03 +02:00
Ethanfel 67c40c1ebe docs: resolution series design doc
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 00:15:08 +02:00
Ethanfel 74b57f71ac feat: add file_name output to ProjectSource node
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 18:16:31 +01:00
16 changed files with 1819 additions and 104 deletions
+77 -18
View File
@@ -1,16 +1,19 @@
"""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 fastapi.responses import FileResponse
from nicegui import app from nicegui import app
from db import ProjectDB from db import ProjectDB
from utils import load_json, load_config, KEY_BATCH_DATA, KEY_SEQUENCE_NUMBER
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,10 +27,13 @@ def register_api_routes(db: ProjectDB) -> None:
_db = db _db = db
app.add_api_route("/api/projects", _list_projects, methods=["GET"]) app.add_api_route("/api/projects", _list_projects, methods=["GET"])
app.add_api_route("/api/active-project", _get_active_project, methods=["GET"])
app.add_api_route("/api/projects/{name}", _get_project, methods=["GET"])
app.add_api_route("/api/projects/{name}/files", _list_files, methods=["GET"]) app.add_api_route("/api/projects/{name}/files", _list_files, methods=["GET"])
app.add_api_route("/api/projects/{name}/files/{file_name}/sequences", _list_sequences, methods=["GET"]) app.add_api_route("/api/projects/{name}/files/{file_name}/sequences", _list_sequences, methods=["GET"])
app.add_api_route("/api/projects/{name}/files/{file_name}/data", _get_data, methods=["GET"]) app.add_api_route("/api/projects/{name}/files/{file_name}/data", _get_data, methods=["GET"])
app.add_api_route("/api/projects/{name}/files/{file_name}/keys", _get_keys, methods=["GET"]) app.add_api_route("/api/projects/{name}/files/{file_name}/keys", _get_keys, methods=["GET"])
app.add_api_route("/api/image-preview", _serve_image, methods=["GET"])
def _get_db() -> ProjectDB: def _get_db() -> ProjectDB:
@@ -42,6 +48,20 @@ def _list_projects() -> dict[str, Any]:
return {"projects": [p["name"] for p in projects]} return {"projects": [p["name"] for p in projects]}
def _get_active_project() -> dict[str, Any]:
config = load_config()
return {"project": config.get("current_project", "")}
def _get_project(name: str) -> dict[str, Any]:
db = _get_db()
proj = db.get_project(name)
if not proj:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
return {"name": proj["name"], "folder_path": proj["folder_path"],
"description": proj.get("description", "")}
def _list_files(name: str) -> dict[str, Any]: def _list_files(name: str) -> dict[str, Any]:
db = _get_db() db = _get_db()
files = db.list_project_files(name) files = db.list_project_files(name)
@@ -54,34 +74,73 @@ 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")
result = dict(match)
for out_key, src_key in (
("start_name", "start frame path"),
("middle_name", "middle frame path"),
("end_name", "end frame path"),
):
path_val = result.get(src_key, "")
result[out_key] = Path(path_val).stem if path_val else ""
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(result), time.perf_counter() - t0)
return data return result
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")
# Computed keys derived from frame paths
for out_key, src_key in (
("start_name", "start frame path"),
("middle_name", "middle frame path"),
("end_name", "end frame path"),
):
if src_key in match:
keys.append(out_key)
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}
def _serve_image(path: str = Query(...)) -> FileResponse:
p = Path(path)
if not p.exists() or not p.is_file():
raise HTTPException(status_code=404, detail="Image not found")
return FileResponse(str(p))
+1 -1
View File
@@ -9,7 +9,7 @@ from utils import load_json, KEY_BATCH_DATA, KEY_HISTORY_TREE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_DB_PATH = Path.home() / ".comfyui_json_manager" / "projects.db" DEFAULT_DB_PATH = Path(__file__).parent / "projects.db"
SCHEMA_SQL = """ SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS projects ( CREATE TABLE IF NOT EXISTS projects (
@@ -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
```
@@ -0,0 +1,67 @@
# BinaryIndexDecoder Node — Design
## Summary
A standalone ComfyUI utility node that converts an integer index into 3 boolean
outputs using binary (bit-field) encoding. Intended for use with loop counters to
gate multiple processing branches simultaneously.
## Node Spec
| Field | Value |
|---|---|
| Class name | `BinaryIndexDecoder` |
| Display name | `Binary Index Decoder` |
| Category | `JSON Manager/utils` |
| Function | `decode` |
### Inputs
| Name | Type | Default | Range |
|---|---|---|---|
| `index` | INT | 0 | 07 |
### Outputs
| Name | Type |
|---|---|
| `flag_0` | BOOLEAN |
| `flag_1` | BOOLEAN |
| `flag_2` | BOOLEAN |
### Logic
```
flag_0 = bool((index >> 0) & 1)
flag_1 = bool((index >> 1) & 1)
flag_2 = bool((index >> 2) & 1)
```
### Truth table
| index | flag_0 | flag_1 | flag_2 |
|---|---|---|---|
| 0 | F | F | F |
| 1 | T | F | F |
| 2 | F | T | F |
| 3 | T | T | F |
| 4 | F | F | T |
| 5 | T | F | T |
| 6 | F | T | T |
| 7 | T | T | T |
## Implementation Notes
- Lives in `project_loader.py` alongside other project nodes
- Added to `PROJECT_NODE_CLASS_MAPPINGS` and `PROJECT_NODE_DISPLAY_NAME_MAPPINGS`
- No JavaScript extension needed (no source sync, no dynamic widgets)
- No NiceGUI UI changes needed
- `IS_CHANGED` not needed (output is deterministic from input)
## Testing
9 tests in `tests/test_project_loader.py::TestBinaryIndexDecoder`:
- Input types include `index` as INT
- All 8 index values (07) produce correct boolean tuple
- Out-of-range index (e.g. 8) clamps to 07 or wraps gracefully
- `NodeMappings` test updated: 5 nodes, mappings length == 5
@@ -0,0 +1,166 @@
# BinaryIndexDecoder Node — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a standalone ComfyUI node `BinaryIndexDecoder` that converts an integer index to 3 boolean outputs using binary (bit-field) encoding.
**Architecture:** Single class in `project_loader.py`, no JS extension needed, no NiceGUI changes. Takes `index` INT, returns `(flag_0, flag_1, flag_2)` as BOOLEAN using bit-shift logic. Added to existing node mappings.
**Tech Stack:** Python, ComfyUI node API, pytest
---
### Task 1: Write failing tests for BinaryIndexDecoder
**Files:**
- Modify: `tests/test_project_loader.py` (append new test class at end, before `TestNodeMappings`)
- Modify: `tests/test_project_loader.py` — update `TestNodeMappings.test_mappings_exist` to expect 5 nodes
**Step 1: Add the test class**
Append this class before `TestNodeMappings` in `tests/test_project_loader.py`:
```python
class TestBinaryIndexDecoder:
def test_input_types(self):
from project_loader import BinaryIndexDecoder
inputs = BinaryIndexDecoder.INPUT_TYPES()
assert "index" in inputs["required"]
assert inputs["required"]["index"][0] == "INT"
def test_three_boolean_outputs(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder.RETURN_TYPES == ("BOOLEAN", "BOOLEAN", "BOOLEAN")
assert BinaryIndexDecoder.RETURN_NAMES == ("flag_0", "flag_1", "flag_2")
def test_category(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder.CATEGORY == "JSON Manager/utils"
def test_index_0(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(0) == (False, False, False)
def test_index_1(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(1) == (True, False, False)
def test_index_2(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(2) == (False, True, False)
def test_index_3(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(3) == (True, True, False)
def test_index_4(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(4) == (False, False, True)
def test_index_7(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(7) == (True, True, True)
```
Also update `TestNodeMappings.test_mappings_exist`:
```python
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 "BinaryIndexDecoder" in PROJECT_NODE_CLASS_MAPPINGS
assert len(PROJECT_NODE_CLASS_MAPPINGS) == 5
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 5
```
**Step 2: Run tests to verify they fail**
```bash
python -m pytest tests/test_project_loader.py::TestBinaryIndexDecoder -v
```
Expected: FAIL with `ImportError: cannot import name 'BinaryIndexDecoder'`
**Step 3: Commit the failing tests**
```bash
git add tests/test_project_loader.py
git commit -m "test: add failing tests for BinaryIndexDecoder node"
```
---
### Task 2: Implement BinaryIndexDecoder
**Files:**
- Modify: `project_loader.py` — add class after `ProjectResolution`, update mappings
**Step 1: Add the class**
Insert after the `ProjectResolution` class (before `# --- Mappings ---`) in `project_loader.py`:
```python
class BinaryIndexDecoder:
"""Decodes an integer index into 3 boolean flags using binary (bit-field) encoding.
index 0 → (False, False, False)
index 1 → (True, False, False) # bit 0
index 2 → (False, True, False) # bit 1
index 3 → (True, True, False) # bits 0+1
index 4 → (False, False, True) # bit 2
...
index 7 → (True, True, True)
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"index": ("INT", {"default": 0, "min": 0, "max": 7}),
}
}
RETURN_TYPES = ("BOOLEAN", "BOOLEAN", "BOOLEAN")
RETURN_NAMES = ("flag_0", "flag_1", "flag_2")
FUNCTION = "decode"
CATEGORY = "JSON Manager/utils"
OUTPUT_NODE = False
def decode(self, index: int):
return (
bool((index >> 0) & 1),
bool((index >> 1) & 1),
bool((index >> 2) & 1),
)
```
**Step 2: Update mappings**
In `PROJECT_NODE_CLASS_MAPPINGS`, add:
```python
"BinaryIndexDecoder": BinaryIndexDecoder,
```
In `PROJECT_NODE_DISPLAY_NAME_MAPPINGS`, add:
```python
"BinaryIndexDecoder": "Binary Index Decoder",
```
**Step 3: Run all tests**
```bash
python -m pytest tests/test_project_loader.py -v
```
Expected: all tests PASS (42 existing + 10 new = 52 total)
**Step 4: Commit**
```bash
git add project_loader.py tests/test_project_loader.py
git commit -m "feat: add BinaryIndexDecoder node (INT index → 3 BOOLEANs, binary encoding)"
git push
```
+123 -10
View File
@@ -67,6 +67,13 @@ def _fetch_json(url: str) -> dict:
return {"error": "parse_error", "message": str(e)} return {"error": "parse_error", "message": str(e)}
def _fetch_project(manager_url: str, project: str) -> dict:
"""Fetch project details (including folder_path) from the NiceGUI REST API."""
p = urllib.parse.quote(project, safe='')
url = f"{manager_url.rstrip('/')}/api/projects/{p}"
return _fetch_json(url)
def _fetch_data(manager_url: str, project: str, file: str, seq: int) -> dict: def _fetch_data(manager_url: str, project: str, file: str, seq: int) -> dict:
"""Fetch sequence data from the NiceGUI REST API.""" """Fetch sequence data from the NiceGUI REST API."""
p = urllib.parse.quote(project, safe='') p = urllib.parse.quote(project, safe='')
@@ -150,7 +157,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 +228,24 @@ class ProjectSource:
}, },
} }
RETURN_TYPES = ("INT",) RETURN_TYPES = ("INT", "STRING", "STRING")
RETURN_NAMES = ("sequence_number",) RETURN_NAMES = ("sequence_number", "file_name", "project_path")
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,) name = project_name.strip()
if not name:
active = _fetch_json(f"{manager_url.rstrip('/')}/api/active-project")
name = active.get("project", "") if "error" not in active else ""
folder_path = ""
if name:
proj = _fetch_project(manager_url, name)
folder_path = proj.get("folder_path", "") if "error" not in proj else ""
if folder_path and not folder_path.endswith("/"):
folder_path += "/"
return (sequence_number, file_name, folder_path)
class ProjectKey: class ProjectKey:
@@ -252,7 +269,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
@@ -282,26 +299,122 @@ class ProjectKey:
val = data.get(key_name, "") val = data.get(key_name, "")
if key_type == "INT": if key_type == "INT":
return (to_int(val),) result = to_int(val)
return {"ui": {"value": [str(result)]}, "result": (result,)}
elif key_type == "FLOAT": elif key_type == "FLOAT":
return (to_float(val),) result = to_float(val)
return {"ui": {"value": [f"{result:.4g}"]}, "result": (result,)}
elif isinstance(val, bool): elif isinstance(val, bool):
return (str(val).lower(),) return {"ui": {"value": [str(val).lower()]}, "result": (str(val).lower(),)}
elif isinstance(val, (int, float)): elif isinstance(val, (int, float)):
return (val,) return {"ui": {"value": [str(val)]}, "result": (val,)}
else: else:
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)
class BinaryIndexDecoder:
"""Decodes an integer index into 3 boolean flags using binary (bit-field) encoding.
index 0 → (False, False, False)
index 1 → (True, False, False) # bit 0
index 2 → (False, True, False) # bit 1
index 3 → (True, True, False) # bits 0+1
index 4 → (False, False, True) # bit 2
...
index 7 → (True, True, True)
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"index": ("INT", {"default": 0, "min": 0, "max": 7}),
}
}
RETURN_TYPES = ("BOOLEAN", "BOOLEAN", "BOOLEAN")
RETURN_NAMES = ("flag_0", "flag_1", "flag_2")
FUNCTION = "decode"
CATEGORY = "JSON Manager/utils"
OUTPUT_NODE = False
def decode(self, index: int):
f0 = bool((index >> 0) & 1)
f1 = bool((index >> 1) & 1)
f2 = bool((index >> 2) & 1)
return {"ui": {"values": [str(f0).lower(), str(f1).lower(), str(f2).lower()]},
"result": (f0, f1, f2)}
# --- Mappings --- # --- Mappings ---
PROJECT_NODE_CLASS_MAPPINGS = { PROJECT_NODE_CLASS_MAPPINGS = {
"ProjectLoaderDynamic": ProjectLoaderDynamic, "ProjectLoaderDynamic": ProjectLoaderDynamic,
"ProjectSource": ProjectSource, "ProjectSource": ProjectSource,
"ProjectKey": ProjectKey, "ProjectKey": ProjectKey,
"ProjectResolution": ProjectResolution,
"BinaryIndexDecoder": BinaryIndexDecoder,
} }
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",
"BinaryIndexDecoder": "Binary Index Decoder",
} }
+113 -35
View File
@@ -6,6 +6,7 @@ import math
import random import random
import time import time
from pathlib import Path from pathlib import Path
from urllib.parse import quote
from nicegui import ui from nicegui import ui
@@ -314,9 +315,12 @@ def render_batch_processor(state: AppState):
standard_keys = { standard_keys = {
'name', 'mode', 'general_prompt', 'general_negative', 'current_prompt', 'negative', 'prompt', 'name', 'mode', 'general_prompt', 'general_negative', 'current_prompt', 'negative', 'prompt',
'seed', 'cfg', 'camera', 'flf', KEY_SEQUENCE_NUMBER, 'seed', 'cfg', 'camera', 'flf', KEY_SEQUENCE_NUMBER,
'frame_to_skip', 'end_frame', 'transition', 'vace_length', 'frame_to_skip', 'end_frame', 'logic index', 'transition', 'vace_length',
'input_a_frames', 'input_b_frames', 'reference switch', 'vace schedule', 'input_a_frames', 'input_b_frames', 'reference switch', 'vace schedule',
'reference path', 'video file path', 'reference image path', 'flf image path', 'start frame path', 'start frame strength',
'middle frame path', 'middle frame strength',
'end frame path', 'end frame strength',
'video file path',
} }
standard_keys.update(lora_keys) standard_keys.update(lora_keys)
@@ -409,6 +413,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 +474,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 +490,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 +503,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,23 +529,24 @@ 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')
ui.separator() ui.separator()
# --- Prompts + Settings (2-column) --- # --- Prompts + Settings (2-column) ---
frame_switches = [] # populated below, used for bidirectional sync with logic index
with ui.splitter(value=66).classes('w-full') as splitter: with ui.splitter(value=66).classes('w-full') as splitter:
with splitter.before: with splitter.before:
dict_textarea('General Prompt', seq, 'general_prompt').classes( dict_textarea('General Prompt', seq, 'general_prompt').classes(
@@ -552,6 +558,35 @@ 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')
# --- Frame paths (start / middle / end) ---
logic_val = int(seq.get('logic index', 0))
for bit, img_label, img_key, str_key in [
(0, 'Start Frame', 'start frame path', 'start frame strength'),
(1, 'Middle Frame', 'middle frame path', 'middle frame strength'),
(2, 'End Frame', 'end frame path', 'end frame strength'),
]:
ui.label(img_label).classes('text-caption text-weight-bold q-mt-sm')
with ui.row().classes('w-full items-center no-wrap q-mt-xs'):
inp = dict_input(ui.input, 'Path', seq, img_key).classes(
'col').props('outlined dense input-style="text-align: right"')
img_path = Path(seq.get(img_key, '')) if seq.get(img_key) else None
if (img_path and img_path.exists() and
img_path.suffix.lower() in IMAGE_EXTENSIONS):
img_url = f'/api/image-preview?path={quote(str(img_path))}'
with ui.dialog() as img_dlg, ui.card().style('max-width:90vw; padding:0'):
ui.html(f'<img src="{img_url}" '
f'style="max-width:80vw;max-height:80vh;display:block">')
ui.html(
f'<img src="{img_url}" '
f'style="width:36px;height:36px;object-fit:cover;'
f'border-radius:4px;cursor:pointer;flex-shrink:0">'
).on('click', img_dlg.open)
str_inp = dict_number('Strength', seq, str_key, default=1.0,
step=0.05, format='%.2f').style(
'width:80px').props('outlined dense')
sw = ui.switch(value=bool((logic_val >> bit) & 1))
frame_switches.append(sw)
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')
@@ -581,25 +616,68 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
dict_input(ui.input, 'Camera', seq, 'camera').props('outlined').classes('w-full') dict_input(ui.input, 'Camera', seq, 'camera').props('outlined').classes('w-full')
dict_input(ui.input, 'FLF', seq, 'flf').props('outlined').classes('w-full') dict_input(ui.input, 'FLF', seq, 'flf').props('outlined').classes('w-full')
dict_number('End Frame', seq, 'end_frame').props('outlined').classes('w-full') ef_input = dict_number('End Frame', seq, 'end_frame').props('outlined').classes('w-full')
seq.setdefault('logic index', 0)
li_input = dict_number('Logic Index', seq, 'logic index').props('outlined readonly').classes('w-full')
with li_input:
ui.tooltip(
'Binary flags — bit 0: start frame | bit 1: middle frame | bit 2: end frame\n'
'0: none 1: start 2: middle 3: start+middle\n'
'4: end 5: start+end 6: middle+end 7: all'
)
dict_input(ui.input, 'Video File Path', seq, 'video file path').props( dict_input(ui.input, 'Video File Path', seq, 'video file path').props(
'outlined input-style="direction: rtl"').classes('w-full') 'outlined input-style="text-align: right"').classes('w-full')
# Image paths with preview # Switches → logic index (sole writer)
for img_label, img_key in [ def _sync_switches_to_logic(li=li_input, switches=frame_switches, s=seq):
('Reference Image Path', 'reference image path'), v = sum(int(sw.value) << b for b, sw in enumerate(switches))
('Reference Path', 'reference path'), s['logic index'] = v
('FLF Image Path', 'flf image path'), li.set_value(v)
]:
with ui.row().classes('w-full items-center'): for frame_sw in frame_switches:
inp = dict_input(ui.input, img_label, seq, img_key).classes( frame_sw.on('update:model-value', lambda _, s=_sync_switches_to_logic: s())
'col').props('outlined input-style="direction: rtl"')
img_path = Path(seq.get(img_key, '')) if seq.get(img_key) else None # --- Resolutions (8 fixed slots) ---
if (img_path and img_path.exists() and resolutions = seq.setdefault('resolutions', [])
img_path.suffix.lower() in IMAGE_EXTENSIONS): while len(resolutions) < 8:
with ui.dialog() as dlg, ui.card(): resolutions.append([512, 512, 0])
ui.image(str(img_path)).classes('w-full') for r_i in range(len(resolutions)):
ui.button(icon='visibility', on_click=dlg.open).props('flat dense') if len(resolutions[r_i]) < 3:
resolutions[r_i] = list(resolutions[r_i]) + [0]
with ui.expansion('Resolutions', icon='aspect_ratio').classes('w-full'):
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())
# --- VACE Settings (full width) --- # --- VACE Settings (full width) ---
with ui.expansion('VACE Settings', icon='settings').classes('w-full'): with ui.expansion('VACE Settings', icon='settings').classes('w-full'):
@@ -645,16 +723,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 +740,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')
+4 -2
View File
@@ -216,8 +216,10 @@ def render_projects_tab(state: AppState):
async def _import_folder(state: AppState, project_id: int, project_name: str, refresh_fn): async def _import_folder(state: AppState, project_id: int, project_name: str, refresh_fn):
"""Bulk import all .json files from current directory into a project.""" """Bulk import all .json files from the project's folder_path into a project."""
json_files = sorted(state.current_dir.glob('*.json')) proj = state.db.get_project(project_name)
scan_dir = Path(proj['folder_path']) if proj else state.current_dir
json_files = sorted(scan_dir.glob('*.json'))
json_files = [f for f in json_files if f.name not in ( json_files = [f for f in json_files if f.name not in (
'.editor_config.json', '.editor_snippets.json')] '.editor_config.json', '.editor_snippets.json')]
+30
View File
@@ -602,6 +602,36 @@ 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', 'middle frame path', 'end frame path', 'start frame path',
'logic index',
}
# 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."""
+162 -8
View File
@@ -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,159 @@ 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 TestBinaryIndexDecoder:
def test_input_types(self):
from project_loader import BinaryIndexDecoder
inputs = BinaryIndexDecoder.INPUT_TYPES()
assert "index" in inputs["required"]
assert inputs["required"]["index"][0] == "INT"
def test_three_boolean_outputs(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder.RETURN_TYPES == ("BOOLEAN", "BOOLEAN", "BOOLEAN")
assert BinaryIndexDecoder.RETURN_NAMES == ("flag_0", "flag_1", "flag_2")
def test_category(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder.CATEGORY == "JSON Manager/utils"
def test_index_0(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(0) == (False, False, False)
def test_index_1(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(1) == (True, False, False)
def test_index_2(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(2) == (False, True, False)
def test_index_3(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(3) == (True, True, False)
def test_index_4(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(4) == (False, False, True)
def test_index_5(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(5) == (True, False, True)
def test_index_6(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(6) == (False, True, True)
def test_index_7(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(7) == (True, True, True)
class TestNodeMappings: class TestNodeMappings:
@@ -350,5 +502,7 @@ 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 "BinaryIndexDecoder" in PROJECT_NODE_CLASS_MAPPINGS
assert len(PROJECT_NODE_CLASS_MAPPINGS) == 5
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 5
+21 -3
View File
@@ -38,6 +38,7 @@ DEFAULTS = {
# --- I2V / VACE Specifics --- # --- I2V / VACE Specifics ---
"frame_to_skip": 81, "frame_to_skip": 81,
"end_frame": 0, "end_frame": 0,
"logic index": 0,
"transition": "1-2", "transition": "1-2",
"vace_length": 49, "vace_length": 49,
"vace schedule": 1, "vace schedule": 1,
@@ -45,9 +46,12 @@ DEFAULTS = {
"input_b_frames": 16, "input_b_frames": 16,
"reference switch": 1, "reference switch": 1,
"video file path": "", "video file path": "",
"reference image path": "", "start frame path": "",
"reference path": "", "start frame strength": 1.0,
"flf image path": "", "middle frame path": "",
"middle frame strength": 1.0,
"end frame path": "",
"end frame strength": 1.0,
# --- LoRAs (name as STRING, strength as FLOAT) --- # --- LoRAs (name as STRING, strength as FLOAT) ---
"lora 1 high": "", "lora 1 high": "",
@@ -150,6 +154,19 @@ def save_snippets(snippets):
json.dump(snippets, f, indent=4) json.dump(snippets, f, indent=4)
os.replace(tmp, SNIPPETS_FILE) os.replace(tmp, SNIPPETS_FILE)
def _migrate_key_renames(data: dict) -> None:
"""Rename legacy keys to their current names."""
for item in data.get(KEY_BATCH_DATA, []):
if not isinstance(item, dict):
continue
if 'reference path' in item and 'middle frame path' not in item:
item['middle frame path'] = item.pop('reference path')
if 'flf image path' in item and 'end frame path' not in item:
item['end frame path'] = item.pop('flf image path')
if 'reference image path' in item and 'start frame path' not in item:
item['start frame path'] = item.pop('reference image path')
def _migrate_lora_keys(data: dict) -> None: def _migrate_lora_keys(data: dict) -> None:
"""Split combined lora 'name:strength' into separate name and strength keys. """Split combined lora 'name:strength' into separate name and strength keys.
@@ -208,6 +225,7 @@ def load_json(path: str | Path) -> tuple[dict[str, Any], float]:
with open(path, 'r') as f: with open(path, 'r') as f:
data = json.load(f) data = json.load(f)
t1 = time.time() t1 = time.time()
_migrate_key_renames(data)
_migrate_lora_keys(data) _migrate_lora_keys(data)
t2 = time.time() t2 = time.time()
mtime = path.stat().st_mtime mtime = path.stat().st_mtime
+20
View File
@@ -0,0 +1,20 @@
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "json.manager.binary_index_decoder",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "BinaryIndexDecoder") return;
nodeType.prototype.onExecuted = function (output) {
if (!output?.values) return;
for (let i = 0; i < Math.min(output.values.length, this.outputs.length); i++) {
const val = output.values[i];
this.outputs[i].label = `${val} ${this.outputs[i].name}`;
this.outputs[i].color_on = (val === "true") ? "#4caf50" : "#888888";
this.outputs[i].color_off = (val === "true") ? "#4caf50" : "#888888";
}
app.graph?.setDirtyCanvas(true, true);
};
},
});
+54
View File
@@ -201,6 +201,60 @@ app.registerExtension({
app.graph?.setDirtyCanvas(true, true); app.graph?.setDirtyCanvas(true, true);
}; };
// --- Show live value on output slot after execution (INT/FLOAT/BOOL only) ---
nodeType.prototype.onExecuted = function (output) {
if (!this.outputs.length) return;
const val = output?.value?.[0];
if (val === undefined) return;
const keyWidget = this.widgets?.find(w => w.name === "key_name");
const name = keyWidget?.value || this.outputs[0].name;
this.outputs[0].label = `${val} ${name}`;
const slotType = this.outputs[0].type;
const TYPE_COLORS = { "INT": "#3d7eb5", "FLOAT": "#68a468", "BOOLEAN": null };
let color;
if (slotType === "BOOLEAN") {
color = (val === "true") ? "#4caf50" : "#888888";
} else {
color = TYPE_COLORS[slotType]
?? LGraphCanvas?.link_type_colors?.[slotType]
?? app.canvas?.default_connection_color_byType?.[slotType];
}
if (color) {
this.outputs[0].color_on = color;
this.outputs[0].color_off = color;
}
app.graph?.setDirtyCanvas(true, true);
};
// --- Highlight all ProjectKey nodes sharing the same key_name on select ---
nodeType.prototype.onSelected = function () {
const keyWidget = this.widgets?.find(w => w.name === "key_name");
const myKey = keyWidget?.value;
if (!myKey || !this.graph) return;
for (const node of this.graph._nodes) {
if (node === this || node.type !== "ProjectKey") continue;
const kw = node.widgets?.find(w => w.name === "key_name");
if (kw?.value !== myKey) continue;
node._savedColor = node.color;
node._savedBgColor = node.bgcolor;
node.color = "#c8a000";
node.bgcolor = "#4a3800";
}
app.graph?.setDirtyCanvas(true, true);
};
nodeType.prototype.onDeselected = function () {
if (!this.graph) return;
for (const node of this.graph._nodes) {
if (node.type !== "ProjectKey" || !("_savedColor" in node)) continue;
node.color = node._savedColor;
node.bgcolor = node._savedBgColor;
delete node._savedColor;
delete node._savedBgColor;
}
app.graph?.setDirtyCanvas(true, true);
};
// --- Sync config on click (lazy, no key refresh to avoid race) --- // --- Sync config on click (lazy, no key refresh to avoid race) ---
const origOnMouseDown = nodeType.prototype.onMouseDown; const origOnMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) { nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) {
+191
View File
@@ -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();
});
};
},
});
+67 -25
View File
@@ -28,6 +28,35 @@ app.registerExtension({
return combo; return combo;
} }
// Fetch active project from Manager and update project_name + title
async function refreshActiveProject(node) {
const urlW = node.widgets?.find(w => w.name === "manager_url");
if (!urlW?.value) return;
try {
const resp = await fetch(`${urlW.value}/api/active-project`);
if (!resp.ok) return;
const data = await resp.json();
const project = data.project || "";
const projW = node.widgets?.find(w => w.name === "project_name");
if (projW && projW.value !== project) {
projW.value = project;
await refreshFiles(node);
}
_updateTitle(node);
} catch (e) {
console.warn("[ProjectSource] Failed to fetch active project:", e);
}
}
function _updateTitle(node) {
const labelW = node.widgets?.find(w => w.name === "label");
const projW = node.widgets?.find(w => w.name === "project_name");
const label = labelW?.value || "";
const project = projW?.value || "?";
node.title = label ? `Source: ${label} [${project}]` : `Project Source [${project}]`;
app.graph?.setDirtyCanvas(true, true);
}
// Fetch file list from API and update file_name combo // Fetch file list from API and update file_name combo
async function refreshFiles(node) { async function refreshFiles(node) {
const urlW = node.widgets?.find(w => w.name === "manager_url"); const urlW = node.widgets?.find(w => w.name === "manager_url");
@@ -84,23 +113,29 @@ app.registerExtension({
const node = this; const node = this;
// Hide project_name — it is auto-filled from the Manager's active project
const projW = this.widgets?.find(w => w.name === "project_name");
if (projW) {
if (projW.origType === undefined) projW.origType = projW.type;
projW.type = "hidden";
projW.hidden = true;
projW.computeSize = () => [0, -4];
}
// Replace file_name STRING with a combo // Replace file_name STRING with a combo
replaceWithCombo(this, "file_name", [], function (value) { replaceWithCombo(this, "file_name", [], function (value) {
notifyRelays(node); notifyRelays(node);
}); });
// Hook manager_url and project_name to refresh file list + notify relays // Hook manager_url to refresh active project + files + notify relays
for (const name of ["manager_url", "project_name"]) { const urlW = this.widgets?.find(w => w.name === "manager_url");
const w = this.widgets?.find(w => w.name === name); if (urlW) {
if (w) { const origCb = urlW.callback;
const origCb = w.callback; urlW.callback = function (...args) {
w.callback = function (...args) {
origCb?.apply(this, args); origCb?.apply(this, args);
refreshFiles(node); refreshActiveProject(node).then(() => notifyRelays(node));
notifyRelays(node);
}; };
} }
}
// Hook sequence_number to notify relays // Hook sequence_number to notify relays
const seqW = this.widgets?.find(w => w.name === "sequence_number"); const seqW = this.widgets?.find(w => w.name === "sequence_number");
@@ -118,22 +153,27 @@ app.registerExtension({
const origCallback = labelWidget.callback; const origCallback = labelWidget.callback;
labelWidget.callback = function (...args) { labelWidget.callback = function (...args) {
origCallback?.apply(this, args); origCallback?.apply(this, args);
node.title = labelWidget.value _updateTitle(node);
? `Source: ${labelWidget.value}`
: "Project Source";
app.graph?.setDirtyCanvas(true, true);
}; };
// Set initial title
if (labelWidget.value) {
this.title = `Source: ${labelWidget.value}`;
}
} }
// Auto-fetch active project on creation
queueMicrotask(() => refreshActiveProject(node));
}; };
const origOnConfigure = nodeType.prototype.onConfigure; const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) { nodeType.prototype.onConfigure = function (info) {
origOnConfigure?.apply(this, arguments); origOnConfigure?.apply(this, arguments);
// Hide project_name (may have been serialized as visible)
const projW = this.widgets?.find(w => w.name === "project_name");
if (projW) {
if (projW.origType === undefined) projW.origType = projW.type;
projW.type = "hidden";
projW.hidden = true;
projW.computeSize = () => [0, -4];
}
// Ensure file_name is a combo (may be STRING from serialization) // Ensure file_name is a combo (may be STRING from serialization)
const fileW = this.widgets?.find(w => w.name === "file_name"); const fileW = this.widgets?.find(w => w.name === "file_name");
if (fileW && fileW.type !== "combo") { if (fileW && fileW.type !== "combo") {
@@ -143,16 +183,18 @@ app.registerExtension({
}); });
} }
const labelWidget = this.widgets?.find(w => w.name === "label"); _updateTitle(this);
if (labelWidget?.value) {
this.title = `Source: ${labelWidget.value}`;
}
// Deferred: refresh file list once graph is ready // Deferred: fetch active project (and files) once graph is ready
const node = this; const node = this;
queueMicrotask(() => { queueMicrotask(() => refreshActiveProject(node));
refreshFiles(node); };
});
// Re-check active project on click (picks up changes made in the Manager)
const origOnMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) {
origOnMouseDown?.apply(this, arguments);
refreshActiveProject(this);
}; };
}, },
}); });