4 Commits

Author SHA1 Message Date
Ethanfel 0687ee651e feat: ProjectResolution JS extension for ComfyUI frontend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:36:48 +02:00
Ethanfel 8b8cb29575 feat: resolution series editor in sequence card
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:35:03 +02:00
Ethanfel 4243f703e6 feat: add ProjectResolution node
Implements ProjectResolution with TDD: fetches a [width, height] pair
from a resolution series by loop index, clamping out-of-bounds indices
to the last entry and returning (512, 512) defaults on error or missing key.
Also registers the node in mappings and updates TestNodeMappings count to 4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:28:21 +02:00
Ethanfel bb2952afcf fix: update ProjectSource tests for file_name output 2026-04-03 00:22:54 +02:00
21 changed files with 861 additions and 2564 deletions
+18 -107
View File
@@ -1,19 +1,16 @@
"""REST API endpoints for ComfyUI to query project data from JSON files. """REST API endpoints for ComfyUI to query project data from SQLite.
All endpoints are read-only. Mounted on the NiceGUI/FastAPI server. All endpoints are read-only. Mounted on the NiceGUI/FastAPI server.
""" """
import logging import logging
import time import time
from pathlib import Path
from typing import Any from typing import Any
from fastapi import HTTPException, Query from fastapi import HTTPException, Query
from fastapi.responses import FileResponse
from nicegui import app from nicegui import app
from db import ProjectDB from db import ProjectDB
from utils import load_json, load_config, resolve_path_case_insensitive, KEY_BATCH_DATA, KEY_SEQUENCE_NUMBER
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,13 +24,10 @@ def register_api_routes(db: ProjectDB) -> None:
_db = db _db = db
app.add_api_route("/api/projects", _list_projects, methods=["GET"]) app.add_api_route("/api/projects", _list_projects, methods=["GET"])
app.add_api_route("/api/active-project", _get_active_project, methods=["GET"])
app.add_api_route("/api/projects/{name}", _get_project, methods=["GET"])
app.add_api_route("/api/projects/{name}/files", _list_files, methods=["GET"]) app.add_api_route("/api/projects/{name}/files", _list_files, methods=["GET"])
app.add_api_route("/api/projects/{name}/files/{file_name}/sequences", _list_sequences, methods=["GET"]) app.add_api_route("/api/projects/{name}/files/{file_name}/sequences", _list_sequences, methods=["GET"])
app.add_api_route("/api/projects/{name}/files/{file_name}/data", _get_data, methods=["GET"]) app.add_api_route("/api/projects/{name}/files/{file_name}/data", _get_data, methods=["GET"])
app.add_api_route("/api/projects/{name}/files/{file_name}/keys", _get_keys, methods=["GET"]) app.add_api_route("/api/projects/{name}/files/{file_name}/keys", _get_keys, methods=["GET"])
app.add_api_route("/api/image-preview", _serve_image, methods=["GET"])
def _get_db() -> ProjectDB: def _get_db() -> ProjectDB:
@@ -48,30 +42,6 @@ def _list_projects() -> dict[str, Any]:
return {"projects": [p["name"] for p in projects]} return {"projects": [p["name"] for p in projects]}
def _get_active_project() -> dict[str, Any]:
config = load_config()
return {"project": config.get("current_project", "")}
def _get_project(name: str) -> dict[str, Any]:
db = _get_db()
proj = db.get_project(name)
if not proj:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
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]: def _list_files(name: str) -> dict[str, Any]:
db = _get_db() db = _get_db()
files = db.list_project_files(name) files = db.list_project_files(name)
@@ -84,93 +54,34 @@ def _list_sequences(name: str, file_name: str) -> dict[str, Any]:
return {"sequences": seqs} return {"sequences": seqs}
def _load_sequences(name: str, file_name: str) -> list[dict]: def _get_data(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]:
"""Load the batch_data list directly from the JSON file.""" t0 = time.perf_counter()
db = _get_db() db = _get_db()
proj = db.get_project(name) proj = db.get_project(name)
if not proj: if not proj:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found") raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
json_path = Path(proj["folder_path"]) / f"{file_name}.json" df = db.get_data_file_by_names(name, file_name)
if not json_path.exists(): if not df:
raise HTTPException(status_code=404, detail=f"File '{file_name}' not found in project '{name}'") raise HTTPException(status_code=404, detail=f"File '{file_name}' not found in project '{name}'")
data, _ = load_json(json_path) data = db.get_sequence(df["id"], seq)
return data.get(KEY_BATCH_DATA, []) if data is None:
def _get_data(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]:
t0 = time.perf_counter()
sequences = _load_sequences(name, file_name)
match = next((s for s in sequences if int(s.get(KEY_SEQUENCE_NUMBER, 0)) == seq), None)
if match is None:
raise HTTPException(status_code=404, detail=f"Sequence {seq} not found") raise HTTPException(status_code=404, detail=f"Sequence {seq} not found")
result = dict(match)
# 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", logger.info("API _get_data %s/%s seq=%d (%d keys): %.3fs",
name, file_name, seq, len(result), time.perf_counter() - t0) name, file_name, seq, len(data), time.perf_counter() - t0)
return result return data
def _get_keys(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]: def _get_keys(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]:
t0 = time.perf_counter() t0 = time.perf_counter()
sequences = _load_sequences(name, file_name) db = _get_db()
match = next((s for s in sequences if int(s.get(KEY_SEQUENCE_NUMBER, 0)) == seq), None) proj = db.get_project(name)
if match is None: if not proj:
raise HTTPException(status_code=404, detail=f"Sequence {seq} not found") raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
keys = [k for k in match.keys() if k != KEY_SEQUENCE_NUMBER] df = db.get_data_file_by_names(name, file_name)
types = [] if not df:
for k in keys: raise HTTPException(status_code=404, detail=f"File '{file_name}' not found in project '{name}'")
v = match[k] keys, types = db.get_sequence_keys(df["id"], seq)
if isinstance(v, bool): total = db.count_sequences(df["id"])
types.append("BOOLEAN")
elif isinstance(v, int):
types.append("INT")
elif isinstance(v, float):
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", logger.info("API _get_keys %s/%s seq=%d (%d keys): %.3fs",
name, file_name, seq, len(keys), time.perf_counter() - t0) name, file_name, seq, len(keys), time.perf_counter() - t0)
return {"keys": keys, "types": types, "total_sequences": total} return {"keys": keys, "types": types, "total_sequences": total}
def _serve_image(path: str = Query(...)) -> FileResponse:
p = Path(path)
if not p.exists() or not p.is_file():
raise HTTPException(status_code=404, detail="Image not found")
return FileResponse(str(p))
+22 -34
View File
@@ -9,7 +9,7 @@ from utils import load_json, KEY_BATCH_DATA, KEY_HISTORY_TREE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_DB_PATH = Path(__file__).parent / "projects.db" DEFAULT_DB_PATH = Path.home() / ".comfyui_json_manager" / "projects.db"
SCHEMA_SQL = """ SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS projects ( CREATE TABLE IF NOT EXISTS projects (
@@ -242,6 +242,7 @@ class ProjectDB:
) )
self.conn.commit() self.conn.commit()
@staticmethod
@staticmethod @staticmethod
def _migrate_lora_keys(data: dict) -> dict: def _migrate_lora_keys(data: dict) -> dict:
"""Split combined lora 'name:strength' into separate name and strength keys.""" """Split combined lora 'name:strength' into separate name and strength keys."""
@@ -339,34 +340,27 @@ class ProjectDB:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def save_history_tree(self, data_file_id: int, tree_data: dict) -> None: def save_history_tree(self, data_file_id: int, tree_data: dict) -> None:
"""Save history tree, extracting snapshot data into separate table. """Save history tree, extracting node snapshots into separate table."""
Supports both new format (snapshots dict) and old format (nodes dict).
"""
now = time.time() now = time.time()
if "snapshots" in tree_data: nodes = tree_data.get("nodes", {})
entries = tree_data.get("snapshots", {})
entry_key = "snapshots"
else:
entries = tree_data.get("nodes", {})
entry_key = "nodes"
slim_tree = dict(tree_data) slim_tree = dict(tree_data)
slim_entries = {} slim_nodes = {}
for eid, entry in entries.items(): for nid, node in nodes.items():
slim_entries[eid] = {k: v for k, v in entry.items() if k != "data"} slim_nodes[nid] = {k: v for k, v in node.items() if k != "data"}
slim_tree[entry_key] = slim_entries slim_tree["nodes"] = slim_nodes
self.conn.execute("BEGIN IMMEDIATE") self.conn.execute("BEGIN IMMEDIATE")
try: try:
for eid, entry in entries.items(): # Extract snapshot data from nodes into history_snapshots table
snap = entry.get("data") for nid, node in nodes.items():
snap = node.get("data")
if snap: if snap:
self.conn.execute( self.conn.execute(
"INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) " "INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) "
"VALUES (?, ?, ?, ?) " "VALUES (?, ?, ?, ?) "
"ON CONFLICT(data_file_id, node_id) DO UPDATE SET " "ON CONFLICT(data_file_id, node_id) DO UPDATE SET "
"snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at", "snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at",
(data_file_id, eid, json.dumps(snap), now), (data_file_id, nid, json.dumps(snap), now),
) )
self.conn.execute( self.conn.execute(
"INSERT INTO history_trees (data_file_id, tree_data, updated_at) " "INSERT INTO history_trees (data_file_id, tree_data, updated_at) "
@@ -469,30 +463,24 @@ class ProjectDB:
) )
# Import history tree (extract snapshots into separate table) # Import history tree (extract snapshots into separate table)
# Supports both new format (snapshots dict) and old format (nodes dict)
history_tree = data.get(KEY_HISTORY_TREE) history_tree = data.get(KEY_HISTORY_TREE)
if history_tree and isinstance(history_tree, dict): if history_tree and isinstance(history_tree, dict):
now = time.time() now = time.time()
if "snapshots" in history_tree: nodes = history_tree.get("nodes", {})
entries = history_tree.get("snapshots", {})
entry_key = "snapshots"
else:
entries = history_tree.get("nodes", {})
entry_key = "nodes"
slim_tree = dict(history_tree) slim_tree = dict(history_tree)
slim_entries = {} slim_nodes = {}
for eid, entry in entries.items(): for nid, node in nodes.items():
snap = entry.get("data") snap = node.get("data")
if snap: if snap:
self.conn.execute( self.conn.execute(
"INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) " "INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) "
"VALUES (?, ?, ?, ?) " "VALUES (?, ?, ?, ?) "
"ON CONFLICT(data_file_id, node_id) DO UPDATE SET " "ON CONFLICT(data_file_id, node_id) DO UPDATE SET "
"snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at", "snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at",
(df_id, eid, json.dumps(snap), now), (df_id, nid, json.dumps(snap), now),
) )
slim_entries[eid] = {k: v for k, v in entry.items() if k != "data"} slim_nodes[nid] = {k: v for k, v in node.items() if k != "data"}
slim_tree[entry_key] = slim_entries slim_tree["nodes"] = slim_nodes
self.conn.execute( self.conn.execute(
"INSERT INTO history_trees (data_file_id, tree_data, updated_at) " "INSERT INTO history_trees (data_file_id, tree_data, updated_at) "
"VALUES (?, ?, ?) " "VALUES (?, ?, ?) "
@@ -552,9 +540,9 @@ class ProjectDB:
# Load history tree (metadata only, no snapshot data) # Load history tree (metadata only, no snapshot data)
tree = self.get_history_tree(df["id"]) tree = self.get_history_tree(df["id"])
if tree: if tree:
# Strip any residual snapshot data (supports both formats) # Strip any residual snapshot data from nodes
for entry in tree.get("snapshots", tree.get("nodes", {})).values(): for node in tree.get("nodes", {}).values():
entry.pop("data", None) node.pop("data", None)
data["history_tree"] = tree data["history_tree"] = tree
t3 = time.time() t3 = time.time()
@@ -1,81 +0,0 @@
# Resolution Series Design
## Problem
When running ComfyUI loop nodes for multi-step upscaling (e.g. 3+ resolutions at different sizes),
managing portrait vs landscape width/height per iteration is tedious. Users need a structured way
to define N resolution pairs in the manager UI and retrieve them by loop index in ComfyUI.
## Design
### Data Model
Resolution series are stored as a JSON array under a user-chosen key in the sequence data:
```json
"upscale_resolutions": [[512, 512], [768, 1344], [1344, 768], [2048, 2048]]
```
- Each element is `[width, height]` (both INT)
- Key name is chosen by the user (any string)
- Number of entries is configurable (add/remove rows)
- Stored in the same project JSON file and sequence — no schema change required
- Index out of bounds → clamp to last entry
### NiceGUI UI (tab_batch_ng.py)
A resolution series editor is rendered in the left column of the sequence card, directly below
the "Specific Negative" textarea.
Layout:
```
── Resolution Series ──────────────────
key name: [upscale_resolutions ]
# Width Height
1 [2048] [2048] [x]
2 [768 ] [1344] [x]
3 [1344] [768 ] [x]
[+ Add row]
```
- Key name is editable (defaults to `resolutions`)
- Rows added/removed inline; each change calls `commit()` immediately
- Hidden behind an "Add Resolution Series" button when no resolution key exists yet
- A value is detected as a resolution series if it is a list of `[int, int]` pairs
### ComfyUI Node (`ProjectResolution`)
New node class in `project_loader.py`, sibling to `ProjectKey`.
**Inputs:**
- `source_label` (STRING) — references a `ProjectSource` by label
- `key_name` (STRING) — the resolution series key name
- `index` (INT, min 0) — wired from loop node's current index output
- `manager_url`, `project_name`, `file_name`, `sequence_number` — optional, synced from `ProjectSource` via JS
**Outputs:** `width` (INT), `height` (INT)
**Execution:** fetches the sequence data, reads `data[key_name]`, indexes into the array with
clamp-to-last on out-of-bounds, returns `(width, height)`.
**JS (`web/project_resolution.js`):**
- Same `_syncFromSource` mechanism as `project_key.js`
- `key_name` widget is replaced with a combo dropdown populated with keys whose value is a
resolution series (list of `[int, int]` pairs), detected via the existing keys API
- Registered in `PROJECT_NODE_CLASS_MAPPINGS` and `PROJECT_NODE_DISPLAY_NAME_MAPPINGS`
### API
No new endpoints. Uses existing:
- `/json_manager/get_project_keys` — for key discovery (JS combo population)
- `_fetch_data()` — for execution-time data fetch
### Files Changed
| File | Change |
|------|--------|
| `project_loader.py` | Add `ProjectResolution` class + register in mappings |
| `web/project_resolution.js` | New JS extension for the node |
| `tab_batch_ng.py` | Resolution series editor below Specific Negative |
| `__init__.py` | Register new JS file if needed |
@@ -1,640 +0,0 @@
# Resolution Series Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a `ProjectResolution` ComfyUI node and NiceGUI editor that let users define N `(width, height)` pairs per sequence and retrieve them by loop index.
**Architecture:** Resolution series are stored as a JSON array of `[width, height]` pairs under a user-chosen key in sequence data (e.g. `"upscale_resolutions": [[512,512],[768,1344]]`). A new `ProjectResolution` ComfyUI node (sibling of `ProjectKey`) accepts a `source_label`, `key_name`, and `index` INT from a loop node, and returns `width` + `height`. The NiceGUI sequence card gets an inline table editor placed directly below the "Specific Negative" textarea.
**Tech Stack:** Python (ComfyUI node), NiceGUI (UI), JavaScript (ComfyUI frontend extension), pytest
**Branch:** Create and work on `feat/resolution-series` branched from `main`:
```bash
git checkout main && git checkout -b feat/resolution-series
```
---
### Task 0: Fix pre-existing test failures on `main`
When `file_name` was added as a second output to `ProjectSource`, two tests were not updated.
They fail on `main` before any new code is written.
**Files:**
- Modify: `tests/test_project_loader.py` (`TestProjectSource` class, lines ~216-231)
**Step 1: Update the two broken tests**
```python
def test_outputs_sequence_number(self):
from project_loader import ProjectSource
assert ProjectSource.RETURN_TYPES == ("INT", "STRING",)
assert ProjectSource.RETURN_NAMES == ("sequence_number", "file_name",)
def test_hold_config_returns_sequence_number(self):
from project_loader import ProjectSource
node = ProjectSource()
result = node.hold_config(
manager_url="http://localhost:8080",
project_name="proj1",
file_name="batch_i2v",
sequence_number=42,
label="my_source"
)
assert result == (42, "batch_i2v")
```
**Step 2: Verify they now pass**
```bash
pytest tests/test_project_loader.py::TestProjectSource -v
```
Expected: all 4 PASS
**Step 3: Commit**
```bash
git add tests/test_project_loader.py
git commit -m "fix: update ProjectSource tests for file_name output"
```
---
### Task 1: Python node — `ProjectResolution`
**Files:**
- Modify: `project_loader.py` (after the `ProjectKey` class, before `# --- Mappings ---`)
- Modify: `tests/test_project_loader.py` (add `TestProjectResolution` class)
**Step 1: Write failing tests**
Add this class to `tests/test_project_loader.py`:
```python
class TestProjectResolution:
def test_input_types(self):
from project_loader import ProjectResolution
inputs = ProjectResolution.INPUT_TYPES()
assert "source_label" in inputs["required"]
assert "key_name" in inputs["required"]
assert "index" in inputs["required"]
assert inputs["required"]["index"][0] == "INT"
def test_two_outputs(self):
from project_loader import ProjectResolution
assert ProjectResolution.RETURN_TYPES == ("INT", "INT")
assert ProjectResolution.RETURN_NAMES == ("width", "height")
def test_fetch_resolution_basic(self):
from project_loader import ProjectResolution
node = ProjectResolution()
data = {"resolutions": [[512, 512], [768, 1344], [1344, 768]]}
with patch("project_loader._fetch_data", return_value=data):
result = node.fetch_resolution(
source_label="src", key_name="resolutions", index=1,
manager_url="http://localhost:8080", project_name="p",
file_name="f", sequence_number=1,
)
assert result == (768, 1344)
def test_fetch_resolution_index_zero(self):
from project_loader import ProjectResolution
node = ProjectResolution()
data = {"resolutions": [[512, 512], [1024, 1024]]}
with patch("project_loader._fetch_data", return_value=data):
result = node.fetch_resolution(
source_label="src", key_name="resolutions", index=0,
manager_url="http://localhost:8080", project_name="p",
file_name="f", sequence_number=1,
)
assert result == (512, 512)
def test_fetch_resolution_clamps_on_out_of_bounds(self):
from project_loader import ProjectResolution
node = ProjectResolution()
data = {"resolutions": [[512, 512], [1024, 1024]]}
with patch("project_loader._fetch_data", return_value=data):
result = node.fetch_resolution(
source_label="src", key_name="resolutions", index=99,
manager_url="http://localhost:8080", project_name="p",
file_name="f", sequence_number=1,
)
assert result == (1024, 1024) # last entry
def test_fetch_resolution_missing_key_returns_defaults(self):
from project_loader import ProjectResolution
node = ProjectResolution()
with patch("project_loader._fetch_data", return_value={}):
result = node.fetch_resolution(
source_label="src", key_name="nonexistent", index=0,
manager_url="http://localhost:8080", project_name="p",
file_name="f", sequence_number=1,
)
assert result == (512, 512)
def test_fetch_resolution_network_error_returns_defaults(self):
from project_loader import ProjectResolution
node = ProjectResolution()
error_resp = {"error": "network_error", "message": "Connection refused"}
with patch("project_loader._fetch_data", return_value=error_resp):
result = node.fetch_resolution(
source_label="src", key_name="resolutions", index=0,
manager_url="http://localhost:8080", project_name="p",
file_name="f", sequence_number=1,
)
assert result == (512, 512)
def test_category(self):
from project_loader import ProjectResolution
assert ProjectResolution.CATEGORY == "utils/json/project"
```
**Step 2: Run tests to verify they fail**
```bash
pytest tests/test_project_loader.py::TestProjectResolution -v
```
Expected: `ImportError: cannot import name 'ProjectResolution'`
**Step 3: Implement `ProjectResolution` in `project_loader.py`**
Insert this class after `ProjectKey` (line ~294), before `# --- Mappings ---`:
```python
class ProjectResolution:
"""Fetches a (width, height) pair from a resolution series by loop index."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"source_label": ("STRING", {"default": "", "multiline": False}),
"key_name": ("STRING", {"default": "resolutions", "multiline": False}),
"index": ("INT", {"default": 0, "min": 0, "max": 9999}),
},
"optional": {
"manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}),
"project_name": ("STRING", {"default": "", "multiline": False}),
"file_name": ("STRING", {"default": "", "multiline": False}),
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
},
}
RETURN_TYPES = ("INT", "INT")
RETURN_NAMES = ("width", "height")
FUNCTION = "fetch_resolution"
CATEGORY = "utils/json/project"
OUTPUT_NODE = False
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan")
def fetch_resolution(self, source_label, key_name, index,
manager_url="http://localhost:8080", project_name="",
file_name="", sequence_number=1):
sequence_number = int(sequence_number)
data = _fetch_data(manager_url, project_name, file_name, sequence_number)
if data.get("error") in ("http_error", "network_error", "parse_error"):
logger.warning("ProjectResolution.fetch_resolution failed: %s", data.get("message"))
return (512, 512)
series = data.get(key_name)
if not isinstance(series, list) or len(series) == 0:
logger.warning("ProjectResolution: key '%s' is not a resolution series", key_name)
return (512, 512)
clamped = min(index, len(series) - 1)
entry = series[clamped]
if not isinstance(entry, (list, tuple)) or len(entry) < 2:
logger.warning("ProjectResolution: entry at index %d is malformed: %r", clamped, entry)
return (512, 512)
return (to_int(entry[0]), to_int(entry[1]))
```
**Step 4: Run tests to verify they pass**
```bash
pytest tests/test_project_loader.py::TestProjectResolution -v
```
Expected: all 7 tests PASS
**Step 5: Commit**
```bash
git add project_loader.py tests/test_project_loader.py
git commit -m "feat: add ProjectResolution node"
```
---
### Task 2: Register `ProjectResolution` in mappings + fix mapping tests
**Files:**
- Modify: `project_loader.py` (mappings section, lines ~297-307)
- Modify: `tests/test_project_loader.py` (`TestNodeMappings` class)
**Step 1: Update mappings in `project_loader.py`**
Change the mappings at the bottom of the file:
```python
PROJECT_NODE_CLASS_MAPPINGS = {
"ProjectLoaderDynamic": ProjectLoaderDynamic,
"ProjectSource": ProjectSource,
"ProjectKey": ProjectKey,
"ProjectResolution": ProjectResolution,
}
PROJECT_NODE_DISPLAY_NAME_MAPPINGS = {
"ProjectLoaderDynamic": "Project Loader (Dynamic)",
"ProjectSource": "Project Source",
"ProjectKey": "Project Key",
"ProjectResolution": "Project Resolution",
}
```
**Step 2: Update the mapping test**
In `tests/test_project_loader.py`, update `TestNodeMappings.test_mappings_exist`:
```python
class TestNodeMappings:
def test_mappings_exist(self):
from project_loader import PROJECT_NODE_CLASS_MAPPINGS, PROJECT_NODE_DISPLAY_NAME_MAPPINGS
assert "ProjectLoaderDynamic" in PROJECT_NODE_CLASS_MAPPINGS
assert "ProjectSource" in PROJECT_NODE_CLASS_MAPPINGS
assert "ProjectKey" in PROJECT_NODE_CLASS_MAPPINGS
assert "ProjectResolution" in PROJECT_NODE_CLASS_MAPPINGS
assert len(PROJECT_NODE_CLASS_MAPPINGS) == 4
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 4
```
**Step 3: Run all project_loader tests**
```bash
pytest tests/test_project_loader.py -v
```
Expected: all tests PASS
**Step 4: Commit**
```bash
git add project_loader.py tests/test_project_loader.py
git commit -m "feat: register ProjectResolution in node mappings"
```
---
### Task 3: NiceGUI resolution series editor in `tab_batch_ng.py`
**Files:**
- Modify: `tab_batch_ng.py`
The resolution series editor goes inside `splitter.before`, directly after the "Specific Negative" textarea (currently line ~552-553). No new file needed.
**Step 1: Add the helper function**
Add this function near the other helper functions at the top of the render section (before `_render_sequence_card`):
```python
def _is_resolution_series(val) -> bool:
"""Return True if val is a list of [width, height] int pairs."""
if not isinstance(val, list) or len(val) == 0:
return False
return all(
isinstance(entry, (list, tuple)) and len(entry) == 2
and all(isinstance(v, (int, float)) for v in entry)
for entry in val
)
```
Note: `Any` is intentionally omitted — `tab_batch_ng.py` does not import `typing.Any`.
**Step 2: Add the resolution series render section**
After the "Specific Negative" textarea in `splitter.before` (after line ~553), add:
```python
# --- Resolution Series ---
res_keys = [k for k, v in seq.items() if _is_resolution_series(v)]
if res_keys:
ui.label('Resolution Series').classes('text-caption text-weight-bold q-mt-md')
for res_key in res_keys:
series: list = seq[res_key]
with ui.card().classes('w-full q-pa-sm q-mt-xs').props('flat bordered'):
with ui.row().classes('items-center q-mb-xs'):
ui.label(res_key).classes('text-caption col')
def del_series(k=res_key):
del seq[k]
commit()
ui.button(icon='delete', on_click=del_series).props(
'flat dense round size=xs color=negative')
with ui.row().classes('text-caption text-grey q-mb-xs'):
ui.label('#').style('width:24px')
ui.label('Width').classes('col')
ui.label('Height').classes('col')
ui.label('').style('width:28px')
for idx, entry in enumerate(series):
with ui.row().classes('items-center w-full'):
ui.label(str(idx + 1)).classes('text-caption').style('width:24px')
w_inp = ui.number(value=int(entry[0]), min=1, step=1).classes(
'col').props('outlined dense hide-bottom-space')
h_inp = ui.number(value=int(entry[1]), min=1, step=1).classes(
'col').props('outlined dense hide-bottom-space')
def _sync_wh(i=idx, k=res_key, wi=w_inp, hi=h_inp):
seq[k][i] = [
int(wi.value) if wi.value else 512,
int(hi.value) if hi.value else 512,
]
commit()
w_inp.on('blur', lambda _, s=_sync_wh: s())
h_inp.on('blur', lambda _, s=_sync_wh: s())
def del_row(i=idx, k=res_key):
seq[k].pop(i)
commit()
ui.button(icon='remove', on_click=del_row).props(
'flat dense round size=xs')
def add_row(k=res_key):
seq[k].append([512, 512])
commit()
ui.button('+ Add row', icon='add', on_click=add_row).props(
'flat dense size=sm').classes('q-mt-xs')
with ui.expansion('Add Resolution Series', icon='straighten').classes('w-full q-mt-sm'):
new_res_key = ui.input('Key name', value='resolutions').props('outlined dense')
def add_res_series():
k = new_res_key.value.strip()
if k and k not in seq:
seq[k] = [[512, 512], [1024, 1024]]
commit()
ui.button('Add', icon='add', on_click=add_res_series).props('outlined dense')
```
**Step 3: Run all tests**
```bash
pytest tests/ -q
```
Expected: all tests PASS (no Python tests cover the NiceGUI render path, but no regressions)
**Important:** Also update the `custom_keys` filter in `_render_sequence_card` (line ~648) to exclude
resolution series keys — otherwise they'd render in both the resolution editor AND "Custom Parameters":
```python
# Find this line:
custom_keys = [k for k in seq.keys() if k not in standard_keys]
# Replace with:
custom_keys = [k for k in seq.keys() if k not in standard_keys and not _is_resolution_series(seq.get(k))]
```
**Step 4: Commit**
```bash
git add tab_batch_ng.py
git commit -m "feat: resolution series editor in sequence card"
```
---
### Task 4: JS extension `web/project_resolution.js`
**Files:**
- Create: `web/project_resolution.js`
This file mirrors `web/project_key.js` exactly, with two differences:
1. It targets `"ProjectResolution"` instead of `"ProjectKey"`
2. `_refreshKeys` filters to only show keys whose value is a resolution series (list of `[int, int]` pairs) — but since the keys API only returns key names (not values), the filter is done by naming convention or we just show all keys and let the user pick. For simplicity, show all keys (same as ProjectKey) and let the user pick.
3. The `index` widget is **not** hidden — the user wires it from a loop node
4. The node has two outputs (`width`, `height`) so no output slot name update is needed
**Step 1: Create `web/project_resolution.js`**
```javascript
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "json.manager.project.resolution",
async beforeQueuePrompt() {
if (!app.graph?._nodes) return;
for (const node of app.graph._nodes) {
if (node.type === "ProjectResolution" && node._syncFromSource) {
node._syncFromSource();
}
}
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "ProjectResolution") return;
function hideWidget(widget) {
if (widget.origType === undefined) widget.origType = widget.type;
widget.type = "hidden";
widget.hidden = true;
widget.computeSize = () => [0, -4];
}
function replaceWithCombo(node, name, values, callback) {
const idx = node.widgets?.findIndex(w => w.name === name);
if (idx === -1 || idx === undefined) return null;
const oldWidget = node.widgets[idx];
const savedValue = oldWidget.value || "";
const comboValues = values.length > 0 ? values : [""];
if (savedValue && !comboValues.includes(savedValue)) {
comboValues.unshift(savedValue);
}
const defaultValue = savedValue || comboValues[0];
node.widgets.splice(idx, 1);
const combo = node.addWidget("combo", name, defaultValue, callback, { values: comboValues });
if (node.widgets.length > 1) {
node.widgets.splice(node.widgets.length - 1, 1);
node.widgets.splice(idx, 0, combo);
}
return combo;
}
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origOnNodeCreated?.apply(this, arguments);
this._configured = false;
// Hide synced config widgets (index stays visible — user wires it)
for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) {
const w = this.widgets?.find(w => w.name === name);
if (w) hideWidget(w);
}
const node = this;
const sourceLabels = this._getSourceLabels?.() || [];
const srcCombo = replaceWithCombo(this, "source_label", sourceLabels, function (value) {
node._syncFromSource();
node._refreshKeys();
});
if (srcCombo) srcCombo.value = sourceLabels[0] || "";
const keyCombo = replaceWithCombo(this, "key_name", [], function (value) {
node.title = value ? `Resolution: ${value}` : "Project Resolution";
app.graph?.setDirtyCanvas(true, true);
});
if (keyCombo) keyCombo.value = "";
queueMicrotask(() => {
if (!this._configured) {
this.setSize(this.computeSize());
}
});
};
nodeType.prototype._getSourceLabels = function () {
const seen = new Set();
const labels = [];
if (!this.graph) return labels;
for (const node of this.graph._nodes) {
if (node.type === "ProjectSource") {
const lw = node.widgets?.find(w => w.name === "label");
if (lw?.value && !seen.has(lw.value)) {
seen.add(lw.value);
labels.push(lw.value);
}
}
}
return labels;
};
nodeType.prototype._findSource = function (label) {
if (!this.graph || !label) return null;
for (const node of this.graph._nodes) {
if (node.type === "ProjectSource") {
const lw = node.widgets?.find(w => w.name === "label");
if (lw?.value === label) return node;
}
}
return null;
};
nodeType.prototype._syncFromSource = function () {
const srcWidget = this.widgets?.find(w => w.name === "source_label");
const source = this._findSource(srcWidget?.value);
if (!source) return;
for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) {
const dst = this.widgets?.find(w => w.name === name);
const src = source.widgets?.find(w => w.name === name);
if (dst && src) dst.value = src.value;
}
};
nodeType.prototype._refreshKeys = async function () {
const urlW = this.widgets?.find(w => w.name === "manager_url");
const projW = this.widgets?.find(w => w.name === "project_name");
const fileW = this.widgets?.find(w => w.name === "file_name");
const seqW = this.widgets?.find(w => w.name === "sequence_number");
if (!urlW?.value || !projW?.value || !fileW?.value) return;
try {
const resp = await api.fetchApi(
`/json_manager/get_project_keys?url=${encodeURIComponent(urlW.value)}&project=${encodeURIComponent(projW.value)}&file=${encodeURIComponent(fileW.value)}&seq=${seqW?.value || 1}`
);
if (!resp.ok) return;
const data = await resp.json();
if (data.error || !Array.isArray(data.keys)) return;
const keyWidget = this.widgets?.find(w => w.name === "key_name");
if (keyWidget) {
keyWidget.options.values = data.keys.length > 0 ? data.keys : [""];
}
} catch (e) {
console.error("[ProjectResolution] Failed to refresh keys:", e);
}
};
const origOnMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) {
origOnMouseDown?.apply(this, arguments);
const srcWidget = this.widgets?.find(w => w.name === "source_label");
if (srcWidget) srcWidget.options.values = this._getSourceLabels();
this._syncFromSource();
};
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
origOnConfigure?.apply(this, arguments);
this._configured = true;
for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) {
const w = this.widgets?.find(w => w.name === name);
if (w) hideWidget(w);
}
const srcWidget = this.widgets?.find(w => w.name === "source_label");
if (srcWidget && srcWidget.type !== "combo") {
const node = this;
replaceWithCombo(this, "source_label", this._getSourceLabels(), function (value) {
node._syncFromSource();
node._refreshKeys();
});
} else if (srcWidget) {
srcWidget.options.values = this._getSourceLabels();
}
const keyWidget = this.widgets?.find(w => w.name === "key_name");
if (keyWidget && keyWidget.type !== "combo") {
const node = this;
replaceWithCombo(this, "key_name", [], function (value) {
node.title = value ? `Resolution: ${value}` : "Project Resolution";
app.graph?.setDirtyCanvas(true, true);
});
}
const finalKeyWidget = this.widgets?.find(w => w.name === "key_name");
if (finalKeyWidget?.value) {
this.title = `Resolution: ${finalKeyWidget.value}`;
}
this.setSize(this.computeSize());
const node = this;
queueMicrotask(() => {
node._syncFromSource();
node._refreshKeys();
});
};
},
});
```
**Step 2: Run all tests**
```bash
pytest tests/ -q
```
Expected: all tests PASS (JS has no Python tests)
**Step 3: Commit**
```bash
git add web/project_resolution.js
git commit -m "feat: ProjectResolution JS extension for ComfyUI frontend"
```
---
### Task 5: Final verification and push
**Step 1: Run full test suite**
```bash
pytest tests/ -v
```
Expected: all tests PASS
**Step 2: Push branch**
```bash
git push origin HEAD
```
@@ -1,67 +0,0 @@
# 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
@@ -1,166 +0,0 @@
# 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
```
+8 -8
View File
@@ -295,12 +295,12 @@ def index():
sync_to_db, pane_state.db, pane_state.current_project, fp, data) sync_to_db, pane_state.db, pane_state.current_project, fp, data)
tree = data.get('history_tree') tree = data.get('history_tree')
if tree and isinstance(tree, dict): if tree and isinstance(tree, dict):
for entry in tree.get('snapshots', tree.get('nodes', {})).values(): for node in tree.get('nodes', {}).values():
entry.pop('data', None) node.pop('data', None)
for backup in data.get('history_tree_backup', []): for backup in data.get('history_tree_backup', []):
if isinstance(backup, dict): if isinstance(backup, dict):
for entry in backup.get('snapshots', backup.get('nodes', {})).values(): for node in backup.get('nodes', {}).values():
entry.pop('data', None) node.pop('data', None)
pane_state.data_cache = data pane_state.data_cache = data
pane_state.last_mtime = fp.stat().st_mtime if fp.exists() else 0 pane_state.last_mtime = fp.stat().st_mtime if fp.exists() else 0
pane_state.loaded_file = str(fp) pane_state.loaded_file = str(fp)
@@ -339,13 +339,13 @@ def index():
sync_to_db, state.db, state.current_project, fp, data) sync_to_db, state.db, state.current_project, fp, data)
tree = data.get('history_tree') tree = data.get('history_tree')
if tree and isinstance(tree, dict): if tree and isinstance(tree, dict):
for entry in tree.get('snapshots', tree.get('nodes', {})).values(): for node in tree.get('nodes', {}).values():
entry.pop('data', None) node.pop('data', None)
# Strip snapshot data from history_tree_backup to prevent RAM/disk bloat # Strip snapshot data from history_tree_backup to prevent RAM/disk bloat
for backup in data.get('history_tree_backup', []): for backup in data.get('history_tree_backup', []):
if isinstance(backup, dict): if isinstance(backup, dict):
for entry in backup.get('snapshots', backup.get('nodes', {})).values(): for node in backup.get('nodes', {}).values():
entry.pop('data', None) node.pop('data', None)
state.data_cache = data state.data_cache = data
state.last_mtime = fp.stat().st_mtime if fp.exists() else 0 state.last_mtime = fp.stat().st_mtime if fp.exists() else 0
state.loaded_file = str(fp) state.loaded_file = str(fp)
+17 -73
View File
@@ -67,13 +67,6 @@ def _fetch_json(url: str) -> dict:
return {"error": "parse_error", "message": str(e)} return {"error": "parse_error", "message": str(e)}
def _fetch_project(manager_url: str, project: str) -> dict:
"""Fetch project details (including folder_path) from the NiceGUI REST API."""
p = urllib.parse.quote(project, safe='')
url = f"{manager_url.rstrip('/')}/api/projects/{p}"
return _fetch_json(url)
def _fetch_data(manager_url: str, project: str, file: str, seq: int) -> dict: def _fetch_data(manager_url: str, project: str, file: str, seq: int) -> dict:
"""Fetch sequence data from the NiceGUI REST API.""" """Fetch sequence data from the NiceGUI REST API."""
p = urllib.parse.quote(project, safe='') p = urllib.parse.quote(project, safe='')
@@ -157,7 +150,7 @@ class ProjectLoaderDynamic:
RETURN_TYPES = ("INT",) + tuple(any_type for _ in range(MAX_DYNAMIC_OUTPUTS)) RETURN_TYPES = ("INT",) + tuple(any_type for _ in range(MAX_DYNAMIC_OUTPUTS))
RETURN_NAMES = ("total_sequences",) + tuple(f"output_{i}" for i in range(MAX_DYNAMIC_OUTPUTS)) RETURN_NAMES = ("total_sequences",) + tuple(f"output_{i}" for i in range(MAX_DYNAMIC_OUTPUTS))
FUNCTION = "load_dynamic" FUNCTION = "load_dynamic"
CATEGORY = "JSON Manager/project" CATEGORY = "utils/json/project"
OUTPUT_NODE = False OUTPUT_NODE = False
def load_dynamic(self, manager_url, project_name, file_name, sequence_number, def load_dynamic(self, manager_url, project_name, file_name, sequence_number,
@@ -228,24 +221,14 @@ class ProjectSource:
}, },
} }
RETURN_TYPES = ("INT", "STRING", "STRING") RETURN_TYPES = ("INT", "STRING",)
RETURN_NAMES = ("sequence_number", "file_name", "project_path") RETURN_NAMES = ("sequence_number", "file_name",)
FUNCTION = "hold_config" FUNCTION = "hold_config"
CATEGORY = "JSON Manager/project" CATEGORY = "utils/json/project"
OUTPUT_NODE = True OUTPUT_NODE = True
def hold_config(self, manager_url, project_name, file_name, sequence_number, label): def hold_config(self, manager_url, project_name, file_name, sequence_number, label):
name = project_name.strip() return (sequence_number, file_name,)
if not name:
active = _fetch_json(f"{manager_url.rstrip('/')}/api/active-project")
name = active.get("project", "") if "error" not in active else ""
folder_path = ""
if name:
proj = _fetch_project(manager_url, name)
folder_path = proj.get("folder_path", "") if "error" not in proj else ""
if folder_path and not folder_path.endswith("/"):
folder_path += "/"
return (sequence_number, file_name, folder_path)
class ProjectKey: class ProjectKey:
@@ -269,7 +252,7 @@ class ProjectKey:
RETURN_TYPES = (any_type,) RETURN_TYPES = (any_type,)
RETURN_NAMES = ("value",) RETURN_NAMES = ("value",)
FUNCTION = "fetch_key" FUNCTION = "fetch_key"
CATEGORY = "JSON Manager/project" CATEGORY = "utils/json/project"
OUTPUT_NODE = False OUTPUT_NODE = False
@classmethod @classmethod
@@ -299,15 +282,13 @@ class ProjectKey:
val = data.get(key_name, "") val = data.get(key_name, "")
if key_type == "INT": if key_type == "INT":
result = to_int(val) return (to_int(val),)
return {"ui": {"value": [str(result)]}, "result": (result,)}
elif key_type == "FLOAT": elif key_type == "FLOAT":
result = to_float(val) return (to_float(val),)
return {"ui": {"value": [f"{result:.4g}"]}, "result": (result,)}
elif isinstance(val, bool): elif isinstance(val, bool):
return {"ui": {"value": [str(val).lower()]}, "result": (str(val).lower(),)} return (str(val).lower(),)
elif isinstance(val, (int, float)): elif isinstance(val, (int, float)):
return {"ui": {"value": [str(val)]}, "result": (val,)} return (val,)
else: else:
return (str(val),) return (str(val),)
@@ -330,10 +311,10 @@ class ProjectResolution:
}, },
} }
RETURN_TYPES = ("INT", "INT", "INT") RETURN_TYPES = ("INT", "INT")
RETURN_NAMES = ("width", "height", "seed") RETURN_NAMES = ("width", "height")
FUNCTION = "fetch_resolution" FUNCTION = "fetch_resolution"
CATEGORY = "JSON Manager/project" CATEGORY = "utils/json/project"
OUTPUT_NODE = False OUTPUT_NODE = False
@classmethod @classmethod
@@ -351,55 +332,20 @@ class ProjectResolution:
data = _fetch_data(manager_url, project_name, file_name, sequence_number) data = _fetch_data(manager_url, project_name, file_name, sequence_number)
if data.get("error") in ("http_error", "network_error", "parse_error"): if data.get("error") in ("http_error", "network_error", "parse_error"):
logger.warning("ProjectResolution.fetch_resolution failed: %s", data.get("message")) logger.warning("ProjectResolution.fetch_resolution failed: %s", data.get("message"))
return (512, 512, 0) return (512, 512)
series = data.get(key_name) series = data.get(key_name)
if not isinstance(series, list) or len(series) == 0: if not isinstance(series, list) or len(series) == 0:
logger.warning("ProjectResolution: key '%s' is not a resolution series", key_name) logger.warning("ProjectResolution: key '%s' is not a resolution series", key_name)
return (512, 512, 0) return (512, 512)
clamped = max(0, min(index, len(series) - 1)) clamped = max(0, min(index, len(series) - 1))
entry = series[clamped] entry = series[clamped]
if not isinstance(entry, (list, tuple)) or len(entry) < 2: if not isinstance(entry, (list, tuple)) or len(entry) < 2:
logger.warning("ProjectResolution: entry at index %d is malformed: %r", clamped, entry) logger.warning("ProjectResolution: entry at index %d is malformed: %r", clamped, entry)
return (512, 512, 0) return (512, 512)
seed = to_int(entry[2]) if len(entry) >= 3 else 0 return (to_int(entry[0]), to_int(entry[1]))
return (to_int(entry[0]), to_int(entry[1]), seed)
class BinaryIndexDecoder:
"""Decodes an integer index into 3 boolean flags using binary (bit-field) encoding.
index 0 → (False, False, False)
index 1 → (True, False, False) # bit 0
index 2 → (False, True, False) # bit 1
index 3 → (True, True, False) # bits 0+1
index 4 → (False, False, True) # bit 2
...
index 7 → (True, True, True)
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"index": ("INT", {"default": 0, "min": 0, "max": 7}),
}
}
RETURN_TYPES = ("BOOLEAN", "BOOLEAN", "BOOLEAN")
RETURN_NAMES = ("flag_0", "flag_1", "flag_2")
FUNCTION = "decode"
CATEGORY = "JSON Manager/utils"
OUTPUT_NODE = False
def decode(self, index: int):
f0 = bool((index >> 0) & 1)
f1 = bool((index >> 1) & 1)
f2 = bool((index >> 2) & 1)
return {"ui": {"values": [str(f0).lower(), str(f1).lower(), str(f2).lower()]},
"result": (f0, f1, f2)}
# --- Mappings --- # --- Mappings ---
@@ -408,7 +354,6 @@ PROJECT_NODE_CLASS_MAPPINGS = {
"ProjectSource": ProjectSource, "ProjectSource": ProjectSource,
"ProjectKey": ProjectKey, "ProjectKey": ProjectKey,
"ProjectResolution": ProjectResolution, "ProjectResolution": ProjectResolution,
"BinaryIndexDecoder": BinaryIndexDecoder,
} }
PROJECT_NODE_DISPLAY_NAME_MAPPINGS = { PROJECT_NODE_DISPLAY_NAME_MAPPINGS = {
@@ -416,5 +361,4 @@ PROJECT_NODE_DISPLAY_NAME_MAPPINGS = {
"ProjectSource": "Project Source", "ProjectSource": "Project Source",
"ProjectKey": "Project Key", "ProjectKey": "Project Key",
"ProjectResolution": "Project Resolution", "ProjectResolution": "Project Resolution",
"BinaryIndexDecoder": "Binary Index Decoder",
} }
-184
View File
@@ -1,184 +0,0 @@
import time
import uuid
from typing import Any
KEY_PROMPT_HISTORY = "prompt_history"
class SnapshotTimeline:
"""Flat chronological snapshot list — replaces the old HistoryTree DAG."""
def __init__(self, raw_data: dict[str, Any]) -> None:
# Detect and migrate old HistoryTree format
if "nodes" in raw_data and "branches" in raw_data:
self._migrate_from_tree(raw_data)
elif KEY_PROMPT_HISTORY in raw_data and isinstance(raw_data[KEY_PROMPT_HISTORY], list):
self._migrate_legacy(raw_data[KEY_PROMPT_HISTORY])
else:
self.snapshots: dict[str, dict[str, Any]] = raw_data.get("snapshots", {})
self.current_id: str | None = raw_data.get("current_id", None)
# ------------------------------------------------------------------
# Migration
# ------------------------------------------------------------------
def _migrate_from_tree(self, raw_data: dict[str, Any]) -> None:
"""Flatten old HistoryTree nodes into snapshot list, discarding DAG info."""
self.snapshots = {}
nodes = raw_data.get("nodes", {})
for nid, node in nodes.items():
self.snapshots[nid] = {
"id": nid,
"timestamp": node.get("timestamp", time.time()),
"note": node.get("note", "Migrated"),
"pinned": False,
"auto": False,
"seq_count": self._count_seqs(node.get("data")),
}
# Preserve snapshot data if present
if "data" in node and node["data"]:
self.snapshots[nid]["data"] = node["data"]
self.current_id = raw_data.get("head_id")
def _migrate_legacy(self, old_list: list[dict[str, Any]]) -> None:
"""Convert ancient prompt_history list into snapshots."""
self.snapshots = {}
self.current_id = None
for item in reversed(old_list):
sid = self._make_id()
self.snapshots[sid] = {
"id": sid,
"timestamp": time.time(),
"note": item.get("note", "Legacy Import"),
"pinned": False,
"auto": False,
"seq_count": self._count_seqs(item),
"data": item,
}
self.current_id = sid
# ------------------------------------------------------------------
# Core operations
# ------------------------------------------------------------------
def record(self, data: dict[str, Any], note: str = "Snapshot",
auto: bool = False) -> str:
"""Create a new snapshot and return its ID."""
sid = self._make_id()
self.snapshots[sid] = {
"id": sid,
"timestamp": time.time(),
"note": note,
"pinned": False,
"auto": auto,
"seq_count": self._count_seqs(data),
"data": data,
}
self.current_id = sid
return sid
def get_snapshot_data(self, snapshot_id: str) -> dict[str, Any] | None:
"""Return the inline snapshot data if present."""
snap = self.snapshots.get(snapshot_id)
if snap:
return snap.get("data")
return None
def toggle_pin(self, snapshot_id: str) -> bool:
"""Toggle pinned state, return new value."""
snap = self.snapshots.get(snapshot_id)
if snap:
snap["pinned"] = not snap.get("pinned", False)
return snap["pinned"]
return False
def delete(self, snapshot_id: str) -> None:
"""Remove a snapshot."""
self.snapshots.pop(snapshot_id, None)
if self.current_id == snapshot_id:
# Fall back to most recent remaining
if self.snapshots:
self.current_id = max(
self.snapshots.values(), key=lambda s: s["timestamp"]
)["id"]
else:
self.current_id = None
def strip_snapshots(self) -> None:
"""Remove inline data from all snapshots (for slim JSON storage)."""
for snap in self.snapshots.values():
snap.pop("data", None)
# ------------------------------------------------------------------
# Serialization
# ------------------------------------------------------------------
def to_dict(self) -> dict[str, Any]:
return {
"snapshots": self.snapshots,
"current_id": self.current_id,
}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _make_id(self) -> str:
for _ in range(10):
sid = str(uuid.uuid4())[:8]
if sid not in self.snapshots:
return sid
raise ValueError("Failed to generate unique snapshot ID after 10 attempts")
@staticmethod
def _count_seqs(data: dict | None) -> int:
if not data:
return 0
from utils import KEY_BATCH_DATA
batch = data.get(KEY_BATCH_DATA, [])
return len(batch) if isinstance(batch, list) else 0
# ------------------------------------------------------------------
# Diff function
# ------------------------------------------------------------------
def diff_snapshots(old_batch: list[dict], new_batch: list[dict]) -> list[dict]:
"""Compare two batch lists by sequence_number, return per-sequence diffs.
Returns a list of dicts:
{
"seq_num": int,
"status": "unchanged" | "changed" | "added" | "removed",
"changes": [{"field": str, "old": Any, "new": Any}],
}
"""
from utils import KEY_SEQUENCE_NUMBER
old_by_seq = {int(s.get(KEY_SEQUENCE_NUMBER, 0)): s for s in old_batch}
new_by_seq = {int(s.get(KEY_SEQUENCE_NUMBER, 0)): s for s in new_batch}
all_seqs = sorted(set(old_by_seq) | set(new_by_seq))
result = []
for seq_num in all_seqs:
old_item = old_by_seq.get(seq_num)
new_item = new_by_seq.get(seq_num)
if old_item and not new_item:
result.append({"seq_num": seq_num, "status": "removed", "changes": []})
elif new_item and not old_item:
result.append({"seq_num": seq_num, "status": "added", "changes": []})
else:
# Both exist — field-by-field comparison
all_keys = sorted(set(old_item) | set(new_item))
changes = []
for k in all_keys:
old_val = old_item.get(k)
new_val = new_item.get(k)
if old_val != new_val:
changes.append({"field": k, "old": old_val, "new": new_val})
status = "changed" if changes else "unchanged"
result.append({"seq_num": seq_num, "status": status, "changes": changes})
return result
+1 -1
View File
@@ -13,7 +13,7 @@ class AppState:
snippets: dict = field(default_factory=dict) snippets: dict = field(default_factory=dict)
file_path: Path | None = None file_path: Path | None = None
restored_indicator: str | None = None restored_indicator: str | None = None
timeline_selected_id: str | None = None timeline_selected_nodes: set = field(default_factory=set)
live_toggles: dict = field(default_factory=dict) live_toggles: dict = field(default_factory=dict)
show_comfy_monitor: bool = True show_comfy_monitor: bool = True
+135 -163
View File
@@ -6,7 +6,6 @@ import math
import random import random
import time import time
from pathlib import Path from pathlib import Path
from urllib.parse import quote
from nicegui import ui from nicegui import ui
@@ -17,11 +16,9 @@ from utils import (
DEFAULTS, save_json, load_json, sync_to_db, DEFAULTS, save_json, load_json, sync_to_db,
KEY_BATCH_DATA, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY, KEY_SEQUENCE_NUMBER, KEY_BATCH_DATA, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY, KEY_SEQUENCE_NUMBER,
) )
from snapshot_timeline import SnapshotTimeline from history_tree import HistoryTree
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'}
_AUTO_SNAP_DEBOUNCE = 30 # seconds between auto-snapshots
_last_auto_snap: dict[str, float] = {} # file_path -> timestamp
SUB_SEGMENT_MULTIPLIER = 1000 SUB_SEGMENT_MULTIPLIER = 1000
SUB_SEGMENT_NUM_COLORS = 6 SUB_SEGMENT_NUM_COLORS = 6
FRAME_TO_SKIP_DEFAULT = DEFAULTS['frame_to_skip'] FRAME_TO_SKIP_DEFAULT = DEFAULTS['frame_to_skip']
@@ -89,18 +86,18 @@ def find_insert_position(batch_list, parent_index, parent_seq_num):
# --- Auto change note --- # --- Auto change note ---
def _auto_change_note(timeline, batch_list, state=None, file_path=None): def _auto_change_note(htree, batch_list, state=None, file_path=None):
"""Compare current batch_list against last snapshot and describe changes.""" """Compare current batch_list against last snapshot and describe changes."""
# Get previous batch data from the current snapshot # Get previous batch data from the current head
if not timeline.current_id or timeline.current_id not in timeline.snapshots: if not htree.head_id or htree.head_id not in htree.nodes:
return f'Initial save ({len(batch_list)} sequences)' return f'Initial save ({len(batch_list)} sequences)'
# Load previous snapshot from inline data or DB # Load previous snapshot from DB (nodes no longer hold data in memory)
prev_data = timeline.get_snapshot_data(timeline.current_id) prev_data = htree.nodes[htree.head_id].get('data')
if not prev_data and state and state.db_enabled and state.db and state.current_project and file_path: if not prev_data and state and state.db_enabled and state.db and state.current_project and file_path:
df = state.db.get_data_file_by_names(state.current_project, file_path.stem) df = state.db.get_data_file_by_names(state.current_project, file_path.stem)
if df: if df:
prev_data = state.db.get_node_snapshot(df['id'], timeline.current_id) prev_data = state.db.get_node_snapshot(df['id'], htree.head_id)
prev_batch = (prev_data or {}).get(KEY_BATCH_DATA, []) prev_batch = (prev_data or {}).get(KEY_BATCH_DATA, [])
prev_by_seq = {int(s.get(KEY_SEQUENCE_NUMBER, 0)): s for s in prev_batch} prev_by_seq = {int(s.get(KEY_SEQUENCE_NUMBER, 0)): s for s in prev_batch}
@@ -314,13 +311,10 @@ def render_batch_processor(state: AppState):
'lora 3 high', 'lora 3 high strength', 'lora 3 low', 'lora 3 low strength'] 'lora 3 high', 'lora 3 high strength', 'lora 3 low', 'lora 3 low strength']
standard_keys = { standard_keys = {
'name', 'mode', 'general_prompt', 'general_negative', 'current_prompt', 'negative', 'prompt', 'name', 'mode', 'general_prompt', 'general_negative', 'current_prompt', 'negative', 'prompt',
'seed', 'camera', KEY_SEQUENCE_NUMBER, 'seed', 'cfg', 'camera', 'flf', KEY_SEQUENCE_NUMBER,
'frame_to_skip', 'logic index', 'transition', 'vace_length', 'frame_to_skip', 'end_frame', 'transition', 'vace_length',
'input_a_frames', 'input_b_frames', 'reference switch', 'vace schedule', 'input_a_frames', 'input_b_frames', 'reference switch', 'vace schedule',
'start frame path', 'start frame high strength', 'start frame low strength', 'reference path', 'video file path', 'reference image path', 'flf image path',
'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) standard_keys.update(lora_keys)
@@ -369,34 +363,38 @@ def render_batch_processor(state: AppState):
logger.info("save_and_snap START") logger.info("save_and_snap START")
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
tree_data = data.get(KEY_HISTORY_TREE, {}) tree_data = data.get(KEY_HISTORY_TREE, {})
timeline = SnapshotTimeline(tree_data) htree = HistoryTree(tree_data)
note = commit_input.value if commit_input.value else _auto_change_note(timeline, batch_list, state=state, file_path=file_path) note = commit_input.value if commit_input.value else _auto_change_note(htree, batch_list, state=state, file_path=file_path)
# Single serialization: json roundtrip gives us an isolated snapshot # Single serialization: json roundtrip gives us an isolated snapshot
# without the expensive deepcopy
t1 = time.perf_counter() t1 = time.perf_counter()
snapshot_json = json.dumps({k: v for k, v in data.items() snapshot_json = json.dumps({k: v for k, v in data.items()
if k != KEY_HISTORY_TREE}) if k != KEY_HISTORY_TREE})
snapshot_payload = json.loads(snapshot_json) snapshot_payload = json.loads(snapshot_json)
logger.info("save_and_snap snapshot %.3fs", time.perf_counter() - t1) logger.info("save_and_snap snapshot %.3fs", time.perf_counter() - t1)
try: try:
timeline.record(snapshot_payload, note=note) htree.commit(snapshot_payload, note=note)
except ValueError as e: except ValueError as e:
ui.notify(f'Save failed: {e}', type='negative') ui.notify(f'Save failed: {e}', type='negative')
return return
if state.db_enabled and state.current_project and state.db: if state.db_enabled and state.current_project and state.db:
full_tree = timeline.to_dict() # DB path: sync full tree (with snapshots) to DB, then
# write slim tree (no snapshots) to JSON and memory
full_tree = htree.to_dict()
data[KEY_HISTORY_TREE] = full_tree data[KEY_HISTORY_TREE] = full_tree
t1 = time.perf_counter() t1 = time.perf_counter()
db_snapshot = json.loads(json.dumps(data)) db_snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, db_snapshot) await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, db_snapshot)
logger.info("save_and_snap sync_to_db %.3fs", time.perf_counter() - t1) logger.info("save_and_snap sync_to_db %.3fs", time.perf_counter() - t1)
timeline.strip_snapshots() htree.strip_snapshots()
data[KEY_HISTORY_TREE] = timeline.to_dict() data[KEY_HISTORY_TREE] = htree.to_dict()
t1 = time.perf_counter() t1 = time.perf_counter()
slim_snapshot = json.loads(json.dumps(data)) slim_snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, slim_snapshot) await asyncio.to_thread(save_json, file_path, slim_snapshot)
logger.info("save_and_snap save_json %.3fs", time.perf_counter() - t1) logger.info("save_and_snap save_json %.3fs", time.perf_counter() - t1)
else: else:
data[KEY_HISTORY_TREE] = timeline.to_dict() # No DB: write full tree (with snapshots) to JSON
data[KEY_HISTORY_TREE] = htree.to_dict()
t1 = time.perf_counter() t1 = time.perf_counter()
save_snapshot = json.loads(json.dumps(data)) save_snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, save_snapshot) await asyncio.to_thread(save_json, file_path, save_snapshot)
@@ -413,36 +411,25 @@ def render_batch_processor(state: AppState):
# Single sequence card # Single sequence card
# ====================================================================== # ======================================================================
def _is_resolution_series(val) -> bool:
"""Return True if val is a non-empty list of [width, height] numeric pairs."""
if not isinstance(val, list) or len(val) == 0:
return False
return all(
isinstance(entry, (list, tuple)) and len(entry) == 2
and all(isinstance(v, (int, float)) for v in entry)
for entry in val
)
def _render_sequence_card(i, seq, batch_list, data, file_path, state, def _render_sequence_card(i, seq, batch_list, data, file_path, state,
src_cache, src_seq_select, standard_keys, src_cache, src_seq_select, standard_keys,
refresh_list): refresh_list):
async def commit(message=None): async def commit(message=None):
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
# Auto-snapshot with debounce
fp_key = str(file_path)
now = time.time()
did_snap = False
if now - _last_auto_snap.get(fp_key, 0) >= _AUTO_SNAP_DEBOUNCE:
timeline = SnapshotTimeline(data.get(KEY_HISTORY_TREE, {}))
snap_json = json.dumps({k: v for k, v in data.items()
if k != KEY_HISTORY_TREE})
snap_payload = json.loads(snap_json)
try:
timeline.record(snap_payload, note=message or "Auto-save", auto=True)
if state.db_enabled and state.current_project and state.db:
data[KEY_HISTORY_TREE] = timeline.to_dict()
db_snap = json.loads(json.dumps(data))
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, db_snap)
timeline.strip_snapshots()
did_snap = True
data[KEY_HISTORY_TREE] = timeline.to_dict()
_last_auto_snap[fp_key] = now
except ValueError:
pass # Non-critical: skip auto-snapshot on ID collision
snapshot = json.loads(json.dumps(data)) snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, snapshot) await asyncio.to_thread(save_json, file_path, snapshot)
if state.db_enabled and state.current_project and state.db and not did_snap: if state.db_enabled and state.current_project and state.db:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot) await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
if message: if message:
ui.notify(message, type='positive') ui.notify(message, type='positive')
@@ -474,11 +461,11 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
) )
if result is not None: if result is not None:
s['name'] = result s['name'] = result
await commit('Renamed!') commit('Renamed!')
ui.button('Rename', icon='edit', on_click=rename).props('outline') ui.button('Rename', icon='edit', on_click=rename).props('outline')
# Copy from source # Copy from source
async def copy_source(idx=i, sn=seq_num): def copy_source(idx=i, sn=seq_num):
item = copy.deepcopy(DEFAULTS) item = copy.deepcopy(DEFAULTS)
src_batch = src_cache['batch'] src_batch = src_cache['batch']
sel_idx = src_seq_select.value sel_idx = src_seq_select.value
@@ -490,12 +477,12 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
item.pop(KEY_PROMPT_HISTORY, None) item.pop(KEY_PROMPT_HISTORY, None)
item.pop(KEY_HISTORY_TREE, None) item.pop(KEY_HISTORY_TREE, None)
batch_list[idx] = item batch_list[idx] = item
await commit('Copied!') commit('Copied!')
ui.button('Copy Src', icon='file_download', on_click=copy_source).props('outline') ui.button('Copy Src', icon='file_download', on_click=copy_source).props('outline')
# Clone Next # Clone Next
async def clone_next(idx=i, sn=seq_num, s=seq): def clone_next(idx=i, sn=seq_num, s=seq):
new_seq = copy.deepcopy(s) new_seq = copy.deepcopy(s)
new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1
if not is_subsegment(sn): if not is_subsegment(sn):
@@ -503,21 +490,21 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
else: else:
pos = idx + 1 pos = idx + 1
batch_list.insert(pos, new_seq) batch_list.insert(pos, new_seq)
await commit('Cloned to Next!') commit('Cloned to Next!')
ui.button('Clone Next', icon='content_copy', on_click=clone_next).props('outline') ui.button('Clone Next', icon='content_copy', on_click=clone_next).props('outline')
# Clone End # Clone End
async def clone_end(s=seq): def clone_end(s=seq):
new_seq = copy.deepcopy(s) new_seq = copy.deepcopy(s)
new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1
batch_list.append(new_seq) batch_list.append(new_seq)
await commit('Cloned to End!') commit('Cloned to End!')
ui.button('Clone End', icon='vertical_align_bottom', on_click=clone_end).props('outline') ui.button('Clone End', icon='vertical_align_bottom', on_click=clone_end).props('outline')
# Clone Sub # Clone Sub
async def clone_sub(idx=i, sn=seq_num, s=seq): def clone_sub(idx=i, sn=seq_num, s=seq):
new_seq = copy.deepcopy(s) new_seq = copy.deepcopy(s)
p_seq = parent_of(sn) p_seq = parent_of(sn)
p_idx = idx p_idx = idx
@@ -529,24 +516,23 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
new_seq[KEY_SEQUENCE_NUMBER] = next_sub_segment_number(batch_list, p_seq) new_seq[KEY_SEQUENCE_NUMBER] = next_sub_segment_number(batch_list, p_seq)
pos = find_insert_position(batch_list, p_idx, p_seq) pos = find_insert_position(batch_list, p_idx, p_seq)
batch_list.insert(pos, new_seq) batch_list.insert(pos, new_seq)
await commit(f'Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!') commit(f'Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!')
ui.button('Clone Sub', icon='link', on_click=clone_sub).props('outline') ui.button('Clone Sub', icon='link', on_click=clone_sub).props('outline')
ui.element('div').classes('col') ui.element('div').classes('col')
# Delete # Delete
async def delete(idx=i): def delete(idx=i):
if idx < len(batch_list): if idx < len(batch_list):
batch_list.pop(idx) batch_list.pop(idx)
await commit() commit()
ui.button(icon='delete', on_click=delete).props('color=negative') ui.button(icon='delete', on_click=delete).props('color=negative')
ui.separator() ui.separator()
# --- Prompts + Settings (2-column) --- # --- Prompts + Settings (2-column) ---
frame_switches = [] # populated below, used for bidirectional sync with logic index
with ui.splitter(value=66).classes('w-full') as splitter: with ui.splitter(value=66).classes('w-full') as splitter:
with splitter.before: with splitter.before:
dict_textarea('General Prompt', seq, 'general_prompt').classes( dict_textarea('General Prompt', seq, 'general_prompt').classes(
@@ -558,42 +544,65 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
dict_textarea('Specific Negative', seq, 'negative').classes( dict_textarea('Specific Negative', seq, 'negative').classes(
'w-full q-mt-sm').props('outlined rows=2') 'w-full q-mt-sm').props('outlined rows=2')
# --- Frame paths (start / middle / end) --- # --- Resolution Series ---
logic_val = int(seq.get('logic index', 0)) res_keys = [k for k, v in seq.items() if _is_resolution_series(v)]
for bit, img_label, img_key, hi_key, lo_key in [ if res_keys:
(0, 'Start Frame', 'start frame path', 'start frame high strength', 'start frame low strength'), ui.label('Resolution Series').classes('text-caption text-weight-bold q-mt-md')
(1, 'Middle Frame', 'middle frame path', 'middle frame high strength', 'middle frame low strength'), for res_key in res_keys:
(2, 'End Frame', 'end frame path', 'end frame high strength', 'end frame low strength'), series: list = seq[res_key]
]: with ui.card().classes('w-full q-pa-sm q-mt-xs').props('flat bordered'):
ui.label(img_label).classes('text-caption text-weight-bold q-mt-sm') with ui.row().classes('items-center q-mb-xs'):
is_on = bool((logic_val >> bit) & 1) ui.label(res_key).classes('text-caption col')
with ui.row().classes('w-full items-center no-wrap q-mt-xs'): def del_series(k=res_key):
inp = dict_input(ui.input, 'Path', seq, img_key).classes( del seq[k]
'col').props('outlined dense input-style="text-align: right"') commit()
thumb = None ui.button(icon='delete', on_click=del_series).props(
img_path = Path(seq.get(img_key, '')) if seq.get(img_key) else None 'flat dense round size=xs color=negative')
if (img_path and img_path.exists() and with ui.row().classes('text-caption text-grey q-mb-xs'):
img_path.suffix.lower() in IMAGE_EXTENSIONS): ui.label('#').style('width:24px')
img_url = f'/api/image-preview?path={quote(str(img_path))}' ui.label('Width').classes('col')
with ui.dialog() as img_dlg, ui.card().style('max-width:90vw; padding:0'): ui.label('Height').classes('col')
ui.html(f'<img src="{img_url}" ' ui.label('').style('width:28px')
f'style="max-width:80vw;max-height:80vh;display:block">') for idx, entry in enumerate(series):
thumb = ui.html( with ui.row().classes('items-center w-full'):
f'<img src="{img_url}" ' ui.label(str(idx + 1)).classes('text-caption').style('width:24px')
f'style="width:36px;height:36px;object-fit:cover;' w_inp = ui.number(value=int(entry[0]), min=1, step=1).classes(
f'border-radius:4px;cursor:pointer;flex-shrink:0;' 'col').props('outlined dense hide-bottom-space')
f'opacity:{"1.0" if is_on else "0.25"}">' h_inp = ui.number(value=int(entry[1]), min=1, step=1).classes(
).on('click', img_dlg.open) 'col').props('outlined dense hide-bottom-space')
sw = ui.switch(value=is_on)
frame_switches.append(sw) def _sync_wh(i=idx, k=res_key, wi=w_inp, hi=h_inp):
if thumb is not None: seq[k][i] = [
sw.on('update:model-value', int(wi.value) if wi.value else 512,
lambda e, t=thumb, s=sw: t.style(f'opacity: {"1.0" if s.value else "0.25"}')) int(hi.value) if hi.value else 512,
with ui.row().classes('w-full no-wrap q-mt-xs q-gutter-xs'): ]
dict_number('High', seq, hi_key, default=1.0, commit()
step=0.05, format='%.2f').classes('col').props('outlined dense')
dict_number('Low', seq, lo_key, default=1.0, w_inp.on('blur', lambda _, s=_sync_wh: s())
step=0.05, format='%.2f').classes('col').props('outlined dense') h_inp.on('blur', lambda _, s=_sync_wh: s())
def del_row(i=idx, k=res_key):
if i < len(seq.get(k, [])):
seq[k].pop(i)
commit()
ui.button(icon='remove', on_click=del_row).props(
'flat dense round size=xs')
def add_row(k=res_key):
seq[k].append([512, 512])
commit()
ui.button('+ Add row', icon='add', on_click=add_row).props(
'flat dense size=sm').classes('q-mt-xs')
with ui.expansion('Add Resolution Series', icon='straighten').classes('w-full q-mt-sm'):
new_res_key = ui.input('Key name', value='resolutions').props('outlined dense')
def add_res_series():
k = new_res_key.value.strip()
if k and k not in seq:
seq[k] = [[512, 512], [1024, 1024]]
new_res_key.set_value('')
commit()
ui.button('Add', icon='add', on_click=add_res_series).props('outlined dense')
with splitter.after: with splitter.after:
# Mode # Mode
@@ -618,68 +627,31 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
ui.button(icon='casino', on_click=randomize_seed).props('flat') 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, 'Camera', seq, 'camera').props('outlined').classes('w-full')
seq.setdefault('logic index', 0) dict_input(ui.input, 'FLF', seq, 'flf').props('outlined').classes('w-full')
li_input = dict_number('Logic Index', seq, 'logic index').props('outlined readonly').classes('w-full') dict_number('End Frame', seq, 'end_frame').props('outlined').classes('w-full')
with li_input:
ui.tooltip(
'Binary flags — bit 0: start frame | bit 1: middle frame | bit 2: end frame\n'
'0: none 1: start 2: middle 3: start+middle\n'
'4: end 5: start+end 6: middle+end 7: all'
)
dict_input(ui.input, 'Video File Path', seq, 'video file path').props( dict_input(ui.input, 'Video File Path', seq, 'video file path').props(
'outlined input-style="text-align: right"').classes('w-full') 'outlined input-style="direction: rtl"').classes('w-full')
# Switches → logic index (sole writer) # Image paths with preview
def _sync_switches_to_logic(li=li_input, switches=frame_switches, s=seq): for img_label, img_key in [
v = sum(int(sw.value) << b for b, sw in enumerate(switches)) ('Reference Image Path', 'reference image path'),
s['logic index'] = v ('Reference Path', 'reference path'),
li.set_value(v) ('FLF Image Path', 'flf image path'),
]:
for frame_sw in frame_switches: with ui.row().classes('w-full items-center'):
frame_sw.on('update:model-value', lambda _, s=_sync_switches_to_logic: s()) inp = dict_input(ui.input, img_label, seq, img_key).classes(
'col').props('outlined input-style="direction: rtl"')
# --- Resolutions (8 fixed slots) --- img_path = Path(seq.get(img_key, '')) if seq.get(img_key) else None
resolutions = seq.setdefault('resolutions', []) if (img_path and img_path.exists() and
while len(resolutions) < 8: img_path.suffix.lower() in IMAGE_EXTENSIONS):
resolutions.append([512, 512, 0]) with ui.dialog() as dlg, ui.card():
for r_i in range(len(resolutions)): ui.image(str(img_path)).classes('w-full')
if len(resolutions[r_i]) < 3: ui.button(icon='visibility', on_click=dlg.open).props('flat dense')
resolutions[r_i] = list(resolutions[r_i]) + [0]
with ui.expansion('Resolutions', icon='aspect_ratio').classes('w-full'):
for idx in range(8):
entry = resolutions[idx]
with ui.row().classes('items-center w-full q-mt-xs no-wrap'):
ui.label(str(idx)).classes('text-caption').style('min-width:16px')
w_inp = ui.number(value=int(entry[0]), min=1, step=1, label='W').style(
'width:70px').props('outlined dense hide-bottom-space')
h_inp = ui.number(value=int(entry[1]), min=1, step=1, label='H').style(
'width:70px').props('outlined dense hide-bottom-space')
seed_inp = ui.number(value=int(entry[2]), min=0, step=1, label='Seed').style(
'flex:1; min-width:60px').props('outlined dense hide-bottom-space')
async def _sync_entry(r=idx, wi=w_inp, hi=h_inp, si=seed_inp):
seq['resolutions'][r] = [
int(wi.value) if wi.value else 512,
int(hi.value) if hi.value else 512,
int(si.value) if si.value else 0,
]
await commit()
async def _randomize(si=seed_inp, r=idx):
si.value = random.randint(0, 2**32 - 1)
seq['resolutions'][r][2] = int(si.value)
await commit()
ui.button(icon='casino', on_click=_randomize).props(
'flat dense round').classes('q-ml-xs')
w_inp.on('blur', lambda _, s=_sync_entry: s())
w_inp.on('update:model-value', lambda _, s=_sync_entry: s())
h_inp.on('blur', lambda _, s=_sync_entry: s())
h_inp.on('update:model-value', lambda _, s=_sync_entry: s())
seed_inp.on('blur', lambda _, s=_sync_entry: s())
seed_inp.on('update:model-value', lambda _, s=_sync_entry: s())
# --- VACE Settings (full width) --- # --- VACE Settings (full width) ---
with ui.expansion('VACE Settings', icon='settings').classes('w-full'): with ui.expansion('VACE Settings', icon='settings').classes('w-full'):
@@ -725,16 +697,16 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
# --- Custom Parameters --- # --- Custom Parameters ---
ui.label('Custom Parameters').classes('section-header q-mt-md') ui.label('Custom Parameters').classes('section-header q-mt-md')
custom_keys = [k for k in seq.keys() if k not in standard_keys and k != 'resolutions'] custom_keys = [k for k in seq.keys() if k not in standard_keys and not _is_resolution_series(seq.get(k))]
if custom_keys: if custom_keys:
for k in custom_keys: for k in custom_keys:
with ui.row().classes('w-full items-center'): with ui.row().classes('w-full items-center'):
ui.input('Key', value=k).props('readonly outlined dense').classes('w-32') ui.input('Key', value=k).props('readonly outlined dense').classes('w-32')
dict_input(ui.input, 'Value', seq, k).props('outlined dense').classes('col') dict_input(ui.input, 'Value', seq, k).props('outlined dense').classes('col')
async def del_custom(key=k): def del_custom(key=k):
del seq[key] del seq[key]
await commit() commit()
ui.button(icon='delete', on_click=del_custom).props('flat dense color=negative') ui.button(icon='delete', on_click=del_custom).props('flat dense color=negative')
@@ -742,14 +714,14 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
new_k_input = ui.input('Key').props('outlined dense') new_k_input = ui.input('Key').props('outlined dense')
new_v_input = ui.input('Value').props('outlined dense') new_v_input = ui.input('Value').props('outlined dense')
async def add_param(): def add_param():
k = new_k_input.value k = new_k_input.value
v = new_v_input.value v = new_v_input.value
if k and k not in seq: if k and k not in seq:
seq[k] = v seq[k] = v
new_k_input.set_value('') new_k_input.set_value('')
new_v_input.set_value('') new_v_input.set_value('')
await commit() commit()
ui.button('Add', on_click=add_param).props('flat') ui.button('Add', on_click=add_param).props('flat')
@@ -944,26 +916,26 @@ def _render_mass_update(batch_list, data, file_path, state: AppState, refresh_li
batch_list[idx][key] = copy.deepcopy(source_seq.get(key)) batch_list[idx][key] = copy.deepcopy(source_seq.get(key))
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
timeline = SnapshotTimeline(data.get(KEY_HISTORY_TREE, {})) htree = HistoryTree(data.get(KEY_HISTORY_TREE, {}))
snapshot_json = json.dumps({k: v for k, v in data.items() snapshot_json = json.dumps({k: v for k, v in data.items()
if k != KEY_HISTORY_TREE}) if k != KEY_HISTORY_TREE})
snapshot = json.loads(snapshot_json) snapshot = json.loads(snapshot_json)
try: try:
timeline.record(snapshot, f"Mass update: {', '.join(selected_keys)}") htree.commit(snapshot, f"Mass update: {', '.join(selected_keys)}")
except ValueError as e: except ValueError as e:
ui.notify(f'Mass update failed: {e}', type='negative') ui.notify(f'Mass update failed: {e}', type='negative')
return return
if state.db_enabled and state.current_project and state.db: if state.db_enabled and state.current_project and state.db:
full_tree = timeline.to_dict() full_tree = htree.to_dict()
data[KEY_HISTORY_TREE] = full_tree data[KEY_HISTORY_TREE] = full_tree
db_snapshot = json.loads(json.dumps(data)) db_snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, db_snapshot) await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, db_snapshot)
timeline.strip_snapshots() htree.strip_snapshots()
data[KEY_HISTORY_TREE] = timeline.to_dict() data[KEY_HISTORY_TREE] = htree.to_dict()
slim_snapshot = json.loads(json.dumps(data)) slim_snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, slim_snapshot) await asyncio.to_thread(save_json, file_path, slim_snapshot)
else: else:
data[KEY_HISTORY_TREE] = timeline.to_dict() data[KEY_HISTORY_TREE] = htree.to_dict()
save_snapshot = json.loads(json.dumps(data)) save_snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, save_snapshot) await asyncio.to_thread(save_json, file_path, save_snapshot)
ui.notify(f'Updated {len(targets)} sequences', type='positive') ui.notify(f'Updated {len(targets)} sequences', type='positive')
+2 -46
View File
@@ -59,48 +59,6 @@ def render_projects_tab(state: AppState):
ui.button('Create Project', icon='add', on_click=create_project).classes('w-full') 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 --- # --- Active project indicator ---
# Fetch once with file counts and reuse in render_project_list # Fetch once with file counts and reuse in render_project_list
_cached_projects = state.db.list_projects_with_file_counts() _cached_projects = state.db.list_projects_with_file_counts()
@@ -258,10 +216,8 @@ def render_projects_tab(state: AppState):
async def _import_folder(state: AppState, project_id: int, project_name: str, refresh_fn): async def _import_folder(state: AppState, project_id: int, project_name: str, refresh_fn):
"""Bulk import all .json files from the project's folder_path into a project.""" """Bulk import all .json files from current directory into a project."""
proj = state.db.get_project(project_name) json_files = sorted(state.current_dir.glob('*.json'))
scan_dir = Path(proj['folder_path']) if proj else state.current_dir
json_files = sorted(scan_dir.glob('*.json'))
json_files = [f for f in json_files if f.name not in ( json_files = [f for f in json_files if f.name not in (
'.editor_config.json', '.editor_snippets.json')] '.editor_config.json', '.editor_snippets.json')]
+585 -537
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -208,10 +208,10 @@ class TestHistoryTrees:
def test_upsert_updates(self, db): def test_upsert_updates(self, db):
pid = db.create_project("p1", "/p1") pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic") df_id = db.create_data_file(pid, "batch", "generic")
db.save_history_tree(df_id, {"snapshots": {}, "v": 1}) db.save_history_tree(df_id, {"v": 1})
db.save_history_tree(df_id, {"snapshots": {}, "v": 2}) db.save_history_tree(df_id, {"v": 2})
result = db.get_history_tree(df_id) result = db.get_history_tree(df_id)
assert result == {"snapshots": {}, "v": 2} assert result == {"v": 2}
def test_get_nonexistent(self, db): def test_get_nonexistent(self, db):
pid = db.create_project("p1", "/p1") pid = db.create_project("p1", "/p1")
+18 -81
View File
@@ -200,7 +200,7 @@ class TestProjectLoaderDynamic:
assert "sequence_number" in inputs["required"] assert "sequence_number" in inputs["required"]
def test_category(self): def test_category(self):
assert ProjectLoaderDynamic.CATEGORY == "JSON Manager/project" assert ProjectLoaderDynamic.CATEGORY == "utils/json/project"
class TestProjectSource: class TestProjectSource:
@@ -232,7 +232,7 @@ class TestProjectSource:
def test_category(self): def test_category(self):
from project_loader import ProjectSource from project_loader import ProjectSource
assert ProjectSource.CATEGORY == "JSON Manager/project" assert ProjectSource.CATEGORY == "utils/json/project"
class TestProjectKey: class TestProjectKey:
@@ -341,7 +341,7 @@ class TestProjectKey:
def test_category(self): def test_category(self):
from project_loader import ProjectKey from project_loader import ProjectKey
assert ProjectKey.CATEGORY == "JSON Manager/project" assert ProjectKey.CATEGORY == "utils/json/project"
class TestProjectResolution: class TestProjectResolution:
@@ -353,59 +353,46 @@ class TestProjectResolution:
assert "index" in inputs["required"] assert "index" in inputs["required"]
assert inputs["required"]["index"][0] == "INT" assert inputs["required"]["index"][0] == "INT"
def test_three_outputs(self): def test_two_outputs(self):
from project_loader import ProjectResolution from project_loader import ProjectResolution
assert ProjectResolution.RETURN_TYPES == ("INT", "INT", "INT") assert ProjectResolution.RETURN_TYPES == ("INT", "INT")
assert ProjectResolution.RETURN_NAMES == ("width", "height", "seed") assert ProjectResolution.RETURN_NAMES == ("width", "height")
def test_fetch_resolution_basic(self): def test_fetch_resolution_basic(self):
from project_loader import ProjectResolution from project_loader import ProjectResolution
node = ProjectResolution() node = ProjectResolution()
data = {"resolutions": [[512, 512, 0], [768, 1344, 12345], [1344, 768, 99]]} data = {"resolutions": [[512, 512], [768, 1344], [1344, 768]]}
with patch("project_loader._fetch_data", return_value=data): with patch("project_loader._fetch_data", return_value=data):
result = node.fetch_resolution( result = node.fetch_resolution(
source_label="src", key_name="resolutions", index=1, source_label="src", key_name="resolutions", index=1,
manager_url="http://localhost:8080", project_name="p", manager_url="http://localhost:8080", project_name="p",
file_name="f", sequence_number=1, file_name="f", sequence_number=1,
) )
assert result == (768, 1344, 12345) assert result == (768, 1344)
def test_fetch_resolution_index_zero(self): def test_fetch_resolution_index_zero(self):
from project_loader import ProjectResolution from project_loader import ProjectResolution
node = ProjectResolution() node = ProjectResolution()
data = {"resolutions": [[512, 512, 42], [1024, 1024, 0]]} data = {"resolutions": [[512, 512], [1024, 1024]]}
with patch("project_loader._fetch_data", return_value=data): with patch("project_loader._fetch_data", return_value=data):
result = node.fetch_resolution( result = node.fetch_resolution(
source_label="src", key_name="resolutions", index=0, source_label="src", key_name="resolutions", index=0,
manager_url="http://localhost:8080", project_name="p", manager_url="http://localhost:8080", project_name="p",
file_name="f", sequence_number=1, file_name="f", sequence_number=1,
) )
assert result == (512, 512, 42) assert result == (512, 512)
def test_fetch_resolution_clamps_on_out_of_bounds(self): def test_fetch_resolution_clamps_on_out_of_bounds(self):
from project_loader import ProjectResolution from project_loader import ProjectResolution
node = ProjectResolution() node = ProjectResolution()
data = {"resolutions": [[512, 512, 0], [1024, 1024, 7]]} data = {"resolutions": [[512, 512], [1024, 1024]]}
with patch("project_loader._fetch_data", return_value=data): with patch("project_loader._fetch_data", return_value=data):
result = node.fetch_resolution( result = node.fetch_resolution(
source_label="src", key_name="resolutions", index=99, source_label="src", key_name="resolutions", index=99,
manager_url="http://localhost:8080", project_name="p", manager_url="http://localhost:8080", project_name="p",
file_name="f", sequence_number=1, file_name="f", sequence_number=1,
) )
assert result == (1024, 1024, 7) # last entry assert result == (1024, 1024) # last entry
def test_fetch_resolution_old_format_no_seed(self):
"""Old [w, h] entries without seed should return seed=0."""
from project_loader import ProjectResolution
node = ProjectResolution()
data = {"resolutions": [[576, 384], [960, 640]]}
with patch("project_loader._fetch_data", return_value=data):
result = node.fetch_resolution(
source_label="src", key_name="resolutions", index=0,
manager_url="http://localhost:8080", project_name="p",
file_name="f", sequence_number=1,
)
assert result == (576, 384, 0)
def test_fetch_resolution_missing_key_returns_defaults(self): def test_fetch_resolution_missing_key_returns_defaults(self):
from project_loader import ProjectResolution from project_loader import ProjectResolution
@@ -416,7 +403,7 @@ class TestProjectResolution:
manager_url="http://localhost:8080", project_name="p", manager_url="http://localhost:8080", project_name="p",
file_name="f", sequence_number=1, file_name="f", sequence_number=1,
) )
assert result == (512, 512, 0) assert result == (512, 512)
def test_fetch_resolution_network_error_returns_defaults(self): def test_fetch_resolution_network_error_returns_defaults(self):
from project_loader import ProjectResolution from project_loader import ProjectResolution
@@ -428,7 +415,7 @@ class TestProjectResolution:
manager_url="http://localhost:8080", project_name="p", manager_url="http://localhost:8080", project_name="p",
file_name="f", sequence_number=1, file_name="f", sequence_number=1,
) )
assert result == (512, 512, 0) assert result == (512, 512)
def test_fetch_resolution_malformed_entry_returns_defaults(self): def test_fetch_resolution_malformed_entry_returns_defaults(self):
from project_loader import ProjectResolution from project_loader import ProjectResolution
@@ -440,60 +427,11 @@ class TestProjectResolution:
manager_url="http://localhost:8080", project_name="p", manager_url="http://localhost:8080", project_name="p",
file_name="f", sequence_number=1, file_name="f", sequence_number=1,
) )
assert result == (512, 512, 0) assert result == (512, 512)
def test_category(self): def test_category(self):
from project_loader import ProjectResolution from project_loader import ProjectResolution
assert ProjectResolution.CATEGORY == "JSON Manager/project" assert ProjectResolution.CATEGORY == "utils/json/project"
class TestBinaryIndexDecoder:
def test_input_types(self):
from project_loader import BinaryIndexDecoder
inputs = BinaryIndexDecoder.INPUT_TYPES()
assert "index" in inputs["required"]
assert inputs["required"]["index"][0] == "INT"
def test_three_boolean_outputs(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder.RETURN_TYPES == ("BOOLEAN", "BOOLEAN", "BOOLEAN")
assert BinaryIndexDecoder.RETURN_NAMES == ("flag_0", "flag_1", "flag_2")
def test_category(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder.CATEGORY == "JSON Manager/utils"
def test_index_0(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(0) == (False, False, False)
def test_index_1(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(1) == (True, False, False)
def test_index_2(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(2) == (False, True, False)
def test_index_3(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(3) == (True, True, False)
def test_index_4(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(4) == (False, False, True)
def test_index_5(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(5) == (True, False, True)
def test_index_6(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(6) == (False, True, True)
def test_index_7(self):
from project_loader import BinaryIndexDecoder
assert BinaryIndexDecoder().decode(7) == (True, True, True)
class TestNodeMappings: class TestNodeMappings:
@@ -503,6 +441,5 @@ class TestNodeMappings:
assert "ProjectSource" in PROJECT_NODE_CLASS_MAPPINGS assert "ProjectSource" in PROJECT_NODE_CLASS_MAPPINGS
assert "ProjectKey" in PROJECT_NODE_CLASS_MAPPINGS assert "ProjectKey" in PROJECT_NODE_CLASS_MAPPINGS
assert "ProjectResolution" 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) == 4
assert len(PROJECT_NODE_CLASS_MAPPINGS) == 5 assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 4
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 5
-159
View File
@@ -1,159 +0,0 @@
import pytest
from snapshot_timeline import SnapshotTimeline, diff_snapshots
def test_record_creates_snapshot():
tl = SnapshotTimeline({})
sid = tl.record({"batch_data": [{"seed": 42}]}, note="first")
assert sid in tl.snapshots
assert tl.current_id == sid
assert tl.snapshots[sid]["note"] == "first"
assert tl.snapshots[sid]["auto"] is False
assert tl.snapshots[sid]["seq_count"] == 1
def test_record_auto_flag():
tl = SnapshotTimeline({})
sid = tl.record({"batch_data": []}, note="auto save", auto=True)
assert tl.snapshots[sid]["auto"] is True
def test_multiple_records():
tl = SnapshotTimeline({})
id1 = tl.record({"batch_data": [{"a": 1}]}, note="one")
id2 = tl.record({"batch_data": [{"b": 2}]}, note="two")
assert len(tl.snapshots) == 2
assert tl.current_id == id2
def test_to_dict_roundtrip():
tl = SnapshotTimeline({})
tl.record({"batch_data": [{"x": 1}]}, note="test")
d = tl.to_dict()
tl2 = SnapshotTimeline(d)
assert tl2.current_id == tl.current_id
assert set(tl2.snapshots.keys()) == set(tl.snapshots.keys())
def test_migrate_from_history_tree():
"""Old HistoryTree format should be flattened into snapshots."""
old_data = {
"nodes": {
"aaa": {"id": "aaa", "parent": None, "timestamp": 1000, "note": "First", "data": {"batch_data": [{"seed": 1}]}},
"bbb": {"id": "bbb", "parent": "aaa", "timestamp": 2000, "note": "Second", "data": {"batch_data": [{"seed": 2}]}},
},
"branches": {"main": "bbb"},
"head_id": "bbb",
}
tl = SnapshotTimeline(old_data)
assert len(tl.snapshots) == 2
assert tl.current_id == "bbb"
assert tl.snapshots["aaa"]["note"] == "First"
assert tl.snapshots["bbb"]["note"] == "Second"
# Data should be preserved
assert tl.snapshots["aaa"]["data"]["batch_data"] == [{"seed": 1}]
def test_migrate_from_history_tree_no_data():
"""Slim tree nodes (no inline data) should still migrate."""
old_data = {
"nodes": {
"aaa": {"id": "aaa", "parent": None, "timestamp": 1000, "note": "First"},
},
"branches": {"main": "aaa"},
"head_id": "aaa",
}
tl = SnapshotTimeline(old_data)
assert len(tl.snapshots) == 1
assert tl.snapshots["aaa"]["seq_count"] == 0
def test_migrate_legacy_prompt_history():
legacy = {
"prompt_history": [
{"note": "A", "seed": 1},
{"note": "B", "seed": 2},
]
}
tl = SnapshotTimeline(legacy)
assert len(tl.snapshots) == 2
assert tl.current_id is not None
def test_toggle_pin():
tl = SnapshotTimeline({})
sid = tl.record({"batch_data": []}, note="test")
assert tl.snapshots[sid]["pinned"] is False
result = tl.toggle_pin(sid)
assert result is True
assert tl.snapshots[sid]["pinned"] is True
result = tl.toggle_pin(sid)
assert result is False
def test_delete_snapshot():
tl = SnapshotTimeline({})
id1 = tl.record({"batch_data": []}, note="one")
id2 = tl.record({"batch_data": []}, note="two")
tl.delete(id2)
assert id2 not in tl.snapshots
assert tl.current_id == id1
def test_delete_all_snapshots():
tl = SnapshotTimeline({})
sid = tl.record({"batch_data": []}, note="only")
tl.delete(sid)
assert len(tl.snapshots) == 0
assert tl.current_id is None
def test_strip_snapshots():
tl = SnapshotTimeline({})
tl.record({"batch_data": [{"a": 1}]}, note="test")
tl.strip_snapshots()
for snap in tl.snapshots.values():
assert "data" not in snap
def test_get_snapshot_data():
tl = SnapshotTimeline({})
sid = tl.record({"batch_data": [{"x": 1}]}, note="test")
data = tl.get_snapshot_data(sid)
assert data == {"batch_data": [{"x": 1}]}
assert tl.get_snapshot_data("nonexistent") is None
# --- diff_snapshots tests ---
def test_diff_unchanged():
batch = [{"sequence_number": 1, "seed": 42}]
result = diff_snapshots(batch, batch)
assert len(result) == 1
assert result[0]["status"] == "unchanged"
assert result[0]["changes"] == []
def test_diff_changed():
old = [{"sequence_number": 1, "seed": 42, "cfg": 1.5}]
new = [{"sequence_number": 1, "seed": 99, "cfg": 1.5}]
result = diff_snapshots(old, new)
assert result[0]["status"] == "changed"
assert len(result[0]["changes"]) == 1
assert result[0]["changes"][0]["field"] == "seed"
assert result[0]["changes"][0]["old"] == 42
assert result[0]["changes"][0]["new"] == 99
def test_diff_added_and_removed():
old = [{"sequence_number": 1, "seed": 1}]
new = [{"sequence_number": 2, "seed": 2}]
result = diff_snapshots(old, new)
assert len(result) == 2
statuses = {r["seq_num"]: r["status"] for r in result}
assert statuses[1] == "removed"
assert statuses[2] == "added"
def test_diff_empty():
assert diff_snapshots([], []) == []
+19 -65
View File
@@ -28,14 +28,16 @@ DEFAULTS = {
"current_prompt": "", "current_prompt": "",
"negative": "", "negative": "",
"seed": -1, "seed": -1,
"cfg": 1.5,
# --- Settings --- # --- Settings ---
"mode": 0, "mode": 0,
"camera": "static", "camera": "static",
"flf": 0.0,
# --- I2V / VACE Specifics --- # --- I2V / VACE Specifics ---
"frame_to_skip": 81, "frame_to_skip": 81,
"logic index": 0, "end_frame": 0,
"transition": "1-2", "transition": "1-2",
"vace_length": 49, "vace_length": 49,
"vace schedule": 1, "vace schedule": 1,
@@ -43,15 +45,9 @@ DEFAULTS = {
"input_b_frames": 16, "input_b_frames": 16,
"reference switch": 1, "reference switch": 1,
"video file path": "", "video file path": "",
"start frame path": "", "reference image path": "",
"start frame high strength": 1.0, "reference path": "",
"start frame low strength": 1.0, "flf image path": "",
"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) --- # --- LoRAs (name as STRING, strength as FLOAT) ---
"lora 1 high": "", "lora 1 high": "",
@@ -154,37 +150,6 @@ def save_snippets(snippets):
json.dump(snippets, f, indent=4) json.dump(snippets, f, indent=4)
os.replace(tmp, SNIPPETS_FILE) os.replace(tmp, SNIPPETS_FILE)
_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: def _migrate_lora_keys(data: dict) -> None:
"""Split combined lora 'name:strength' into separate name and strength keys. """Split combined lora 'name:strength' into separate name and strength keys.
@@ -243,8 +208,6 @@ def load_json(path: str | Path) -> tuple[dict[str, Any], float]:
with open(path, 'r') as f: with open(path, 'r') as f:
data = json.load(f) data = json.load(f)
t1 = time.time() t1 = time.time()
_migrate_remove_keys(data)
_migrate_key_renames(data)
_migrate_lora_keys(data) _migrate_lora_keys(data)
t2 = time.time() t2 = time.time()
mtime = path.stat().st_mtime mtime = path.stat().st_mtime
@@ -342,47 +305,38 @@ def sync_to_db(db, project_name: str, file_path: Path, data: dict) -> None:
else: else:
db.conn.execute("DELETE FROM sequences WHERE data_file_id = ?", (df_id,)) db.conn.execute("DELETE FROM sequences WHERE data_file_id = ?", (df_id,))
# Sync history tree (extract snapshot data into separate table) # Sync history tree (extract node snapshots into separate table)
# Supports both new format (snapshots dict) and old format (nodes dict)
history_tree = data.get(KEY_HISTORY_TREE) history_tree = data.get(KEY_HISTORY_TREE)
if history_tree and isinstance(history_tree, dict): if history_tree and isinstance(history_tree, dict):
# Detect format: new has "snapshots", old has "nodes" nodes = history_tree.get("nodes", {})
if "snapshots" in history_tree:
entries = history_tree.get("snapshots", {})
else:
entries = history_tree.get("nodes", {})
slim_tree = dict(history_tree) slim_tree = dict(history_tree)
slim_entries = {} slim_nodes = {}
for eid, entry in entries.items(): for nid, node in nodes.items():
snap = entry.get("data") snap = node.get("data")
if snap: if snap:
db.conn.execute( db.conn.execute(
"INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) " "INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) "
"VALUES (?, ?, ?, ?) " "VALUES (?, ?, ?, ?) "
"ON CONFLICT(data_file_id, node_id) DO UPDATE SET " "ON CONFLICT(data_file_id, node_id) DO UPDATE SET "
"snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at", "snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at",
(df_id, eid, json.dumps(snap), now), (df_id, nid, json.dumps(snap), now),
) )
slim_entries[eid] = {k: v for k, v in entry.items() if k != "data"} slim_nodes[nid] = {k: v for k, v in node.items() if k != "data"}
# Write back slim version using the correct key slim_tree["nodes"] = slim_nodes
if "snapshots" in history_tree:
slim_tree["snapshots"] = slim_entries
else:
slim_tree["nodes"] = slim_entries
db.conn.execute( db.conn.execute(
"INSERT INTO history_trees (data_file_id, tree_data, updated_at) " "INSERT INTO history_trees (data_file_id, tree_data, updated_at) "
"VALUES (?, ?, ?) " "VALUES (?, ?, ?) "
"ON CONFLICT(data_file_id) DO UPDATE SET tree_data=excluded.tree_data, updated_at=excluded.updated_at", "ON CONFLICT(data_file_id) DO UPDATE SET tree_data=excluded.tree_data, updated_at=excluded.updated_at",
(df_id, json.dumps(slim_tree), now), (df_id, json.dumps(slim_tree), now),
) )
# Clean up orphaned snapshots # Clean up orphaned snapshots for nodes no longer in tree
current_ids = set(entries.keys()) current_node_ids = set(nodes.keys())
if current_ids: if current_node_ids:
placeholders = ",".join("?" for _ in current_ids) placeholders = ",".join("?" for _ in current_node_ids)
db.conn.execute( db.conn.execute(
f"DELETE FROM history_snapshots WHERE data_file_id = ? " f"DELETE FROM history_snapshots WHERE data_file_id = ? "
f"AND node_id NOT IN ({placeholders})", f"AND node_id NOT IN ({placeholders})",
(df_id, *current_ids), (df_id, *current_node_ids),
) )
else: else:
db.conn.execute( db.conn.execute(
-20
View File
@@ -1,20 +0,0 @@
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,60 +201,6 @@ app.registerExtension({
app.graph?.setDirtyCanvas(true, true); app.graph?.setDirtyCanvas(true, true);
}; };
// --- Show live value on output slot after execution (INT/FLOAT/BOOL only) ---
nodeType.prototype.onExecuted = function (output) {
if (!this.outputs.length) return;
const val = output?.value?.[0];
if (val === undefined) return;
const keyWidget = this.widgets?.find(w => w.name === "key_name");
const name = keyWidget?.value || this.outputs[0].name;
this.outputs[0].label = `${val} ${name}`;
const slotType = this.outputs[0].type;
const TYPE_COLORS = { "INT": "#3d7eb5", "FLOAT": "#68a468", "BOOLEAN": null };
let color;
if (slotType === "BOOLEAN") {
color = (val === "true") ? "#4caf50" : "#888888";
} else {
color = TYPE_COLORS[slotType]
?? LGraphCanvas?.link_type_colors?.[slotType]
?? app.canvas?.default_connection_color_byType?.[slotType];
}
if (color) {
this.outputs[0].color_on = color;
this.outputs[0].color_off = color;
}
app.graph?.setDirtyCanvas(true, true);
};
// --- Highlight all ProjectKey nodes sharing the same key_name on select ---
nodeType.prototype.onSelected = function () {
const keyWidget = this.widgets?.find(w => w.name === "key_name");
const myKey = keyWidget?.value;
if (!myKey || !this.graph) return;
for (const node of this.graph._nodes) {
if (node === this || node.type !== "ProjectKey") continue;
const kw = node.widgets?.find(w => w.name === "key_name");
if (kw?.value !== myKey) continue;
node._savedColor = node.color;
node._savedBgColor = node.bgcolor;
node.color = "#c8a000";
node.bgcolor = "#4a3800";
}
app.graph?.setDirtyCanvas(true, true);
};
nodeType.prototype.onDeselected = function () {
if (!this.graph) return;
for (const node of this.graph._nodes) {
if (node.type !== "ProjectKey" || !("_savedColor" in node)) continue;
node.color = node._savedColor;
node.bgcolor = node._savedBgColor;
delete node._savedColor;
delete node._savedBgColor;
}
app.graph?.setDirtyCanvas(true, true);
};
// --- Sync config on click (lazy, no key refresh to avoid race) --- // --- Sync config on click (lazy, no key refresh to avoid race) ---
const origOnMouseDown = nodeType.prototype.onMouseDown; const origOnMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) { nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) {
+1 -1
View File
@@ -65,7 +65,7 @@ app.registerExtension({
node.title = value ? `Resolution: ${value}` : "Project Resolution"; node.title = value ? `Resolution: ${value}` : "Project Resolution";
app.graph?.setDirtyCanvas(true, true); app.graph?.setDirtyCanvas(true, true);
}); });
if (keyCombo && !keyCombo.value) keyCombo.value = "resolutions"; if (keyCombo) keyCombo.value = "";
queueMicrotask(() => { queueMicrotask(() => {
if (!this._configured) { if (!this._configured) {
+25 -67
View File
@@ -28,35 +28,6 @@ app.registerExtension({
return combo; return combo;
} }
// Fetch active project from Manager and update project_name + title
async function refreshActiveProject(node) {
const urlW = node.widgets?.find(w => w.name === "manager_url");
if (!urlW?.value) return;
try {
const resp = await fetch(`${urlW.value}/api/active-project`);
if (!resp.ok) return;
const data = await resp.json();
const project = data.project || "";
const projW = node.widgets?.find(w => w.name === "project_name");
if (projW && projW.value !== project) {
projW.value = project;
await refreshFiles(node);
}
_updateTitle(node);
} catch (e) {
console.warn("[ProjectSource] Failed to fetch active project:", e);
}
}
function _updateTitle(node) {
const labelW = node.widgets?.find(w => w.name === "label");
const projW = node.widgets?.find(w => w.name === "project_name");
const label = labelW?.value || "";
const project = projW?.value || "?";
node.title = label ? `Source: ${label} [${project}]` : `Project Source [${project}]`;
app.graph?.setDirtyCanvas(true, true);
}
// Fetch file list from API and update file_name combo // Fetch file list from API and update file_name combo
async function refreshFiles(node) { async function refreshFiles(node) {
const urlW = node.widgets?.find(w => w.name === "manager_url"); const urlW = node.widgets?.find(w => w.name === "manager_url");
@@ -113,29 +84,23 @@ app.registerExtension({
const node = this; const node = this;
// Hide project_name — it is auto-filled from the Manager's active project
const projW = this.widgets?.find(w => w.name === "project_name");
if (projW) {
if (projW.origType === undefined) projW.origType = projW.type;
projW.type = "hidden";
projW.hidden = true;
projW.computeSize = () => [0, -4];
}
// Replace file_name STRING with a combo // Replace file_name STRING with a combo
replaceWithCombo(this, "file_name", [], function (value) { replaceWithCombo(this, "file_name", [], function (value) {
notifyRelays(node); notifyRelays(node);
}); });
// Hook manager_url to refresh active project + files + notify relays // Hook manager_url and project_name to refresh file list + notify relays
const urlW = this.widgets?.find(w => w.name === "manager_url"); for (const name of ["manager_url", "project_name"]) {
if (urlW) { const w = this.widgets?.find(w => w.name === name);
const origCb = urlW.callback; if (w) {
urlW.callback = function (...args) { const origCb = w.callback;
w.callback = function (...args) {
origCb?.apply(this, args); origCb?.apply(this, args);
refreshActiveProject(node).then(() => notifyRelays(node)); refreshFiles(node);
notifyRelays(node);
}; };
} }
}
// Hook sequence_number to notify relays // Hook sequence_number to notify relays
const seqW = this.widgets?.find(w => w.name === "sequence_number"); const seqW = this.widgets?.find(w => w.name === "sequence_number");
@@ -153,27 +118,22 @@ app.registerExtension({
const origCallback = labelWidget.callback; const origCallback = labelWidget.callback;
labelWidget.callback = function (...args) { labelWidget.callback = function (...args) {
origCallback?.apply(this, args); origCallback?.apply(this, args);
_updateTitle(node); node.title = labelWidget.value
? `Source: ${labelWidget.value}`
: "Project Source";
app.graph?.setDirtyCanvas(true, true);
}; };
// Set initial title
if (labelWidget.value) {
this.title = `Source: ${labelWidget.value}`;
}
} }
// Auto-fetch active project on creation
queueMicrotask(() => refreshActiveProject(node));
}; };
const origOnConfigure = nodeType.prototype.onConfigure; const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) { nodeType.prototype.onConfigure = function (info) {
origOnConfigure?.apply(this, arguments); origOnConfigure?.apply(this, arguments);
// Hide project_name (may have been serialized as visible)
const projW = this.widgets?.find(w => w.name === "project_name");
if (projW) {
if (projW.origType === undefined) projW.origType = projW.type;
projW.type = "hidden";
projW.hidden = true;
projW.computeSize = () => [0, -4];
}
// Ensure file_name is a combo (may be STRING from serialization) // Ensure file_name is a combo (may be STRING from serialization)
const fileW = this.widgets?.find(w => w.name === "file_name"); const fileW = this.widgets?.find(w => w.name === "file_name");
if (fileW && fileW.type !== "combo") { if (fileW && fileW.type !== "combo") {
@@ -183,18 +143,16 @@ app.registerExtension({
}); });
} }
_updateTitle(this); const labelWidget = this.widgets?.find(w => w.name === "label");
if (labelWidget?.value) {
this.title = `Source: ${labelWidget.value}`;
}
// Deferred: fetch active project (and files) once graph is ready // Deferred: refresh file list once graph is ready
const node = this; const node = this;
queueMicrotask(() => refreshActiveProject(node)); queueMicrotask(() => {
}; refreshFiles(node);
});
// Re-check active project on click (picks up changes made in the Manager)
const origOnMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) {
origOnMouseDown?.apply(this, arguments);
refreshActiveProject(this);
}; };
}, },
}); });