Add Collapsilbe Grouping

This commit is contained in:
ialhabbal
2026-05-08 17:11:19 +03:00
parent f7b9f93913
commit c8eb06c86c
6 changed files with 160 additions and 79 deletions
+21 -21
View File
@@ -1,21 +1,21 @@
MIT License
Copyright (c) 2026 ialhabbal
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
MIT License
Copyright (c) 2026 ialhabbal
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -3,6 +3,7 @@
A ComfyUI node that turns clean AI-generated portraits and photos into images that look like they were shot on real film, edited in a darkroom, or simply lived-in and human. It combines classic photo effects (compression artifacts, grain, vignette, color grading) with a full suite of face skin effects that break the plastic, over-smooth look common in AI art.
<img src="https://raw.githubusercontent.com/ialhabbal/PhotoLab/main/media/PhotoLab_New.png" width="600">
<img src="https://raw.githubusercontent.com/ialhabbal/PhotoLab/main/media/PhotoLab_New1.png" width="600">
<img src="https://raw.githubusercontent.com/ialhabbal/PhotoLab/main/media/PhotoLab_.png" width="600">
+127 -52
View File
@@ -6,7 +6,8 @@
* Features
* --------
* - ↺ Reset to Defaults
* - Built-in Global and Face preset buttons (collapsible sections)
* - Collapsible sections for Global Effects, Lighting Match, and Face Skin Effects
* - Built-in Global and Face preset buttons (within collapsible sections)
* - User presets saved to localStorage — persist across sessions
* · Each user preset has its own Apply button + a separate ✕ Delete button
* · Active preset is highlighted with a ● marker
@@ -47,8 +48,6 @@ const WIDGET_DEFAULTS = {
};
// ─── Built-in presets ─────────────────────────────────────────────────────────
// Each preset lists only the keys it changes; everything else is left untouched.
const GLOBAL_PRESETS = [
{
label: "📷 Film Snapshot",
@@ -172,23 +171,14 @@ function captureValues(node) {
}
// ─── Active-preset highlight state ────────────────────────────────────────────
//
// We track which button widget is currently "active" so we can prefix its
// label with ● and clear the previous one when a new preset is selected.
//
// Since ComfyUI button widgets render their `name` as the visible label,
// we store the original name alongside a flag and swap the name on activate /
// deactivate.
let _activePresetWidget = null; // the currently highlighted button widget
let _activePresetLabel = ""; // its original label (without ●)
let _activePresetWidget = null;
let _activePresetLabel = "";
function activatePreset(node, btnWidget, originalLabel, values) {
// Clear previous highlight
if (_activePresetWidget && _activePresetWidget !== btnWidget) {
_activePresetWidget.name = _activePresetLabel;
}
// Set new highlight
_activePresetWidget = btnWidget;
_activePresetLabel = originalLabel;
btnWidget.name = `● ${originalLabel}`;
@@ -205,7 +195,7 @@ function clearActiveHighlight() {
}
}
// ─── Section builder ──────────────────────────────────────────────────────────
// ─── Collapsible Section Builder ──────────────────────────────────────────────
//
// Adds a collapsible section to the node with one button widget per preset.
// The toggle header button hides/shows the row buttons by setting w.hidden.
@@ -213,11 +203,11 @@ function clearActiveHighlight() {
// @param tag string — unique identifier used to tag widgets
// @param title string — section header text
// @param presets array — [{label, desc, values}]
// @param startOpen bool
// @param startOpen bool — whether section starts open or closed
//
// Returns an array of the added button widgets (not the header).
function addSection(node, tag, title, presets, startOpen) {
function addCollapsibleSection(node, tag, title, presets, startOpen) {
let open = startOpen;
const header = node.addWidget("button",
@@ -226,7 +216,7 @@ function addSection(node, tag, title, presets, startOpen) {
open = !open;
header.name = `${open ? "▼" : "▶"} ${title}`;
for (const w of node.widgets) {
if (w._plTag === tag) w.hidden = !open;
if (w._plSectionTag === tag) w.hidden = !open;
}
node.setSize(node.computeSize());
app.graph.setDirtyCanvas(true, true);
@@ -241,64 +231,158 @@ function addSection(node, tag, title, presets, startOpen) {
() => activatePreset(node, btn, p.label, p.values),
{ serialize: false, tooltip: p.desc || "" }
);
btn._plTag = tag;
btn._plSectionTag = tag;
btn.hidden = !open;
btns.push(btn);
}
return btns;
}
function addCollapsibleWidgetSection(node, tag, title, widgetNames, startOpen) {
let open = startOpen;
const matched = [];
for (const w of node.widgets) {
if (w.type === "button") continue;
if (widgetNames.includes(w.name)) {
w._plSectionTag = tag;
w._plSectionHeader = false;
w.hidden = !open;
matched.push(w);
}
}
if (matched.length === 0) return;
const header = node.addWidget("button",
`${open ? "▼" : "▶"} ${title}`, null,
() => {
open = !open;
header.name = `${open ? "▼" : "▶"} ${title}`;
for (const w of node.widgets) {
if (w._plSectionTag === tag && !w._plSectionHeader) w.hidden = !open;
}
node.setSize(node.computeSize());
app.graph.setDirtyCanvas(true, true);
},
{ serialize: false }
);
header._plSectionTag = tag;
header._plSectionHeader = true;
const headerIndex = node.widgets.indexOf(header);
const firstMatchIndex = node.widgets.indexOf(matched[0]);
if (firstMatchIndex >= 0 && headerIndex >= 0) {
node.widgets.splice(headerIndex, 1);
node.widgets.splice(firstMatchIndex, 0, header);
}
}
// ─── Extension ────────────────────────────────────────────────────────────────
app.registerExtension({
name: "PhotoLab.PresetsV3",
name: "PhotoLab.PresetsV4",
async nodeCreated(node) {
if (node.comfyClass !== "PhotoLab" && node.type !== "PhotoLab") return;
// Guard: ComfyUI fires nodeCreated both on first creation and on
// workflow load/deserialisation. Without this check every widget is
// added twice, producing two full sets of buttons.
// Guard against double-adding on workflow load/deserialisation
if (node._plWidgetsAdded) return;
node._plWidgetsAdded = true;
// ── Reset to defaults ──────────────────────────────────────────────
// ── Reset to defaults button ─────────────────────────────────────────────
node.addWidget("button", "↺ Reset to Defaults", null, () => {
clearActiveHighlight();
for (const w of node.widgets) {
const def = WIDGET_DEFAULTS[w.name];
if (def !== undefined) { w.value = def; w.callback?.(def, app.canvas, node); }
if (def !== undefined) {
w.value = def;
w.callback?.(def, app.canvas, node);
}
}
app.graph.setDirtyCanvas(true, true);
}, { serialize: false });
// ── Global presets ─────────────────────────────────────────────────
addSection(node, "global", "Global Presets", GLOBAL_PRESETS, true);
node.addWidget("button", "Presets", null, null, { serialize: false });
// ── Face presets ───────────────────────────────────────────────────
addSection(node, "face", "Face Presets", FACE_PRESETS, false);
addCollapsibleWidgetSection(node, "global_inputs", "Aesthetic", [
"quality",
"passes",
"pixelate_strength",
"grain_strength",
"vignette_strength",
], true);
// ── User presets ───────────────────────────────────────────────────
// Layout per user preset:
// [ Apply — preset label (full width) ]
// [ ✕ Delete (compact, below apply) ]
//
// Keeping apply and delete as completely separate widgets with
// unambiguous click targets avoids the unreliable pos hit-test.
//
// The section is fully rebuilt after every save or delete so indices
// stay consistent.
addCollapsibleWidgetSection(node, "color_inputs", "Color", [
"color_grade",
"color_grade_strength",
"saturation",
], true);
addCollapsibleWidgetSection(node, "blur_inputs", "Blur", [
"blur_type",
"blur_strength",
], true);
addCollapsibleWidgetSection(node, "lighting_inputs", "Lighting", [
"lighting_match_mode",
"lighting_match_strength",
], true);
addCollapsibleWidgetSection(node, "mask_inputs", "Mask", [
"mask_mode",
"pores_strength",
"blemishes_strength",
"acne_strength",
"freckles_strength",
"skin_texture_strength",
"sss_strength",
"peach_fuzz_strength",
"skin_redness_strength",
"sebum_shine_strength",
"skin_seed",
"pores_opacity",
"blemishes_opacity",
"acne_opacity",
"freckles_opacity",
"skin_texture_opacity",
], true);
// ── Section 1: Global Photo Effects (starts OPEN) ────────────────────────
addCollapsibleSection(node, "global", "Global Photo Effects", GLOBAL_PRESETS, true);
// ── Section 2: Lighting Match (starts CLOSED) ───────────────────────────
const lightingPresets = [
{
label: "🎯 Histogram L-Channel",
desc: "Best all-rounder — matches luminance distribution only.",
values: { lighting_match_mode: "Histogram (L-channel)" }
},
{
label: "⚡ Reinhard Transfer",
desc: "Fast mean/std transfer — good for subtle corrections.",
values: { lighting_match_mode: "Reinhard Transfer" }
},
{
label: "🎨 Full LAB Histogram",
desc: "Matches all LAB channels including color tone.",
values: { lighting_match_mode: "Full LAB Histogram" }
}
];
addCollapsibleSection(node, "lighting", "Lighting Match", lightingPresets, false);
// ── Section 3: Face Skin Effects (starts CLOSED) ────────────────────────
addCollapsibleSection(node, "face", "Face Skin Effects", FACE_PRESETS, false);
// ── User presets section ─────────────────────────────────────────────────
function rebuildUser() {
// Remove all widgets that belong to the user section
node.widgets = node.widgets.filter(w => !w._plUser);
const all = loadUserPresets();
// ── Section toggle header ──────────────────────────────────────
let open = true;
const header = node.addWidget("button",
`▼ My Presets (${all.length})`, null,
`${open ? "▼" : "▶"} My Presets (${all.length})`, null,
() => {
open = !open;
header.name = `${open ? "▼" : "▶"} My Presets (${all.length})`;
@@ -313,7 +397,6 @@ app.registerExtension({
header._plUser = true;
header._plUserHeader = true;
// ── Save current settings as a new preset ──────────────────────
const saveBtn = node.addWidget("button", "+ Save Current as Preset", null,
() => {
const name = prompt(
@@ -330,24 +413,17 @@ app.registerExtension({
);
saveBtn._plUser = true;
// ── One Apply + one Delete widget per user preset ──────────────
// Apply and Delete are separate sequential widgets.
// ComfyUI renders them on separate rows, which gives each a full
// unambiguous click target — no pos coordinate math needed.
for (let i = 0; i < all.length; i++) {
const p = all[i];
// Apply button — full label, activates highlight
const applyBtn = node.addWidget("button", p.label, null,
() => activatePreset(node, applyBtn, p.label, p.values),
{ serialize: false, tooltip: "Click to apply this preset" }
);
applyBtn._plUser = true;
// Delete button — compact, indented label
const delBtn = node.addWidget("button", ` ✕ Delete "${p.label}"`, null,
() => {
// If this preset was active, clear the highlight
if (_activePresetWidget === applyBtn) clearActiveHighlight();
const fresh = loadUserPresets();
fresh.splice(i, 1);
@@ -364,6 +440,5 @@ app.registerExtension({
}
rebuildUser();
node.setSize(node.computeSize());
},
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+10 -5
View File
@@ -912,7 +912,8 @@ class PhotoLab:
pad = int(r * 3) + 2
y0i, y1i = max(0, int(cy)-pad), min(h, int(cy)+pad+1)
x0i, x1i = max(0, int(cx)-pad), min(w, int(cx)+pad+1)
yy_p = yy[y0i:y1i, x0i:x1i]; xx_p = xx[y0i:y1i, x0i:x1i]
yy_p = yy[y0i:y1i, x0i:x1i]
xx_p = xx[y0i:y1i, x0i:x1i]
dxr = cos_a * (xx_p - cx) + sin_a * (yy_p - cy)
dyr = -sin_a * (xx_p - cx) + cos_a * (yy_p - cy)
@@ -1010,7 +1011,8 @@ class PhotoLab:
y0, y1 = max(0, cy - pad), min(h, cy + pad + 1)
x0, x1 = max(0, cx - pad), min(w, cx + pad + 1)
yy, xx = np.mgrid[y0:y1, x0:x1].astype(np.float32)
dx = xx - cx; dy = yy - cy
dx = xx - cx
dy = yy - cy
dx_r = cos_a * dx + sin_a * dy
dy_r = -sin_a * dx + cos_a * dy
d2 = (dx_r / pr) ** 2 + (dy_r / (pr * aspect)) ** 2
@@ -1102,7 +1104,8 @@ class PhotoLab:
yy = yy_full[y0:y1, x0:x1]
xx = xx_full[y0:y1, x0:x1]
dx = xx - cx; dy = yy - cy
dx = xx - cx
dy = yy - cy
dx_r = cos_a * dx + sin_a * dy
dy_r = -sin_a * dx + cos_a * dy
# Add mild radius wobble for organic edge
@@ -1181,7 +1184,8 @@ class PhotoLab:
yy = yy_full[y0:y1, x0:x1]
xx = xx_full[y0:y1, x0:x1]
dx = xx - cx; dy = yy - cy
dx = xx - cx
dy = yy - cy
dx_r = cos_a * dx + sin_a * dy
dy_r = -sin_a * dx + cos_a * dy
d2 = (dx_r / r) ** 2 + (dy_r / (r * aspect)) ** 2
@@ -1223,7 +1227,8 @@ class PhotoLab:
pad2 = int(sr * 3) + 2
sy0, sy1 = max(0, oy-pad2), min(h, oy+pad2+1)
sx0, sx1 = max(0, ox-pad2), min(w, ox+pad2+1)
yys = yy_full[sy0:sy1, sx0:sx1]; xxs = xx_full[sy0:sy1, sx0:sx1]
yys = yy_full[sy0:sy1, sx0:sx1]
xxs = xx_full[sy0:sy1, sx0:sx1]
sd2 = ((xxs-ox)**2 + (yys-oy)**2) / (sr**2 + 1e-6)
smask = np.exp(-1.5 * sd2) * rng.uniform(0.5, 0.9) * spot_opacity
for c, bc in enumerate([br, bg, bb]):
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "PhotoLab"
description = "ComfyUI node that applies effects to generated images like JPG compression, pixelation, color grading, film grain, vignette, blur, and optional lighting matching with a reference image, and many more"
version = "2.5.0"
version = "2.5.1"
license = { file = "LICENSE" }
dependencies = [
"numpy",