Compare commits
41 Commits
20be3204b3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c5d2fc4e0 | |||
| e6d260eb1a | |||
| 8dafee9f6d | |||
| b405427a6b | |||
| c771fa3451 | |||
| 4b40d0f50c | |||
| 97c755316b | |||
| d1e32e5fc4 | |||
| c252d0b4e3 | |||
| bc61033826 | |||
| 4b19ad0a1d | |||
| b3d7c3868d | |||
| 5d2f3bbf4f | |||
| 3b700b099b | |||
| 91241b787c | |||
| e9056457cd | |||
| 5c90a59d7e | |||
| 111b37dc8d | |||
| f857485bc8 | |||
| 410c80afc8 | |||
| 2277e6e427 | |||
| 3065dd7e71 | |||
| 783da171e7 | |||
| 783f07e57a | |||
| c7ca3ae277 | |||
| f376fd5622 | |||
| fec843f804 | |||
| 2619d2c7e2 | |||
| 03dcb1c13a | |||
| 9ffdf6287d | |||
| 735d905833 | |||
| 932295ed27 | |||
| a5da8b26f4 | |||
| 5bc2838b21 | |||
| a7a4794adb | |||
| d33ce4da38 | |||
| 4fe9a9c958 | |||
| 820cb426aa | |||
| b75b177591 | |||
| c8cc397cc6 | |||
| f0e785afab |
+75
-3
@@ -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))
|
||||
|
||||
@@ -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 | 0–7 |
|
||||
|
||||
### 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 (0–7) produce correct boolean tuple
|
||||
- Out-of-range index (e.g. 8) clamps to 0–7 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
@@ -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
@@ -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
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user