diff --git a/LICENSE b/LICENSE
index ec15f26..2157557 100644
--- a/LICENSE
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
index 3b1a713..945236a 100644
--- a/README.md
+++ b/README.md
@@ -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.
+
diff --git a/js/photo_lab.js b/js/photo_lab.js
index 559129b..1b49fa3 100644
--- a/js/photo_lab.js
+++ b/js/photo_lab.js
@@ -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());
},
-});
+});
\ No newline at end of file
diff --git a/media/PhotoLab_New1.png b/media/PhotoLab_New1.png
new file mode 100644
index 0000000..2aa205e
Binary files /dev/null and b/media/PhotoLab_New1.png differ
diff --git a/photo_lab.py b/photo_lab.py
index 2de3d4a..e96576a 100644
--- a/photo_lab.py
+++ b/photo_lab.py
@@ -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]):
diff --git a/pyproject.toml b/pyproject.toml
index c2b9ed9..2b26392 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",