41 Commits

Author SHA1 Message Date
Ethanfel 3c5d2fc4e0 feat: configurable path replacements for ComfyUI Docker mount differences
Added 'Path Replacements' section in the Projects tab. Each entry is a
from→to string substitution applied to project_path output, fixing
casing mismatches between Docker containers (e.g. Davinci → davinci).
Stored in .editor_config.json under 'path_replacements'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 16:19:10 +02:00
Ethanfel e6d260eb1a fix: resolve project folder_path to actual filesystem casing
Uses resolve_path_case_insensitive so stored paths like '/Davinci/...'
are returned as '/davinci/...' if that's the real casing on disk.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 16:16:53 +02:00
Ethanfel 8dafee9f6d fix: read sw.value directly for thumbnail opacity — e.args is a list not bool 2026-04-04 14:49:34 +02:00
Ethanfel b405427a6b feat: dim frame thumbnail when its switch is toggled off
Thumbnail opacity is 1.0 when the frame switch is on, 0.25 when off.
Initial state reflects the current logic index bit, and updates live
on toggle without requiring a page refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:41:49 +02:00
Ethanfel c771fa3451 feat: split frame strength into high/low noise strength per frame
Each frame path now has two strength fields instead of one:
'start frame high strength', 'start frame low strength' (and same
for middle/end). Migration splits old single 'X frame strength' into
both new keys using the same value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:34:03 +02:00
Ethanfel 4b40d0f50c feat: remove cfg, flf, end_frame fields
These fields are no longer needed. Removed from DEFAULTS, UI widgets,
standard_keys set, and timeline preview. Added _migrate_remove_keys()
migration so existing JSON files are cleaned up transparently on load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:24:57 +02:00
Ethanfel 97c755316b fix: inject frame strength defaults in API so they appear in ProjectKey dropdown
start/middle/end frame strength are set via setdefault in the UI but
only persist after save. API now injects them with default 1.0 so
ProjectKey can use them immediately without requiring a save first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:21:32 +02:00
Ethanfel d1e32e5fc4 feat: resolution series, frame paths, live node values & UX improvements
Key features:
- 8 resolution slots with per-slot randomizable seed; ProjectResolution outputs seed
- Frame paths (start/middle/end) with strength float + logic index switch
- Logic index read-only, driven by frame path switches (binary bit-field)
- BinaryIndexDecoder node: INT index → 3 BOOLEAN outputs with live value display
- ProjectKey live value display on output dot (INT=blue, FLOAT=green, BOOL=green/grey)
- ProjectSource auto-fills active project from Manager, outputs project_path
- ProjectKey highlights sibling nodes sharing the same key_name on select
- Computed keys: start_name/middle_name/end_name (Path.stem of frame paths)
- Image preview via /api/image-preview endpoint; click-to-open dialog with raw <img>
- Import folder scans project folder_path; DB moved to project directory
- Key renames: reference path→middle frame path, flf image path→end frame path,
  reference image path→start frame path (with auto-migration in load_json)
- Timeline preview shows resolutions and custom fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:13:10 +02:00
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
13 changed files with 761 additions and 118 deletions
+75 -3
View File
@@ -9,10 +9,11 @@ from pathlib import Path
from typing import Any
from fastapi import HTTPException, Query
from fastapi.responses import FileResponse
from nicegui import app
from db import ProjectDB
from utils import load_json, KEY_BATCH_DATA, KEY_SEQUENCE_NUMBER
from utils import load_json, load_config, resolve_path_case_insensitive, KEY_BATCH_DATA, KEY_SEQUENCE_NUMBER
logger = logging.getLogger(__name__)
@@ -26,10 +27,13 @@ def register_api_routes(db: ProjectDB) -> None:
_db = db
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/{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}/keys", _get_keys, methods=["GET"])
app.add_api_route("/api/image-preview", _serve_image, methods=["GET"])
def _get_db() -> ProjectDB:
@@ -44,6 +48,30 @@ def _list_projects() -> dict[str, Any]:
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")
folder_path = proj["folder_path"]
resolved = resolve_path_case_insensitive(folder_path)
if resolved:
folder_path = str(resolved)
# Apply configured path replacements (e.g. Docker mount casing differences)
config = load_config()
for rep in config.get("path_replacements", []):
src, dst = rep.get("from", ""), rep.get("to", "")
if src:
folder_path = folder_path.replace(src, dst)
return {"name": proj["name"], "folder_path": folder_path,
"description": proj.get("description", "")}
def _list_files(name: str) -> dict[str, Any]:
db = _get_db()
files = db.list_project_files(name)
@@ -75,9 +103,28 @@ def _get_data(name: str, file_name: str, seq: int = Query(default=1)) -> dict[st
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")
result = dict(match)
# Inject strength defaults if not yet saved to JSON
for key, default in (
("start frame high strength", 1.0),
("start frame low strength", 1.0),
("middle frame high strength", 1.0),
("middle frame low strength", 1.0),
("end frame high strength", 1.0),
("end frame low strength", 1.0),
):
result.setdefault(key, default)
# Computed stem names from frame paths
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",
name, file_name, seq, len(match), time.perf_counter() - t0)
return match
name, file_name, seq, len(result), time.perf_counter() - t0)
return result
def _get_keys(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]:
@@ -98,7 +145,32 @@ def _get_keys(name: str, file_name: str, seq: int = Query(default=1)) -> dict[st
types.append("FLOAT")
else:
types.append("STRING")
# Injected defaults — always present even if not yet saved to JSON
for key in (
"start frame high strength", "start frame low strength",
"middle frame high strength", "middle frame low strength",
"end frame high strength", "end frame low strength",
):
if key not in match:
keys.append(key)
types.append("FLOAT")
# 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",
name, file_name, seq, len(keys), time.perf_counter() - t0)
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__)
DEFAULT_DB_PATH = Path.home() / ".comfyui_json_manager" / "projects.db"
DEFAULT_DB_PATH = Path(__file__).parent / "projects.db"
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS projects (
@@ -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
```
+62 -7
View File
@@ -67,6 +67,13 @@ def _fetch_json(url: str) -> dict:
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:
"""Fetch sequence data from the NiceGUI REST API."""
p = urllib.parse.quote(project, safe='')
@@ -221,14 +228,24 @@ class ProjectSource:
},
}
RETURN_TYPES = ("INT", "STRING",)
RETURN_NAMES = ("sequence_number", "file_name",)
RETURN_TYPES = ("INT", "STRING", "STRING")
RETURN_NAMES = ("sequence_number", "file_name", "project_path")
FUNCTION = "hold_config"
CATEGORY = "JSON Manager/project"
OUTPUT_NODE = True
def hold_config(self, manager_url, project_name, file_name, sequence_number, label):
return (sequence_number, file_name,)
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:
@@ -282,13 +299,15 @@ class ProjectKey:
val = data.get(key_name, "")
if key_type == "INT":
return (to_int(val),)
result = to_int(val)
return {"ui": {"value": [str(result)]}, "result": (result,)}
elif key_type == "FLOAT":
return (to_float(val),)
result = to_float(val)
return {"ui": {"value": [f"{result:.4g}"]}, "result": (result,)}
elif isinstance(val, bool):
return (str(val).lower(),)
return {"ui": {"value": [str(val).lower()]}, "result": (str(val).lower(),)}
elif isinstance(val, (int, float)):
return (val,)
return {"ui": {"value": [str(val)]}, "result": (val,)}
else:
return (str(val),)
@@ -349,12 +368,47 @@ class ProjectResolution:
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 ---
PROJECT_NODE_CLASS_MAPPINGS = {
"ProjectLoaderDynamic": ProjectLoaderDynamic,
"ProjectSource": ProjectSource,
"ProjectKey": ProjectKey,
"ProjectResolution": ProjectResolution,
"BinaryIndexDecoder": BinaryIndexDecoder,
}
PROJECT_NODE_DISPLAY_NAME_MAPPINGS = {
@@ -362,4 +416,5 @@ PROJECT_NODE_DISPLAY_NAME_MAPPINGS = {
"ProjectSource": "Project Source",
"ProjectKey": "Project Key",
"ProjectResolution": "Project Resolution",
"BinaryIndexDecoder": "Binary Index Decoder",
}
+90 -54
View File
@@ -6,6 +6,7 @@ import math
import random
import time
from pathlib import Path
from urllib.parse import quote
from nicegui import ui
@@ -313,10 +314,13 @@ def render_batch_processor(state: AppState):
'lora 3 high', 'lora 3 high strength', 'lora 3 low', 'lora 3 low strength']
standard_keys = {
'name', 'mode', 'general_prompt', 'general_negative', 'current_prompt', 'negative', 'prompt',
'seed', 'cfg', 'camera', 'flf', KEY_SEQUENCE_NUMBER,
'frame_to_skip', 'end_frame', 'transition', 'vace_length',
'seed', 'camera', KEY_SEQUENCE_NUMBER,
'frame_to_skip', 'logic index', 'transition', 'vace_length',
'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 high strength', 'start frame low strength',
'middle frame path', 'middle frame high strength', 'middle frame low strength',
'end frame path', 'end frame high strength', 'end frame low strength',
'video file path',
}
standard_keys.update(lora_keys)
@@ -542,6 +546,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
ui.separator()
# --- 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 splitter.before:
dict_textarea('General Prompt', seq, 'general_prompt').classes(
@@ -553,15 +558,95 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
dict_textarea('Specific Negative', seq, 'negative').classes(
'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, hi_key, lo_key in [
(0, 'Start Frame', 'start frame path', 'start frame high strength', 'start frame low strength'),
(1, 'Middle Frame', 'middle frame path', 'middle frame high strength', 'middle frame low strength'),
(2, 'End Frame', 'end frame path', 'end frame high strength', 'end frame low strength'),
]:
ui.label(img_label).classes('text-caption text-weight-bold q-mt-sm')
is_on = bool((logic_val >> bit) & 1)
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"')
thumb = None
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">')
thumb = 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;'
f'opacity:{"1.0" if is_on else "0.25"}">'
).on('click', img_dlg.open)
sw = ui.switch(value=is_on)
frame_switches.append(sw)
if thumb is not None:
sw.on('update:model-value',
lambda e, t=thumb, s=sw: t.style(f'opacity: {"1.0" if s.value else "0.25"}'))
with ui.row().classes('w-full no-wrap q-mt-xs q-gutter-xs'):
dict_number('High', seq, hi_key, default=1.0,
step=0.05, format='%.2f').classes('col').props('outlined dense')
dict_number('Low', seq, lo_key, default=1.0,
step=0.05, format='%.2f').classes('col').props('outlined dense')
with splitter.after:
# Mode
dict_number('Mode', seq, 'mode').props('outlined').classes('w-full')
# Sequence number
sn_label = (
f'Seq Number (Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)})'
if is_subsegment(seq_num) else 'Sequence Number'
)
sn_input = dict_number(sn_label, seq, KEY_SEQUENCE_NUMBER)
sn_input.props('outlined').classes('w-full')
# Seed + randomize
with ui.row().classes('w-full items-end'):
seed_input = dict_number('Seed', seq, 'seed').classes('col').props('outlined')
def randomize_seed(si=seed_input, s=seq):
new_seed = random.randint(0, 999999999999)
si.set_value(new_seed)
s['seed'] = new_seed
ui.button(icon='casino', on_click=randomize_seed).props('flat')
dict_input(ui.input, 'Camera', seq, 'camera').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(
'outlined input-style="text-align: right"').classes('w-full')
# Switches → logic index (sole writer)
def _sync_switches_to_logic(li=li_input, switches=frame_switches, s=seq):
v = sum(int(sw.value) << b for b, sw in enumerate(switches))
s['logic index'] = v
li.set_value(v)
for frame_sw in frame_switches:
frame_sw.on('update:model-value', lambda _, s=_sync_switches_to_logic: s())
# --- Resolutions (8 fixed slots) ---
ui.label('Resolutions').classes('text-caption text-weight-bold q-mt-md')
resolutions = seq.setdefault('resolutions', [])
while len(resolutions) < 8:
resolutions.append([512, 512, 0])
# Migrate old [w, h] entries to [w, h, seed] (persisted on next real save)
for r_i in range(len(resolutions)):
if len(resolutions[r_i]) < 3:
resolutions[r_i] = list(resolutions[r_i]) + [0]
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'):
@@ -596,55 +681,6 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
seed_inp.on('blur', lambda _, s=_sync_entry: s())
seed_inp.on('update:model-value', lambda _, s=_sync_entry: s())
with splitter.after:
# Mode
dict_number('Mode', seq, 'mode').props('outlined').classes('w-full')
# Sequence number
sn_label = (
f'Seq Number (Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)})'
if is_subsegment(seq_num) else 'Sequence Number'
)
sn_input = dict_number(sn_label, seq, KEY_SEQUENCE_NUMBER)
sn_input.props('outlined').classes('w-full')
# Seed + randomize
with ui.row().classes('w-full items-end'):
seed_input = dict_number('Seed', seq, 'seed').classes('col').props('outlined')
def randomize_seed(si=seed_input, s=seq):
new_seed = random.randint(0, 999999999999)
si.set_value(new_seed)
s['seed'] = new_seed
ui.button(icon='casino', on_click=randomize_seed).props('flat')
# CFG
dict_number('CFG', seq, 'cfg', default=DEFAULTS['cfg'],
step=0.5, format='%.1f').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_number('End Frame', seq, 'end_frame').props('outlined').classes('w-full')
dict_input(ui.input, 'Video File Path', seq, 'video file path').props(
'outlined input-style="direction: rtl"').classes('w-full')
# Image paths with preview
for img_label, img_key in [
('Reference Image Path', 'reference image path'),
('Reference Path', 'reference path'),
('FLF Image Path', 'flf image path'),
]:
with ui.row().classes('w-full items-center'):
inp = dict_input(ui.input, img_label, seq, img_key).classes(
'col').props('outlined input-style="direction: rtl"')
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):
with ui.dialog() as dlg, ui.card():
ui.image(str(img_path)).classes('w-full')
ui.button(icon='visibility', on_click=dlg.open).props('flat dense')
# --- VACE Settings (full width) ---
with ui.expansion('VACE Settings', icon='settings').classes('w-full'):
_render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_list)
+46 -2
View File
@@ -59,6 +59,48 @@ def render_projects_tab(state: AppState):
ui.button('Create Project', icon='add', on_click=create_project).classes('w-full')
# --- Path replacements (for ComfyUI Docker path differences) ---
with ui.card().classes('w-full q-pa-md q-mb-md'):
ui.label('ComfyUI Path Replacements').classes('section-header')
ui.label('Applied to project_path output — use to fix Docker mount casing differences.'
).classes('text-caption q-mb-sm')
replacements: list[dict] = state.config.get('path_replacements', [])
@ui.refreshable
def render_replacements():
for idx, rep in enumerate(replacements):
with ui.row().classes('w-full items-center no-wrap q-gutter-xs'):
ui.input('From', value=rep.get('from', '')).classes('col').props(
'outlined dense').on('update:model-value',
lambda e, i=idx: _update_replacement(i, 'from', e.args))
ui.label('').classes('text-caption')
ui.input('To', value=rep.get('to', '')).classes('col').props(
'outlined dense').on('update:model-value',
lambda e, i=idx: _update_replacement(i, 'to', e.args))
ui.button(icon='delete', on_click=lambda i=idx: _remove_replacement(i)
).props('flat dense color=negative')
def _update_replacement(idx, field, value):
replacements[idx][field] = value
state.config['path_replacements'] = replacements
save_config(state.current_dir, state.config.get('favorites', []), state.config)
def _remove_replacement(idx):
replacements.pop(idx)
state.config['path_replacements'] = replacements
save_config(state.current_dir, state.config.get('favorites', []), state.config)
render_replacements.refresh()
def _add_replacement():
replacements.append({'from': '', 'to': ''})
state.config['path_replacements'] = replacements
save_config(state.current_dir, state.config.get('favorites', []), state.config)
render_replacements.refresh()
render_replacements()
ui.button('Add Replacement', icon='add', on_click=_add_replacement).props('flat dense')
# --- Active project indicator ---
# Fetch once with file counts and reuse in render_project_list
_cached_projects = state.db.list_projects_with_file_counts()
@@ -216,8 +258,10 @@ def render_projects_tab(state: AppState):
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."""
json_files = sorted(state.current_dir.glob('*.json'))
"""Bulk import all .json files from the project's folder_path into a project."""
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 (
'.editor_config.json', '.editor_snippets.json')]
+3 -3
View File
@@ -577,7 +577,6 @@ def _render_preview_fields(item_data: dict):
with ui.row().classes('w-full q-gutter-md'):
ui.input('Camera', value=str(item_data.get('camera', 'static'))).props('readonly outlined')
ui.input('FLF', value=str(item_data.get('flf', '0.0'))).props('readonly outlined')
ui.input('Seed', value=str(item_data.get('seed', '-1'))).props('readonly outlined')
with ui.expansion('LoRA Configuration'):
@@ -617,8 +616,9 @@ def _render_preview_fields(item_data: dict):
known_keys = {
'sequence_number', 'general_prompt', 'general_negative', 'current_prompt', 'prompt',
'negative', 'camera', 'flf', 'seed', 'resolutions',
'frame_to_skip', 'vace schedule', 'video file path',
'negative', 'camera', '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 = [
+52 -2
View File
@@ -447,6 +447,55 @@ class TestProjectResolution:
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:
def test_mappings_exist(self):
from project_loader import PROJECT_NODE_CLASS_MAPPINGS, PROJECT_NODE_DISPLAY_NAME_MAPPINGS
@@ -454,5 +503,6 @@ class TestNodeMappings:
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
assert "BinaryIndexDecoder" in PROJECT_NODE_CLASS_MAPPINGS
assert len(PROJECT_NODE_CLASS_MAPPINGS) == 5
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 5
+43 -6
View File
@@ -28,16 +28,14 @@ DEFAULTS = {
"current_prompt": "",
"negative": "",
"seed": -1,
"cfg": 1.5,
# --- Settings ---
"mode": 0,
"camera": "static",
"flf": 0.0,
# --- I2V / VACE Specifics ---
"frame_to_skip": 81,
"end_frame": 0,
"logic index": 0,
"transition": "1-2",
"vace_length": 49,
"vace schedule": 1,
@@ -45,9 +43,15 @@ DEFAULTS = {
"input_b_frames": 16,
"reference switch": 1,
"video file path": "",
"reference image path": "",
"reference path": "",
"flf image path": "",
"start frame path": "",
"start frame high strength": 1.0,
"start frame low strength": 1.0,
"middle frame path": "",
"middle frame high strength": 1.0,
"middle frame low strength": 1.0,
"end frame path": "",
"end frame high strength": 1.0,
"end frame low strength": 1.0,
# --- LoRAs (name as STRING, strength as FLOAT) ---
"lora 1 high": "",
@@ -150,6 +154,37 @@ def save_snippets(snippets):
json.dump(snippets, f, indent=4)
os.replace(tmp, SNIPPETS_FILE)
_REMOVED_KEYS = {"cfg", "flf", "end_frame"}
def _migrate_remove_keys(data: dict) -> None:
"""Drop keys that have been removed from the schema."""
for item in data.get(KEY_BATCH_DATA, []):
if not isinstance(item, dict):
continue
for k in _REMOVED_KEYS:
item.pop(k, None)
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')
# Split old single strength into high+low
for prefix in ('start frame', 'middle frame', 'end frame'):
old_key = f'{prefix} strength'
if old_key in item:
val = item.pop(old_key)
item.setdefault(f'{prefix} high strength', val)
item.setdefault(f'{prefix} low strength', val)
def _migrate_lora_keys(data: dict) -> None:
"""Split combined lora 'name:strength' into separate name and strength keys.
@@ -208,6 +243,8 @@ def load_json(path: str | Path) -> tuple[dict[str, Any], float]:
with open(path, 'r') as f:
data = json.load(f)
t1 = time.time()
_migrate_remove_keys(data)
_migrate_key_renames(data)
_migrate_lora_keys(data)
t2 = time.time()
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);
};
// --- 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) ---
const origOnMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) {
+67 -25
View File
@@ -28,6 +28,35 @@ app.registerExtension({
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
async function refreshFiles(node) {
const urlW = node.widgets?.find(w => w.name === "manager_url");
@@ -84,23 +113,29 @@ app.registerExtension({
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
replaceWithCombo(this, "file_name", [], function (value) {
notifyRelays(node);
});
// Hook manager_url and project_name to refresh file list + notify relays
for (const name of ["manager_url", "project_name"]) {
const w = this.widgets?.find(w => w.name === name);
if (w) {
const origCb = w.callback;
w.callback = function (...args) {
// Hook manager_url to refresh active project + files + notify relays
const urlW = this.widgets?.find(w => w.name === "manager_url");
if (urlW) {
const origCb = urlW.callback;
urlW.callback = function (...args) {
origCb?.apply(this, args);
refreshFiles(node);
notifyRelays(node);
refreshActiveProject(node).then(() => notifyRelays(node));
};
}
}
// Hook sequence_number to notify relays
const seqW = this.widgets?.find(w => w.name === "sequence_number");
@@ -118,22 +153,27 @@ app.registerExtension({
const origCallback = labelWidget.callback;
labelWidget.callback = function (...args) {
origCallback?.apply(this, args);
node.title = labelWidget.value
? `Source: ${labelWidget.value}`
: "Project Source";
app.graph?.setDirtyCanvas(true, true);
_updateTitle(node);
};
// 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;
nodeType.prototype.onConfigure = function (info) {
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)
const fileW = this.widgets?.find(w => w.name === "file_name");
if (fileW && fileW.type !== "combo") {
@@ -143,16 +183,18 @@ app.registerExtension({
});
}
const labelWidget = this.widgets?.find(w => w.name === "label");
if (labelWidget?.value) {
this.title = `Source: ${labelWidget.value}`;
}
_updateTitle(this);
// Deferred: refresh file list once graph is ready
// Deferred: fetch active project (and files) once graph is ready
const node = this;
queueMicrotask(() => {
refreshFiles(node);
});
queueMicrotask(() => refreshActiveProject(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);
};
},
});