fix: pool profiles never auto-switch on connect; seed empty profile from current pool
Connecting a Pool Profile no longer overwrites the pool's pool_id. The pool is switched only when the user actively selects a profile in the dropdown; picking an empty profile while a pool with images is connected offers to copy those images into it (new seed_profile op + /grid_pool/profiles/seed route), so the current pool is never silently lost. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -114,6 +114,26 @@ def export_profile(base, pid, dest_zip):
|
|||||||
return dest_zip
|
return dest_zip
|
||||||
|
|
||||||
|
|
||||||
|
def seed_profile(base, from_id, profile_id):
|
||||||
|
"""Copy a pool dir's files (images/masks/manifest) into a profile dir.
|
||||||
|
|
||||||
|
Used to save an Image Pool's current contents into a freshly-selected empty
|
||||||
|
profile. Copies top-level files only (the pool layout is flat); returns the
|
||||||
|
number of files copied. No-op (0) if the source dir is missing.
|
||||||
|
"""
|
||||||
|
src = Path(base) / from_id
|
||||||
|
dst = Path(base) / profile_id
|
||||||
|
if not src.exists():
|
||||||
|
return 0
|
||||||
|
dst.mkdir(parents=True, exist_ok=True)
|
||||||
|
n = 0
|
||||||
|
for f in src.iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
shutil.copy2(f, dst / f.name)
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def import_profile(base, src_zip, new_id, name=None, ts=0):
|
def import_profile(base, src_zip, new_id, name=None, ts=0):
|
||||||
reg = read_registry(base)
|
reg = read_registry(base)
|
||||||
meta_name = None
|
meta_name = None
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ async def _duplicate(request):
|
|||||||
return web.json_response(e)
|
return web.json_response(e)
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/grid_pool/profiles/seed")
|
||||||
|
async def _seed(request):
|
||||||
|
body = await request.json()
|
||||||
|
n = profiles.seed_profile(_base(), body["from"], body["id"])
|
||||||
|
return web.json_response({"copied": n})
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/grid_pool/profiles/export")
|
@routes.get("/grid_pool/profiles/export")
|
||||||
async def _export(request):
|
async def _export(request):
|
||||||
pid = request.query["id"]
|
pid = request.query["id"]
|
||||||
|
|||||||
@@ -90,3 +90,19 @@ def test_import_name_collision_suffixes(tmp_path):
|
|||||||
z = str(tmp_path / "e.zip"); pr.export_profile(base, "id1", z)
|
z = str(tmp_path / "e.zip"); pr.export_profile(base, "id1", z)
|
||||||
e = pr.import_profile(base, z, "id2")
|
e = pr.import_profile(base, z, "id2")
|
||||||
assert e["name"] == "setA (2)"
|
assert e["name"] == "setA (2)"
|
||||||
|
|
||||||
|
def test_seed_profile_copies_pool_into_empty(tmp_path):
|
||||||
|
from pathlib import Path
|
||||||
|
base = str(tmp_path)
|
||||||
|
pr.create_profile(base, "A", "id1") # empty profile dir
|
||||||
|
(Path(base) / "srcpool").mkdir() # a pool's own-UUID dir
|
||||||
|
(Path(base) / "srcpool" / "img_0001.png").write_bytes(b"img")
|
||||||
|
(Path(base) / "srcpool" / "manifest.json").write_text("{}")
|
||||||
|
n = pr.seed_profile(base, "srcpool", "id1")
|
||||||
|
assert n == 2 # image + manifest copied
|
||||||
|
assert (Path(base) / "id1" / "img_0001.png").read_bytes() == b"img"
|
||||||
|
|
||||||
|
def test_seed_profile_missing_source_is_noop(tmp_path):
|
||||||
|
base = str(tmp_path)
|
||||||
|
pr.create_profile(base, "A", "id1")
|
||||||
|
assert pr.seed_profile(base, "nope", "id1") == 0
|
||||||
|
|||||||
+74
-37
@@ -3,9 +3,11 @@ import { api } from "../../scripts/api.js";
|
|||||||
|
|
||||||
// Pool Profile — companion to the Image Pool. A dropdown of named profiles
|
// Pool Profile — companion to the Image Pool. A dropdown of named profiles
|
||||||
// (registry under input/grid_pool/profiles.json) plus create/rename/delete/
|
// (registry under input/grid_pool/profiles.json) plus create/rename/delete/
|
||||||
// duplicate/export/import actions. Selecting a profile propagates its id into
|
// duplicate/export/import actions. The pool is switched ONLY when the user
|
||||||
// any connected Image Pool node's pool_id widget and refreshes that grid, so the
|
// actively picks a profile in the dropdown (or creates/duplicates/imports one) —
|
||||||
// pool's images switch live at edit time. (Modeled on JSON-Manager/project_key.)
|
// connecting the node never changes the pool. Selecting an *empty* profile while
|
||||||
|
// a pool with images is connected offers to seed it from those images, so the
|
||||||
|
// current pool is never silently lost. (Modeled on JSON-Manager/project_key.)
|
||||||
|
|
||||||
const NODE = "PoolProfile";
|
const NODE = "PoolProfile";
|
||||||
const POOL_NODE = "GridImagePool";
|
const POOL_NODE = "GridImagePool";
|
||||||
@@ -18,6 +20,13 @@ async function listProfiles() {
|
|||||||
return (await r.json()).profiles || [];
|
return (await r.json()).profiles || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listPoolSlots(poolId) {
|
||||||
|
try {
|
||||||
|
const r = await api.fetchApi(`/grid_pool/list?pool_id=${encodeURIComponent(poolId)}`);
|
||||||
|
return (await r.json()).slots || [];
|
||||||
|
} catch (e) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
async function postJson(path, body) {
|
async function postJson(path, body) {
|
||||||
const r = await api.fetchApi(`${R}/${path}`, {
|
const r = await api.fetchApi(`${R}/${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -61,40 +70,72 @@ function replaceWithCombo(node, name, values, callback) {
|
|||||||
if (saved && !vals.includes(saved)) vals.unshift(saved);
|
if (saved && !vals.includes(saved)) vals.unshift(saved);
|
||||||
node.widgets.splice(idx, 1);
|
node.widgets.splice(idx, 1);
|
||||||
const combo = node.addWidget("combo", name, saved || vals[0], callback, { values: vals });
|
const combo = node.addWidget("combo", name, saved || vals[0], callback, { values: vals });
|
||||||
// move from the end back to the original slot
|
|
||||||
node.widgets.splice(node.widgets.length - 1, 1);
|
node.widgets.splice(node.widgets.length - 1, 1);
|
||||||
node.widgets.splice(idx, 0, combo);
|
node.widgets.splice(idx, 0, combo);
|
||||||
return combo;
|
return combo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- propagation ------------------------------------------------------------
|
// ---- connected pools + switching --------------------------------------------
|
||||||
|
|
||||||
// Push the selected profile id into every connected Image Pool node's pool_id
|
function connectedPools(node) {
|
||||||
// widget (the grid keys off getPoolId), then refresh that grid.
|
const res = [];
|
||||||
function propagate(node) {
|
|
||||||
const id = idWidget(node)?.value || "default";
|
|
||||||
const out = node.outputs?.[0];
|
const out = node.outputs?.[0];
|
||||||
if (!out?.links) return;
|
if (!out?.links) return res;
|
||||||
for (const linkId of out.links) {
|
for (const linkId of out.links) {
|
||||||
const link = node.graph?.links?.[linkId];
|
const link = node.graph?.links?.[linkId];
|
||||||
if (!link) continue;
|
if (!link) continue;
|
||||||
const target = node.graph?.getNodeById?.(link.target_id);
|
const t = node.graph?.getNodeById?.(link.target_id);
|
||||||
if (!target || target.type !== POOL_NODE) continue;
|
if (t && t.type === POOL_NODE) res.push(t);
|
||||||
const pw = target.widgets?.find((w) => w.name === "pool_id");
|
|
||||||
if (pw) pw.value = id;
|
|
||||||
target._datasetePoolRefresh?.();
|
|
||||||
target.setDirtyCanvas?.(true, true);
|
|
||||||
}
|
}
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySelection(node) {
|
function setIdFromCombo(node) {
|
||||||
const entry = currentEntry(node);
|
const entry = currentEntry(node);
|
||||||
const idw = idWidget(node);
|
const idw = idWidget(node);
|
||||||
if (idw) idw.value = entry?.id || "";
|
if (idw) idw.value = entry?.id || "";
|
||||||
propagate(node);
|
}
|
||||||
|
|
||||||
|
// Push the current profile id into every connected pool's pool_id widget (the
|
||||||
|
// grid keys off getPoolId) and repaint. Only ever called from user actions.
|
||||||
|
function switchPools(node) {
|
||||||
|
const id = idWidget(node)?.value || "default";
|
||||||
|
for (const pool of connectedPools(node)) {
|
||||||
|
const pw = pool.widgets?.find((w) => w.name === "pool_id");
|
||||||
|
if (pw) pw.value = id;
|
||||||
|
pool._datasetePoolRefresh?.();
|
||||||
|
pool.setDirtyCanvas?.(true, true);
|
||||||
|
}
|
||||||
node.setDirtyCanvas?.(true, true);
|
node.setDirtyCanvas?.(true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the selected profile is empty and a connected pool has images, offer to
|
||||||
|
// copy those images into the profile (so switching never loses the current pool).
|
||||||
|
async function maybeSeed(node, entry) {
|
||||||
|
const profSlots = await listPoolSlots(entry.id);
|
||||||
|
if (profSlots.length > 0) return; // profile already has images
|
||||||
|
for (const pool of connectedPools(node)) {
|
||||||
|
const curId = pool.widgets?.find((w) => w.name === "pool_id")?.value;
|
||||||
|
if (!curId || curId === entry.id) continue;
|
||||||
|
const curSlots = await listPoolSlots(curId);
|
||||||
|
if (curSlots.length === 0) continue;
|
||||||
|
if (confirm(`Profile "${entry.name}" is empty. Copy the ${curSlots.length} current pool image(s) into it?`)) {
|
||||||
|
try { await postJson("seed", { from: curId, id: entry.id }); }
|
||||||
|
catch (err) { alert("Seed failed: " + err); }
|
||||||
|
}
|
||||||
|
return; // seed from the first match only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// user-initiated: set id from the dropdown, optionally offer to seed, then switch
|
||||||
|
async function selectProfile(node) {
|
||||||
|
setIdFromCombo(node);
|
||||||
|
const entry = currentEntry(node);
|
||||||
|
if (entry) await maybeSeed(node, entry);
|
||||||
|
switchPools(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// programmatic: refresh the dropdown options + hidden id only — never switches
|
||||||
async function refreshList(node, selectName) {
|
async function refreshList(node, selectName) {
|
||||||
const profs = await listProfiles();
|
const profs = await listProfiles();
|
||||||
node._profiles = profs;
|
node._profiles = profs;
|
||||||
@@ -105,8 +146,9 @@ async function refreshList(node, selectName) {
|
|||||||
combo.options.values = names.length ? names : [""];
|
combo.options.values = names.length ? names : [""];
|
||||||
if (selectName !== undefined) combo.value = selectName;
|
if (selectName !== undefined) combo.value = selectName;
|
||||||
else if (!names.includes(combo.value)) combo.value = names[0] || "";
|
else if (!names.includes(combo.value)) combo.value = names[0] || "";
|
||||||
applySelection(node);
|
|
||||||
}
|
}
|
||||||
|
setIdFromCombo(node);
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- actions ----------------------------------------------------------------
|
// ---- actions ----------------------------------------------------------------
|
||||||
@@ -117,6 +159,7 @@ async function actionCreate(node) {
|
|||||||
try {
|
try {
|
||||||
const e = await postJson("create", { name });
|
const e = await postJson("create", { name });
|
||||||
await refreshList(node, e.name);
|
await refreshList(node, e.name);
|
||||||
|
await selectProfile(node); // new profile is empty → offer to seed current pool
|
||||||
} catch (err) { alert("Create failed: " + err); }
|
} catch (err) { alert("Create failed: " + err); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +170,7 @@ async function actionRename(node) {
|
|||||||
if (!name || name === e.name) return;
|
if (!name || name === e.name) return;
|
||||||
try {
|
try {
|
||||||
await postJson("rename", { id: e.id, name });
|
await postJson("rename", { id: e.id, name });
|
||||||
await refreshList(node, name);
|
await refreshList(node, name); // same id, no pool switch needed
|
||||||
} catch (err) { alert("Rename failed: " + err); }
|
} catch (err) { alert("Rename failed: " + err); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +182,7 @@ async function actionDuplicate(node) {
|
|||||||
try {
|
try {
|
||||||
const ne = await postJson("duplicate", { id: e.id, name });
|
const ne = await postJson("duplicate", { id: e.id, name });
|
||||||
await refreshList(node, ne.name);
|
await refreshList(node, ne.name);
|
||||||
|
await selectProfile(node); // already has images → maybeSeed no-ops, just switch
|
||||||
} catch (err) { alert("Duplicate failed: " + err); }
|
} catch (err) { alert("Duplicate failed: " + err); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +192,7 @@ async function actionDelete(node) {
|
|||||||
if (!confirm(`Delete profile "${e.name}"? This removes its images.`)) return;
|
if (!confirm(`Delete profile "${e.name}"? This removes its images.`)) return;
|
||||||
try {
|
try {
|
||||||
await postJson("delete", { id: e.id });
|
await postJson("delete", { id: e.id });
|
||||||
await refreshList(node);
|
await refreshList(node); // update dropdown; leave the pool as-is
|
||||||
} catch (err) { alert("Delete failed: " + err); }
|
} catch (err) { alert("Delete failed: " + err); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +215,7 @@ function actionImport(node) {
|
|||||||
if (!r.ok) throw new Error(await r.text());
|
if (!r.ok) throw new Error(await r.text());
|
||||||
const e = await r.json();
|
const e = await r.json();
|
||||||
await refreshList(node, e.name);
|
await refreshList(node, e.name);
|
||||||
|
await selectProfile(node); // imported profile has images → just switch
|
||||||
} catch (err) { alert("Import failed: " + err); }
|
} catch (err) { alert("Import failed: " + err); }
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
@@ -180,7 +225,8 @@ function actionImport(node) {
|
|||||||
|
|
||||||
function setupProfileNode(node) {
|
function setupProfileNode(node) {
|
||||||
hideWidget(idWidget(node));
|
hideWidget(idWidget(node));
|
||||||
replaceWithCombo(node, "profile", [], () => applySelection(node));
|
// combo callback = active user selection → switch (and maybe seed)
|
||||||
|
replaceWithCombo(node, "profile", [], () => { selectProfile(node); });
|
||||||
|
|
||||||
node.addWidget("button", "➕ Create", null, () => actionCreate(node));
|
node.addWidget("button", "➕ Create", null, () => actionCreate(node));
|
||||||
node.addWidget("button", "✎ Rename", null, () => actionRename(node));
|
node.addWidget("button", "✎ Rename", null, () => actionRename(node));
|
||||||
@@ -190,7 +236,7 @@ function setupProfileNode(node) {
|
|||||||
node.addWidget("button", "⬆ Import", null, () => actionImport(node));
|
node.addWidget("button", "⬆ Import", null, () => actionImport(node));
|
||||||
|
|
||||||
node.setSize(node.computeSize());
|
node.setSize(node.computeSize());
|
||||||
refreshList(node); // async: populate the dropdown
|
refreshList(node); // populate the dropdown; does NOT switch any pool
|
||||||
}
|
}
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
@@ -206,25 +252,16 @@ app.registerExtension({
|
|||||||
return r;
|
return r;
|
||||||
};
|
};
|
||||||
|
|
||||||
// loaded workflows restore the combo + profile_id after create — re-list and
|
// on load the pool already has its saved pool_id, so just refresh the
|
||||||
// re-propagate the saved id once the graph is ready.
|
// dropdown to show the saved name — no switching, no seeding.
|
||||||
const onConfigure = nodeType.prototype.onConfigure;
|
const onConfigure = nodeType.prototype.onConfigure;
|
||||||
nodeType.prototype.onConfigure = function () {
|
nodeType.prototype.onConfigure = function () {
|
||||||
const r = onConfigure?.apply(this, arguments);
|
const r = onConfigure?.apply(this, arguments);
|
||||||
const node = this;
|
const node = this;
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => refreshList(node, profileWidget(node)?.value));
|
||||||
propagate(node); // propagate saved id immediately
|
|
||||||
refreshList(node, profileWidget(node)?.value);
|
|
||||||
});
|
|
||||||
return r;
|
|
||||||
};
|
|
||||||
|
|
||||||
// when our output gets connected to a pool, propagate right away
|
|
||||||
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
|
|
||||||
nodeType.prototype.onConnectionsChange = function () {
|
|
||||||
const r = onConnectionsChange?.apply(this, arguments);
|
|
||||||
propagate(this);
|
|
||||||
return r;
|
return r;
|
||||||
};
|
};
|
||||||
|
// NOTE: intentionally no onConnectionsChange handler — connecting a profile
|
||||||
|
// must never change the pool (the user switches via the dropdown).
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user