Add Collapsilbe Grouping
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user