Add Pool Profiles design + implementation plan
Named, portable profiles for the Image Pool: a Pool Profile companion node (create/select/rename/delete/duplicate/export-import) outputs a POOL_PROFILE id into the pool's new optional input; profile or pool_id wins. Registry (name->id) in profiles.json; live edit-time grid switch via cross-node propagation. TDD plan with a pure stdlib profiles layer incl. zip round-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
# Pool Profiles (companion node) — Design
|
||||||
|
|
||||||
|
Date: 2026-06-21
|
||||||
|
Status: Approved (brainstorming complete, ready for implementation plan)
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Make Image Pool contents durable and reusable across workflows via named **profiles**.
|
||||||
|
A companion node `Pool Profile` creates/selects/manages named profiles and feeds the chosen
|
||||||
|
one into an Image Pool node, so the same set of images (with their masks/labels) can be
|
||||||
|
reloaded in any workflow by picking the profile. Profiles are also portable (zip
|
||||||
|
export/import) to move between machines.
|
||||||
|
|
||||||
|
## 2. Storage / registry
|
||||||
|
|
||||||
|
`input/grid_pool/profiles.json` maps a friendly **name → stable id**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "profiles": [ {"id": "<uuid>", "name": "characters_A", "created": 1718960000} ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Each profile's data stays in the existing layout `input/grid_pool/<id>/` (manifest.json +
|
||||||
|
images + masks). **Backward compatible:** existing random-UUID pools are simply unregistered
|
||||||
|
ids and keep working unchanged.
|
||||||
|
|
||||||
|
## 3. Nodes
|
||||||
|
|
||||||
|
### `Pool Profile` (companion, new)
|
||||||
|
- Widgets: `profile` (dropdown of names, JS-populated) + hidden `profile_id` (JS-owned, like
|
||||||
|
`pool_id`).
|
||||||
|
- Buttons: **Create, Rename, Delete, Duplicate/Save-as, Export, Import**.
|
||||||
|
- Output: `POOL_PROFILE` = the selected profile id.
|
||||||
|
- `run()` returns `profile_id or "default"`; `IS_CHANGED` returns `profile_id` (so a
|
||||||
|
selection change re-runs downstream).
|
||||||
|
|
||||||
|
### `Image Pool (Grid)` (existing, change — backward compatible)
|
||||||
|
- New **optional input `profile`** (`POOL_PROFILE`).
|
||||||
|
- `run()`/`_resolve()`/`IS_CHANGED` use `effective = profile or pool_id` (connected id wins).
|
||||||
|
- With nothing connected, behaves exactly as today (per-node UUID).
|
||||||
|
|
||||||
|
## 4. Live edit-time sync (key UX)
|
||||||
|
|
||||||
|
Modeled on `ComfyUI-JSON-Manager/web/project_key.js`: when the companion's selection changes
|
||||||
|
(or on connect), it walks its `POOL_PROFILE` output links, sets each connected pool node's
|
||||||
|
hidden `pool_id` widget to the profile id, and calls that pool's refresh. So selecting a
|
||||||
|
profile instantly shows its images in the grid, and adds/masks land in that profile. The
|
||||||
|
pool JS exposes a `node._datasetePoolRefresh()` hook for the companion to call.
|
||||||
|
|
||||||
|
## 5. Server routes (`/grid_pool/profiles/*`)
|
||||||
|
|
||||||
|
`list` (GET), `create` `{name}`, `rename` `{id,name}`, `delete` `{id}`, `duplicate`
|
||||||
|
`{id,name}`, `export` (GET `?id=` → streams a zip), `import` (multipart zip [+name] → new id).
|
||||||
|
The route layer generates UUIDs; the pure layer takes ids as params (testable).
|
||||||
|
|
||||||
|
## 6. Code shape
|
||||||
|
|
||||||
|
- `gates/profiles.py` — pure stdlib: registry read/write (atomic), `find_by_id/name`,
|
||||||
|
`create/rename/delete/duplicate`, and `export_profile`/`import_profile` (zipfile). Unit-
|
||||||
|
testable with tmp dirs; no comfy/torch.
|
||||||
|
- `gates/profiles_routes.py` — aiohttp glue (uuid gen, file streaming).
|
||||||
|
- `gates/profile_node.py` — the `PoolProfile` node.
|
||||||
|
- `web/pool_profile.js` — dropdown + action buttons + cross-node propagation.
|
||||||
|
- `gates/node.py` + `web/grid_image_pool.js` — small additive tweak: optional `profile`
|
||||||
|
input, `effective` id, and the `_datasetePoolRefresh` hook.
|
||||||
|
|
||||||
|
## 7. Edge cases
|
||||||
|
|
||||||
|
- Duplicate/import name collision → auto-suffix `name (2)`; create/rename reject duplicates.
|
||||||
|
- Delete removes the dir (`shutil.rmtree`) and the registry entry.
|
||||||
|
- Corrupt/missing `profiles.json` → treated as empty registry.
|
||||||
|
- Import zip carries a `profile_meta.json` (original name) under an internal `pool/` prefix;
|
||||||
|
imported under a fresh id so it never clobbers an existing profile.
|
||||||
|
- Profile connected then disconnected → pool keeps the last id (the profile); no data loss.
|
||||||
|
|
||||||
|
## 8. Phasing & testing
|
||||||
|
|
||||||
|
- **Phase 1**: `profiles.py` (registry + create/select) + `PoolProfile` node + routes +
|
||||||
|
frontend dropdown + live sync into the pool.
|
||||||
|
- **Phase 2**: rename / delete / duplicate.
|
||||||
|
- **Phase 3**: export / import (portable zip).
|
||||||
|
- **Phase 4 (optional)**: "adopt" an existing unnamed pool into a profile.
|
||||||
|
|
||||||
|
Testing: pytest for `profiles.py` (CRUD, duplicate copies images, export→import round-trips a
|
||||||
|
profile with its files); manual for dropdown, cross-node propagation, and the zip UI.
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
# Pool Profiles Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add named, portable **profiles** to the Image Pool — a `Pool Profile` companion node (create/select/rename/delete/duplicate/export/import) that feeds a `POOL_PROFILE` id into the Image Pool node, with live edit-time grid switching.
|
||||||
|
|
||||||
|
**Architecture:** A pure stdlib `gates/profiles.py` manages a `profiles.json` registry (name→id) plus per-profile dir ops and zip export/import — fully unit-testable. `gates/profile_node.py` is the companion node (outputs the id). The existing pool node gains an optional `profile` input and uses `profile or pool_id`. `gates/profiles_routes.py` is the aiohttp glue (uuid gen + zip streaming). Frontend (`web/pool_profile.js`) drives a dropdown + action buttons and propagates the selected id into connected pool nodes (project_key.js style); a small `grid_image_pool.js` tweak accepts the input + exposes a refresh hook.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12 (stdlib: json/shutil/zipfile), aiohttp, pytest 9; vanilla JS frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions (read once)
|
||||||
|
|
||||||
|
- **Test python:** `/media/p5/miniforge3/bin/python` (`PY=...`).
|
||||||
|
- **Run tests:** `cd /media/p5/ComfyUI-Datasete-Gates && $PY -m pytest tests/test_profiles.py -v`
|
||||||
|
- `gates/profiles.py` MUST be stdlib-only (no comfy/torch); ids are passed in as params
|
||||||
|
(UUIDs are generated in the route layer) so tests are deterministic.
|
||||||
|
- Edits to `gates/node.py`, `web/grid_image_pool.js`, `__init__.py` are **additive** —
|
||||||
|
re-Read first, keep existing Image Pool behavior, run full suite after.
|
||||||
|
- Base dir for all profile ops = `gates_compat.grid_pool_base()` (= `input/grid_pool`).
|
||||||
|
- Concurrency: stage only this feature's paths per commit. Commit style: Conventional
|
||||||
|
Commits + repo Co-Authored-By trailer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `profiles.py` — registry read/write + find helpers
|
||||||
|
|
||||||
|
**Files:** Create `gates/profiles.py`; Test `tests/test_profiles.py`
|
||||||
|
|
||||||
|
**Step 1: Failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_profiles.py
|
||||||
|
from gates import profiles as pr
|
||||||
|
|
||||||
|
def test_empty_registry():
|
||||||
|
assert pr.empty_registry() == {"profiles": []}
|
||||||
|
|
||||||
|
def test_read_missing_is_empty(tmp_path):
|
||||||
|
assert pr.read_registry(str(tmp_path)) == {"profiles": []}
|
||||||
|
|
||||||
|
def test_write_then_read(tmp_path):
|
||||||
|
reg = {"profiles": [{"id": "a", "name": "n", "created": 1}]}
|
||||||
|
pr.write_registry(str(tmp_path), reg)
|
||||||
|
assert (tmp_path / "profiles.json").exists()
|
||||||
|
assert pr.read_registry(str(tmp_path)) == reg
|
||||||
|
|
||||||
|
def test_read_corrupt_is_empty(tmp_path):
|
||||||
|
(tmp_path / "profiles.json").write_text("{ not json")
|
||||||
|
assert pr.read_registry(str(tmp_path)) == {"profiles": []}
|
||||||
|
|
||||||
|
def test_find_helpers():
|
||||||
|
reg = {"profiles": [{"id": "a", "name": "x"}, {"id": "b", "name": "y"}]}
|
||||||
|
assert pr.find_by_id(reg, "b")["name"] == "y"
|
||||||
|
assert pr.find_by_name(reg, "x")["id"] == "a"
|
||||||
|
assert pr.find_by_id(reg, "z") is None
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run → FAIL.**
|
||||||
|
|
||||||
|
**Step 3: Implement**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# gates/profiles.py
|
||||||
|
"""Named-profile registry + dir ops for the Image Pool. Stdlib only."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REGISTRY_NAME = "profiles.json"
|
||||||
|
|
||||||
|
|
||||||
|
def registry_path(base):
|
||||||
|
return Path(base) / REGISTRY_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def empty_registry():
|
||||||
|
return {"profiles": []}
|
||||||
|
|
||||||
|
|
||||||
|
def read_registry(base):
|
||||||
|
p = registry_path(base)
|
||||||
|
if not p.exists():
|
||||||
|
return empty_registry()
|
||||||
|
try:
|
||||||
|
with open(p, "r", encoding="utf-8") as f:
|
||||||
|
reg = json.load(f)
|
||||||
|
if not isinstance(reg, dict) or "profiles" not in reg:
|
||||||
|
raise ValueError("bad registry")
|
||||||
|
return reg
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
return empty_registry()
|
||||||
|
|
||||||
|
|
||||||
|
def write_registry(base, reg):
|
||||||
|
Path(base).mkdir(parents=True, exist_ok=True)
|
||||||
|
final = registry_path(base)
|
||||||
|
tmp = final.with_name(REGISTRY_NAME + ".tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(reg, f, indent=2)
|
||||||
|
os.replace(tmp, final)
|
||||||
|
return reg
|
||||||
|
|
||||||
|
|
||||||
|
def find_by_id(reg, pid):
|
||||||
|
return next((p for p in reg["profiles"] if p["id"] == pid), None)
|
||||||
|
|
||||||
|
|
||||||
|
def find_by_name(reg, name):
|
||||||
|
return next((p for p in reg["profiles"] if p["name"] == name), None)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run → PASS.** **Step 5: Commit** `feat: profiles registry read/write + find`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `profiles.py` — `create_profile`
|
||||||
|
|
||||||
|
**Files:** Modify `gates/profiles.py`, `tests/test_profiles.py`
|
||||||
|
|
||||||
|
**Step 1: Failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_create_profile(tmp_path):
|
||||||
|
e = pr.create_profile(str(tmp_path), "setA", "id1", ts=10)
|
||||||
|
assert e == {"id": "id1", "name": "setA", "created": 10}
|
||||||
|
assert (tmp_path / "id1").is_dir()
|
||||||
|
assert pr.find_by_name(pr.read_registry(str(tmp_path)), "setA")["id"] == "id1"
|
||||||
|
|
||||||
|
def test_create_duplicate_name_raises(tmp_path):
|
||||||
|
import pytest
|
||||||
|
pr.create_profile(str(tmp_path), "setA", "id1")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pr.create_profile(str(tmp_path), "setA", "id2")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run → FAIL.**
|
||||||
|
|
||||||
|
**Step 3: Implement (append)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def create_profile(base, name, pid, ts=0):
|
||||||
|
reg = read_registry(base)
|
||||||
|
if find_by_name(reg, name):
|
||||||
|
raise ValueError(f"profile name already exists: {name}")
|
||||||
|
(Path(base) / pid).mkdir(parents=True, exist_ok=True)
|
||||||
|
entry = {"id": pid, "name": name, "created": ts}
|
||||||
|
reg["profiles"].append(entry)
|
||||||
|
write_registry(base, reg)
|
||||||
|
return entry
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run → PASS.** **Step 5: Commit** `feat: profiles create_profile`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `profiles.py` — `rename_profile`
|
||||||
|
|
||||||
|
**Step 1: Failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_rename_profile(tmp_path):
|
||||||
|
pr.create_profile(str(tmp_path), "old", "id1")
|
||||||
|
e = pr.rename_profile(str(tmp_path), "id1", "new")
|
||||||
|
assert e["name"] == "new"
|
||||||
|
assert pr.find_by_name(pr.read_registry(str(tmp_path)), "new")["id"] == "id1"
|
||||||
|
|
||||||
|
def test_rename_to_existing_name_raises(tmp_path):
|
||||||
|
import pytest
|
||||||
|
pr.create_profile(str(tmp_path), "a", "id1")
|
||||||
|
pr.create_profile(str(tmp_path), "b", "id2")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pr.rename_profile(str(tmp_path), "id2", "a")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run → FAIL.** **Step 3: Implement (append)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def rename_profile(base, pid, name):
|
||||||
|
reg = read_registry(base)
|
||||||
|
entry = find_by_id(reg, pid)
|
||||||
|
if not entry:
|
||||||
|
raise KeyError(pid)
|
||||||
|
other = find_by_name(reg, name)
|
||||||
|
if other and other["id"] != pid:
|
||||||
|
raise ValueError(f"profile name already exists: {name}")
|
||||||
|
entry["name"] = name
|
||||||
|
write_registry(base, reg)
|
||||||
|
return entry
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run → PASS.** **Step 5: Commit** `feat: profiles rename_profile`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: `profiles.py` — `delete_profile`
|
||||||
|
|
||||||
|
**Step 1: Failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_delete_profile_removes_dir_and_entry(tmp_path):
|
||||||
|
pr.create_profile(str(tmp_path), "a", "id1")
|
||||||
|
(tmp_path / "id1" / "img_0001.png").write_bytes(b"x")
|
||||||
|
pr.delete_profile(str(tmp_path), "id1")
|
||||||
|
assert not (tmp_path / "id1").exists()
|
||||||
|
assert pr.find_by_id(pr.read_registry(str(tmp_path)), "id1") is None
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run → FAIL.** **Step 3: Implement (append)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def delete_profile(base, pid):
|
||||||
|
reg = read_registry(base)
|
||||||
|
reg["profiles"] = [p for p in reg["profiles"] if p["id"] != pid]
|
||||||
|
write_registry(base, reg)
|
||||||
|
d = Path(base) / pid
|
||||||
|
if d.exists():
|
||||||
|
shutil.rmtree(d)
|
||||||
|
return reg
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run → PASS.** **Step 5: Commit** `feat: profiles delete_profile`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: `profiles.py` — `duplicate_profile`
|
||||||
|
|
||||||
|
**Step 1: Failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_duplicate_copies_images(tmp_path):
|
||||||
|
pr.create_profile(str(tmp_path), "src", "id1")
|
||||||
|
(tmp_path / "id1" / "img_0001.png").write_bytes(b"abc")
|
||||||
|
e = pr.duplicate_profile(str(tmp_path), "id1", "copy", "id2", ts=5)
|
||||||
|
assert e == {"id": "id2", "name": "copy", "created": 5}
|
||||||
|
assert (tmp_path / "id2" / "img_0001.png").read_bytes() == b"abc"
|
||||||
|
|
||||||
|
def test_duplicate_duplicate_name_raises(tmp_path):
|
||||||
|
import pytest
|
||||||
|
pr.create_profile(str(tmp_path), "src", "id1")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pr.duplicate_profile(str(tmp_path), "id1", "src", "id2")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run → FAIL.** **Step 3: Implement (append)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def duplicate_profile(base, src_id, name, new_id, ts=0):
|
||||||
|
reg = read_registry(base)
|
||||||
|
if not find_by_id(reg, src_id):
|
||||||
|
raise KeyError(src_id)
|
||||||
|
if find_by_name(reg, name):
|
||||||
|
raise ValueError(f"profile name already exists: {name}")
|
||||||
|
src = Path(base) / src_id
|
||||||
|
dst = Path(base) / new_id
|
||||||
|
if src.exists():
|
||||||
|
shutil.copytree(src, dst)
|
||||||
|
else:
|
||||||
|
dst.mkdir(parents=True, exist_ok=True)
|
||||||
|
entry = {"id": new_id, "name": name, "created": ts}
|
||||||
|
reg["profiles"].append(entry)
|
||||||
|
write_registry(base, reg)
|
||||||
|
return entry
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run → PASS.** **Step 5: Commit** `feat: profiles duplicate_profile`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: `profiles.py` — `export_profile` + `import_profile`
|
||||||
|
|
||||||
|
**Step 1: Failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_export_import_roundtrip(tmp_path):
|
||||||
|
src_base = str(tmp_path / "a"); dst_base = str(tmp_path / "b")
|
||||||
|
pr.create_profile(src_base, "setA", "id1", ts=1)
|
||||||
|
from pathlib import Path
|
||||||
|
(Path(src_base) / "id1" / "img_0001.png").write_bytes(b"hello")
|
||||||
|
zpath = str(tmp_path / "setA.zip")
|
||||||
|
pr.export_profile(src_base, "id1", zpath)
|
||||||
|
assert (tmp_path / "setA.zip").exists()
|
||||||
|
# import into a different base, fresh id
|
||||||
|
e = pr.import_profile(dst_base, zpath, "id99", ts=2)
|
||||||
|
assert e["id"] == "id99"
|
||||||
|
assert e["name"] == "setA" # name carried in zip meta
|
||||||
|
assert (Path(dst_base) / "id99" / "img_0001.png").read_bytes() == b"hello"
|
||||||
|
|
||||||
|
def test_import_name_collision_suffixes(tmp_path):
|
||||||
|
base = str(tmp_path)
|
||||||
|
pr.create_profile(base, "setA", "id1")
|
||||||
|
from pathlib import Path
|
||||||
|
(Path(base) / "id1" / "f.png").write_bytes(b"x")
|
||||||
|
z = str(tmp_path / "e.zip"); pr.export_profile(base, "id1", z)
|
||||||
|
e = pr.import_profile(base, z, "id2")
|
||||||
|
assert e["name"] == "setA (2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run → FAIL.** **Step 3: Implement (append)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def export_profile(base, pid, dest_zip):
|
||||||
|
src = Path(base) / pid
|
||||||
|
if not src.exists():
|
||||||
|
raise KeyError(pid)
|
||||||
|
entry = find_by_id(read_registry(base), pid)
|
||||||
|
name = entry["name"] if entry else pid
|
||||||
|
with zipfile.ZipFile(dest_zip, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
z.writestr("profile_meta.json", json.dumps({"name": name}))
|
||||||
|
for f in src.rglob("*"):
|
||||||
|
if f.is_file():
|
||||||
|
z.write(f, arcname=str(Path("pool") / f.relative_to(src)))
|
||||||
|
return dest_zip
|
||||||
|
|
||||||
|
|
||||||
|
def import_profile(base, src_zip, new_id, name=None, ts=0):
|
||||||
|
reg = read_registry(base)
|
||||||
|
meta_name = None
|
||||||
|
dst = Path(base) / new_id
|
||||||
|
dst.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zipfile.ZipFile(src_zip) as z:
|
||||||
|
names = z.namelist()
|
||||||
|
if "profile_meta.json" in names:
|
||||||
|
meta_name = json.loads(z.read("profile_meta.json")).get("name")
|
||||||
|
for n in names:
|
||||||
|
if n.startswith("pool/") and not n.endswith("/"):
|
||||||
|
target = dst / n[len("pool/"):]
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with z.open(n) as srcf, open(target, "wb") as out:
|
||||||
|
shutil.copyfileobj(srcf, out)
|
||||||
|
final = name or meta_name or new_id
|
||||||
|
candidate, i = final, 2
|
||||||
|
while find_by_name(reg, candidate):
|
||||||
|
candidate = f"{final} ({i})"
|
||||||
|
i += 1
|
||||||
|
entry = {"id": new_id, "name": candidate, "created": ts}
|
||||||
|
reg["profiles"].append(entry)
|
||||||
|
write_registry(base, reg)
|
||||||
|
return entry
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run → PASS.** **Step 5: Commit** `feat: profiles export/import (portable zip)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: `profile_node.py` — the `PoolProfile` node
|
||||||
|
|
||||||
|
**Files:** Create `gates/profile_node.py`; Test `tests/test_profile_node.py`
|
||||||
|
|
||||||
|
**Step 1: Failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_profile_node.py
|
||||||
|
from gates import profile_node as pn
|
||||||
|
|
||||||
|
def test_io():
|
||||||
|
assert pn.PoolProfile.RETURN_TYPES == ("POOL_PROFILE",)
|
||||||
|
assert pn.PoolProfile.RETURN_NAMES == ("profile",)
|
||||||
|
|
||||||
|
def test_run_returns_id_or_default():
|
||||||
|
assert pn.PoolProfile().run(profile="setA", profile_id="id1") == ("id1",)
|
||||||
|
assert pn.PoolProfile().run(profile="", profile_id="") == ("default",)
|
||||||
|
|
||||||
|
def test_is_changed_tracks_id():
|
||||||
|
assert pn.PoolProfile.IS_CHANGED(profile="x", profile_id="id1") == "id1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run → FAIL.** **Step 3: Implement**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# gates/profile_node.py
|
||||||
|
NODE_CLASS_MAPPINGS = {}
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||||
|
|
||||||
|
|
||||||
|
class PoolProfile:
|
||||||
|
CATEGORY = "Datasete Gates"
|
||||||
|
FUNCTION = "run"
|
||||||
|
RETURN_TYPES = ("POOL_PROFILE",)
|
||||||
|
RETURN_NAMES = ("profile",)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"profile": ("STRING", {"default": ""}), # name; JS renders a dropdown
|
||||||
|
"profile_id": ("STRING", {"default": ""}), # hidden, JS-owned id
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def run(self, profile, profile_id=""):
|
||||||
|
return (profile_id or "default",)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def IS_CHANGED(cls, profile, profile_id="", **kwargs):
|
||||||
|
return profile_id
|
||||||
|
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {"PoolProfile": PoolProfile}
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {"PoolProfile": "Pool Profile"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run → PASS.** **Step 5: Commit** `feat: PoolProfile companion node`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: `node.py` — optional `profile` input on the pool (MERGE)
|
||||||
|
|
||||||
|
**Files:** Modify `gates/node.py`, `tests/test_node.py`
|
||||||
|
|
||||||
|
**Step 1: Failing test** (add)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_profile_input_overrides_pool_id(tmp_path, monkeypatch):
|
||||||
|
base = str(tmp_path / "grid_pool")
|
||||||
|
monkeypatch.setattr(node, "_grid_pool_base", lambda: base)
|
||||||
|
import io
|
||||||
|
from PIL import Image
|
||||||
|
from gates import pool
|
||||||
|
buf = io.BytesIO(); Image.new("RGB", (4, 6), (255, 0, 0)).save(buf, "PNG")
|
||||||
|
pool.add_image(base, "prof1", buf.getvalue(), ts=1) # images under the PROFILE id
|
||||||
|
n = node.GridImagePool()
|
||||||
|
# pool_id is "default" (empty) but profile points at prof1
|
||||||
|
img, mask, idx, count, label = n.run(index=-1, pool_id="default", profile="prof1")
|
||||||
|
assert count == 1 and idx == 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run → FAIL.**
|
||||||
|
|
||||||
|
**Step 3: Implement** — re-Read `gates/node.py`, then:
|
||||||
|
- In `INPUT_TYPES`, add an optional block:
|
||||||
|
```python
|
||||||
|
"optional": {"profile": ("POOL_PROFILE",)},
|
||||||
|
```
|
||||||
|
- Compute the effective id wherever `pool_id` is used. Simplest: update `_resolve`, `run`,
|
||||||
|
`IS_CHANGED` signatures to accept `profile=None` and resolve `effective = profile or pool_id`
|
||||||
|
at the top, then use `effective` instead of `pool_id`:
|
||||||
|
```python
|
||||||
|
def run(self, index, pool_id="default", profile=None):
|
||||||
|
effective = profile or pool_id
|
||||||
|
base, m, idx = self._resolve(index, effective)
|
||||||
|
...
|
||||||
|
d = pool.pool_dir(base, effective)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
```python
|
||||||
|
@classmethod
|
||||||
|
def IS_CHANGED(cls, index, pool_id="default", profile=None, **kwargs):
|
||||||
|
effective = profile or pool_id
|
||||||
|
base, m, idx = cls._resolve(index, effective)
|
||||||
|
...
|
||||||
|
return imaging.change_hash(effective, f"{idx}:{m.get('active')}", mtimes)
|
||||||
|
```
|
||||||
|
(`_resolve` already takes the id as its 2nd arg — pass `effective`.)
|
||||||
|
|
||||||
|
**Step 4: Run → PASS** (existing pool tests still pass).
|
||||||
|
|
||||||
|
**Step 5: Commit** `feat: Image Pool accepts optional POOL_PROFILE (profile or pool_id)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: `profiles_routes.py` — aiohttp glue + register (MERGE)
|
||||||
|
|
||||||
|
**Files:** Create `gates/profiles_routes.py`; Modify `__init__.py`
|
||||||
|
|
||||||
|
**Step 1: Implement `gates/profiles_routes.py`** (verified live, not unit-tested)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# gates/profiles_routes.py
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from server import PromptServer
|
||||||
|
|
||||||
|
from . import profiles
|
||||||
|
from .gates_compat import grid_pool_base
|
||||||
|
|
||||||
|
routes = PromptServer.instance.routes
|
||||||
|
|
||||||
|
|
||||||
|
def _base():
|
||||||
|
return grid_pool_base()
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/grid_pool/profiles/list")
|
||||||
|
async def _list(request):
|
||||||
|
return web.json_response(profiles.read_registry(_base()))
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/grid_pool/profiles/create")
|
||||||
|
async def _create(request):
|
||||||
|
body = await request.json()
|
||||||
|
e = profiles.create_profile(_base(), body["name"], uuid.uuid4().hex)
|
||||||
|
return web.json_response(e)
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/grid_pool/profiles/rename")
|
||||||
|
async def _rename(request):
|
||||||
|
body = await request.json()
|
||||||
|
return web.json_response(profiles.rename_profile(_base(), body["id"], body["name"]))
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/grid_pool/profiles/delete")
|
||||||
|
async def _delete(request):
|
||||||
|
body = await request.json()
|
||||||
|
return web.json_response(profiles.delete_profile(_base(), body["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/grid_pool/profiles/duplicate")
|
||||||
|
async def _duplicate(request):
|
||||||
|
body = await request.json()
|
||||||
|
e = profiles.duplicate_profile(_base(), body["id"], body["name"], uuid.uuid4().hex)
|
||||||
|
return web.json_response(e)
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/grid_pool/profiles/export")
|
||||||
|
async def _export(request):
|
||||||
|
pid = request.query["id"]
|
||||||
|
reg = profiles.read_registry(_base())
|
||||||
|
entry = profiles.find_by_id(reg, pid)
|
||||||
|
fname = (entry["name"] if entry else pid) + ".zip"
|
||||||
|
tmp = os.path.join(tempfile.gettempdir(), f"profile_{pid}.zip")
|
||||||
|
profiles.export_profile(_base(), pid, tmp)
|
||||||
|
return web.FileResponse(tmp, headers={"Content-Disposition": f'attachment; filename="{fname}"'})
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/grid_pool/profiles/import")
|
||||||
|
async def _import(request):
|
||||||
|
reader = await request.multipart()
|
||||||
|
tmp = os.path.join(tempfile.gettempdir(), f"import_{uuid.uuid4().hex}.zip")
|
||||||
|
async for part in reader:
|
||||||
|
if part.name == "file":
|
||||||
|
with open(tmp, "wb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = await part.read_chunk()
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
f.write(chunk)
|
||||||
|
e = profiles.import_profile(_base(), tmp, uuid.uuid4().hex)
|
||||||
|
return web.json_response(e)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Re-Read `__init__.py`** and merge the companion node + import the routes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .gates.profile_node import NODE_CLASS_MAPPINGS as _PROF_NODES, \
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS as _PROF_NAMES
|
||||||
|
from .gates import profiles_routes # noqa: F401 (registers /grid_pool/profiles/*)
|
||||||
|
NODE_CLASS_MAPPINGS = {**NODE_CLASS_MAPPINGS, **_PROF_NODES}
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {**NODE_DISPLAY_NAME_MAPPINGS, **_PROF_NAMES}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3:** `$PY -c "import gates.profile_node; print(gates.profile_node.NODE_CLASS_MAPPINGS)"`.
|
||||||
|
|
||||||
|
**Step 4:** Full suite green: `$PY -m pytest tests/ -v`.
|
||||||
|
|
||||||
|
**Step 5: Commit** `feat: profiles routes + register PoolProfile`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: `web/pool_profile.js` — dropdown, actions, propagation
|
||||||
|
|
||||||
|
**Files:** Create `web/pool_profile.js`
|
||||||
|
|
||||||
|
`app.registerExtension` for `PoolProfile`:
|
||||||
|
- Replace the `profile` STRING widget with a **combo** populated from `GET
|
||||||
|
/grid_pool/profiles/list`; keep a hidden `profile_id` widget (mint/sync like `pool_id`).
|
||||||
|
- Action buttons: **Create** (prompt name → POST create), **Rename**, **Delete** (confirm),
|
||||||
|
**Duplicate** (prompt name), **Export** (`window.open('/grid_pool/profiles/export?id=...')`),
|
||||||
|
**Import** (hidden file input → multipart POST → refresh). After each, re-list + reselect.
|
||||||
|
- On selection change: set `profile_id`, then **propagate** — for each link from this node's
|
||||||
|
`POOL_PROFILE` output, find the target pool node, set its hidden `pool_id` widget to the id,
|
||||||
|
and call `node._datasetePoolRefresh?.()`. Also propagate on `onConnectionsChange` when the
|
||||||
|
output gets connected. (Model on `ComfyUI-JSON-Manager/web/project_key.js`.)
|
||||||
|
|
||||||
|
**Manual verify:** dropdown lists profiles; create adds one; selecting updates a connected
|
||||||
|
pool's grid live.
|
||||||
|
|
||||||
|
**Commit** `feat: pool profile frontend — dropdown, actions, cross-node propagation`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: `grid_image_pool.js` — accept `profile` input + refresh hook
|
||||||
|
|
||||||
|
**Files:** Modify `web/grid_image_pool.js`
|
||||||
|
|
||||||
|
- Expose `node._datasetePoolRefresh = () => refresh(node)` in the pool's `nodeCreated` so the
|
||||||
|
companion can trigger a grid reload.
|
||||||
|
- No other change required: propagation sets the pool's existing `pool_id` widget, and the
|
||||||
|
grid/routes already key off `getPoolId(node)`. (Optional: when the `profile` input is
|
||||||
|
disconnected, leave the last id in place.)
|
||||||
|
|
||||||
|
**Manual verify:** selecting in the companion repaints the pool grid with the profile's images.
|
||||||
|
|
||||||
|
**Commit** `feat: pool grid exposes refresh hook for profile sync`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 12: Live smoke test in ComfyUI
|
||||||
|
|
||||||
|
Restart ComfyUI. Drop `Pool Profile` + `Image Pool (Grid)`, wire profile→profile. Verify:
|
||||||
|
- [ ] Both nodes appear under "Datasete Gates".
|
||||||
|
- [ ] Create profile "A" → folder + registry entry appear; dropdown shows "A".
|
||||||
|
- [ ] Add images to the pool → they land under the profile's id dir.
|
||||||
|
- [ ] Create "B", switch → pool grid switches live (empty); switch back to "A" → images return.
|
||||||
|
- [ ] In a **new** workflow, add both nodes, select "A" → the same images load.
|
||||||
|
- [ ] Rename / Delete / Duplicate behave; duplicate copies images.
|
||||||
|
- [ ] Export "A" downloads a zip; Import it → a new profile with the same images.
|
||||||
|
- [ ] A pool with **no** profile connected still works (per-node UUID, unchanged).
|
||||||
|
- [ ] Run the graph → IMAGE/MASK come from the selected profile's active slot.
|
||||||
|
|
||||||
|
**Commit** (if fixes) `fix: pool profiles live-test adjustments`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
|
||||||
|
- `$PY -m pytest tests/test_profiles.py tests/test_profile_node.py -v` green; full `tests/`
|
||||||
|
green (existing pool/gate/loader unaffected).
|
||||||
|
- Manual checklist passes: create/select with live grid switch, reuse across workflows,
|
||||||
|
rename/delete/duplicate, export/import round-trip, backward-compatible unconnected pools.
|
||||||
Reference in New Issue
Block a user