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",