diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1a34063..1fde4c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,11 +15,23 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - # Pinned. The rule set is also pinned in pyproject.toml, because ruff - # changes its default selection between releases. - - run: pip install "ruff==0.16.0" + # requirements-dev.txt pins ruff's version and pyproject.toml pins its + # rule set, because ruff changes its default selection between releases. + - run: pip install -r requirements-dev.txt - run: ruff check . + # The Python suite cannot open a browser, and every frontend bug this pack has + # shipped went out through a green Python suite. This drives the real, + # unmodified web/*.js against a fake LiteGraph node. + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + - run: node --import ./tests/frontend/hooks.mjs --test "tests/frontend/*.test.mjs" + test: runs-on: ubuntu-latest strategy: @@ -40,8 +52,7 @@ jobs: python -m pip install --upgrade pip # ComfyUI supplies torch at runtime; the CPU wheel is enough to test with. pip install torch --index-url https://download.pytorch.org/whl/cpu - pip install -r requirements.txt - pip install pytest + pip install -r requirements-dev.txt - name: Test run: pytest -q diff --git a/AGENTS.md b/AGENTS.md index 305cdcc..b3bff61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,26 +1,27 @@ # AGENTS.md — comfyui-relight -A single, self-contained ComfyUI node that adds up to 3 positionable light sources to any image — colored additive light or precise color correction, with presets, directional gradients, rim lighting, and mask-aware 3D occlusion. Fast and deterministic: pure image processing (numpy + Pillow + scipy), no diffusion pass, no models to download. Built on ComfyUI v3 node schema. +A single, self-contained ComfyUI node that adds up to 3 positionable light sources to any image — colored additive light, precise color correction, or both — with presets, directional gradients, rim lighting, and mask-aware subject occlusion including a cast shadow. Fast and deterministic: pure image processing (numpy + Pillow + scipy), no diffusion pass, no models to download. Built on the ComfyUI v3 node schema, with a small frontend in `web/`. ## Current state -_Last verified: 2026-08-24_ +_Last verified: 2026-09-04_ -- **Status:** released v3.1.2 (`pyproject.toml`). Published to the Comfy Registry via `.github/workflows/publish_action.yml`, which fires on a `pyproject.toml` version change on `main` — a functional change needs a version bump or it never ships. -- **Works:** up to three independent light sources; both per-source modes (colored additive light, and color correction with brightness/contrast/saturation/temperature/tint/gamma); circular-falloff and gradient mask shapes; mask-aware front / rim / standard subject interaction; the built-in preset set; the visual debug view. `.github/workflows/test.yml` runs pytest across Python 3.10–3.12 plus a separate, deliberately non-blocking ruff job. -- **In progress:** nothing — v3.0.0 closed out the crash, batch-mask and 8-bit precision-loss bugs and added the test suite; v3.1.0 closed out a second audit pass; v3.1.1 fixed the locked-class crash that made both of those releases unrunnable in ComfyUI; v3.1.2 replaced the black `debug_image` frame with a placeholder that says why it is empty. See the changelog in `README.md` for what each release changed. -- **Known gaps / next steps:** output quality depends heavily on the input mask, and the only mask-quality guard is a console-only warning when a mask is >90% white; presets are plain dicts at the top of `relight.py` with no way for a user to add their own without editing the file; there is no example beyond the bundled workflow JSON; the occlusion paths deliberately still do per-frame CPU SciPy work (`fg_mask[b].cpu().numpy()` + Sobel per frame) because vectorising risks numeric drift. +- **Status:** v4.0.0 is on branch `v4-overhaul`, not yet merged or released; `main` is still v3.1.2. Published to the Comfy Registry via `.github/workflows/publish_action.yml`, which fires on a `pyproject.toml` version change on **`main`** — so the bump is safe on a branch, and a functional change on main needs a bump or it never ships. +- **Works:** up to three independent light sources; three lighting modes (colored additive, two-zone grading, and both in that order); radial-falloff and directional-gradient mask shapes; subject interaction in front of / behind the subject, the latter with a rim highlight, a falloff background glow and a traced cast shadow; the built-in preset set; a debug view that follows the `debug_image` wiring; conditional widget visibility and a working node recreate. `.github/workflows/test.yml` runs pytest across Python 3.10–3.12, a `node --test` frontend job, and a separate ruff job. +- **In progress:** v4.0.0 on `v4-overhaul`, now **exercised in a live ComfyUI** (0.34.0 / frontend 1.51.9) via Playwright: node registration, conditional visibility and its round-trip, preset greying, the legacy-workflow migration against the real v3.1.2 save, single-run debug wiring, and "Fix node (recreate)" all behave, with no ReLight console warnings. Browser QA found two defects, both fixed here — see the v4.0.0 changelog. The three retuned presets have been rendered and signed off; "Rim Light (Behind)" carried a `(200, 255, 200)` light colour from v1.0 that put a +20/255 green cast on the rim, and is now neutral white at `light_intensity` 1.0. +- **Known gaps / next steps:** output quality depends heavily on the input mask, and the only mask-quality guard is a console-only warning when a mask is >90% white; presets are plain dicts at the top of `relight.py` with no way for a user to add their own without editing the file; there is no example beyond the bundled workflow JSON; the rim highlight still does per-frame CPU SciPy work (`fg_mask[b].cpu().numpy()` + Sobel per frame) because vectorising it risks numeric drift, though the cast shadow added in v4.0.0 is vectorised in torch. - **Deep docs:** none — `README.md` is the user-facing reference and `relight.py` is the whole implementation. ## Architecture in 60 seconds - **Single node.** `relight.py` contains the entire feature — one node class, all logic self-contained. - **Up to 3 independent light sources.** Each with position, mode (colored light or color correction), mask shape (circular falloff or gradient), and fine-tuning controls. -- **Two lighting modes per source:** colored additive RGB light, or precise color correction (brightness, contrast, saturation, temperature, tint, gamma). -- **Mask shapes:** circular falloff (natural radial lighting with inner/outer radius) or gradient (directional lighting for sunset rays, window light effects). -- **3D subject interaction** (when used with mask input): front lighting, rim lighting (dramatic edge highlighting with background glow), or standard lighting. +- **Three lighting modes per source:** colored additive RGB light, precise color correction (brightness, contrast, saturation, temperature, tint, gamma), or both — colour first, then the grade applied to the lit result. +- **Mask shapes:** radial falloff (inner/outer radius) or directional gradient (sunset rays, window light). +- **3D subject interaction** (needs a mask): light in front of the subject, or behind it — rim highlight, a background glow with real falloff, and a cast shadow traced by `cast_shadow_mask` (a `grid_sample` ray march, run at `_SHADOW_TRACE_MAX` and upsampled; no SciPy). - **Built-in presets.** Soft Window Light, Dramatic Side Light, Warm Sunset Glow, Cool Blue Moonlight, Studio Key Light, Rim Light (Behind), Spotlight, Negative Light (Darken). -- **Visual debugging.** Shows exactly where lights are positioned and how they interact. +- **Visual debugging with no toggle.** Wiring the `debug_image` output is the whole gesture; see the convention below for why that needs a hidden input. +- **A frontend that hides what is inert.** `web/` carries the legacy-workflow migration, the debug-output tracking, conditional widget visibility and a working "Fix node (recreate)"; `tests/frontend/` drives all four outside a browser. ## Layout @@ -28,9 +29,16 @@ _Last verified: 2026-08-24_ |------|---------| | `__init__.py` | ComfyUI custom-node entry point (registers the ReLight node) | | `relight.py` | The entire node: lighting engine, presets, UI widgets, image processing | -| `requirements.txt` | numpy, Pillow, scipy | -| `tests/` | pytest suite (run in CI on Python 3.10-3.12) | +| `web/relight_migrate.js` | Remaps a pre-v4 save's positional widget values by name on `onConfigure` | +| `web/relight_debug.js` | Keeps `debug_output_connected` in step with the `debug_image` wiring, and hides it | +| `web/relight_ui.js` | Conditional visibility: hides irrelevant blocks, greys what a preset overrides, fits the node | +| `web/relight_recreate.js` | A "Fix node (recreate)" that replaces instead of duplicating | +| `web/relight_presets.js` | GENERATED - which widgets each preset overrides, and what it sets them to | +| `scripts/dump_frontend_fixture.py` | Dumps the live schema to `tests/frontend/fixtures/schema.json` for the JS tests (`--check` gates staleness) | +| `requirements-dev.txt` | test-only deps (numpy, Pillow, scipy, pytest, pinned ruff); the pack declares no runtime deps | +| `tests/` | pytest suite (CI, Python 3.10-3.12) plus `tests/frontend/` (`node --test`) and `tests/fixtures/` (a verbatim v3.1.2 save) | | `example_workflows/` | Example ComfyUI workflows demonstrating the node | +| `docs/images/` | README screenshots and renders; photographic panels are JPEG, UI captures PNG | ## Build / test / run @@ -41,10 +49,9 @@ _Last verified: 2026-08-24_ # Manual install cd ComfyUI/custom_nodes git clone https://github.com/EnragedAntelope/comfyui-relight -pip install -r comfyui-relight/requirements.txt -# Restart ComfyUI +# Restart ComfyUI - nothing to install -# Dependencies: numpy, Pillow, scipy (torch provided by ComfyUI) +# No runtime dependencies: numpy, Pillow, scipy and torch all come with ComfyUI core # Run tests (CI runs this on Python 3.10-3.12) pytest -q @@ -52,6 +59,12 @@ pytest -q # Lint (CI runs this as a separate job so a style nit can't hide a test result) ruff check . +# Frontend tests - drives the real web/*.js outside a browser (own CI job) +node --import ./tests/frontend/hooks.mjs --test "tests/frontend/*.test.mjs" + +# Regenerate the fixture the frontend tests build fake nodes from +python scripts/dump_frontend_fixture.py + # Manual QA in ComfyUI after any visible change: # node appears, presets work, lights position correctly, mask interaction works ``` @@ -60,13 +73,18 @@ ruff check . - Single-file node — `relight.py` is the entire feature. Keep it self-contained. - No models to download — pure image processing, deterministic output. -- Dependencies are lightweight: numpy, Pillow, scipy. torch comes from ComfyUI itself. +- **Declare no runtime dependencies.** numpy, Pillow, scipy and torch are all in ComfyUI core's own `requirements.txt` at floors at or above anything here, so `pyproject.toml` keeps `dependencies = []`. Test-only deps live in `requirements-dev.txt`, which both CI jobs install. - Works best with high-quality foreground masks (e.g. from ComfyUI Essentials). - The node uses the ComfyUI v3 schema (`comfy_api`, `v0_0_2` with a `latest` fallback). -- Widget inputs are stored *positionally* in saved workflows. Appending is safe; inserting, removing or reordering silently corrupts every workflow in the wild. `test_saved_workflow_widget_order_is_stable` pins the order. +- Widget inputs are stored *positionally* in saved workflows, but the order is **no longer frozen**. What keeps pre-v4 files loading is `web/relight_migrate.js`, which remaps them by name. So: any schema change - add, remove, rename, reorder - must be paired with a check that the migration still maps correctly, and the legacy order pinned in `tests/test_relight.py` (`LEGACY_WIDGET_ORDER`) must keep matching the JS constant. Get the migration wrong and every saved workflow loads plausible garbage with no error, which is worse than a crash. - A preset overrides whatever widgets it names — except `effect_strength`, which it scales (see `ReLight.STRENGTH_KEY`), and the `GEOMETRY_KEYS` when `preserve_positioning` is on. -- Presets are defined as dicts at the top of `relight.py` — easy to extend. -- `use_colored_lights` and the `inner_*`/`outer_*` correction values are **mutually exclusive** in the engine. Three presets ("Warm Sunset Glow", "Cool Blue Moonlight", "Rim Light (Behind)") set colored light *and* a full correction block, so 12 of their values are inert as shipped. Rendering both halves was measured on a real image and moves the mean by ~1/255 — merging the modes is a taste change with output drift, not a bug fix. +- Presets are defined as dicts at the top of `relight.py` — easy to extend. Re-run `scripts/dump_frontend_fixture.py` after any change to them or to the schema; `web/relight_presets.js` and `tests/frontend/fixtures/schema.json` are generated from those and a stale one fails the suite. +- **Hiding a widget needs both halves** — swap `widget.type` *and* set `widget.hidden = true` — and showing it again means `delete widget.computeSize`, never reassigning a saved copy (most widgets have no own `computeSize`, so the saved value is `undefined` and the zero-size stub stays forever). Grey out (`disabled = true`) anything a preset overrides rather than hiding it, so the node does not reshuffle under the pointer. Never resize from `onDrawForeground`; defer it a frame. +- **A greyed widget cannot show its own value.** The frontend's `_displayValue` getter returns `""` for anything with `computedDisabled` set, so `disabled = true` alone paints an empty bar with a dim label and no number. `relight_ui.js` writes the *preset's* value into `widget.label` instead, which still paints — and it must be the preset's value, not the widget's, because the widget still holds whatever the user last set, which is exactly the number the preset is ignoring. `PRESET_VALUES` in the generated `web/relight_presets.js` is what supplies them. Restoring is self-healing: any label shaped `name → value` is treated as ReLight's to clear, so a label that arrives without matching bookkeeping cannot strand a preset value on a live control. +- **Anything drawn near a frame edge needs a fallback side.** The debug view's `L1`/`L2` marker labels are drawn to the right of the marker, and a light at `light_position_x` 0.9 — where "Warm Sunset Glow" puts one — pushed them off the frame, where PIL clips silently. They flip to the left when there is no room. Test this by measuring which *side* of the marker the label's backing box lands on, on a white frame: on a black one the box is invisible against the input and the measurement silently passes with the bug still in. +- `lighting_mode` is two independent switches underneath (`apply_colored`, `apply_correction`); `Both` runs the colour pass and then grades the result. Pre-v4 this was one boolean and the two were mutually exclusive, which left 12 values inert in three presets. +- **The debug view has no toggle.** Connecting the `debug_image` output is the whole gesture. `debug_output_connected` is a hidden boolean input that `web/relight_debug.js` writes; it exists only because ComfyUI's cache key is built from a node's *inputs*, so without it, wiring an *output* would replay the cached placeholder. Never surface it as a control. +- **Anything drawn onto a full-resolution frame must scale with it.** `_debug_font_size()` is the one rule (3.5% of frame height, floored at 13px, capped at 64). v3.1.2 drew 13px type on a 1344x768 canvas - legible on the 96x64 test fixture, a black rectangle in a preview - and every debug test passed because they all ran on that fixture. Test overlays at a realistic resolution, measuring ink coverage inside the border, not `max() > 0`. - Never return an all-black image as an "empty" result. A wired preview makes it look like the node crashed; say why the frame is empty (`_blank_debug_image(image, reason)`). - **Never store per-run state on the class.** ComfyUI does not call `execute` on `ReLight`; it calls it on a *locked clone* (`ReLightClone`) whose metaclass raises `AttributeError` on any class-attribute write, and whose instances reject `__setattr__` too. `cls._coord_cache = ...` shipped in v3.0.0 and crashed every single run until v3.1.1. Caches belong at module level (`_COORD_CACHE`). - The `node` fixture in `tests/conftest.py` hands tests that same locked clone, mirroring `comfy_api.internal.lock_class`, so this class of bug fails in CI. Do not "simplify" it back to the bare class — the bare class is not what ComfyUI runs. diff --git a/README.md b/README.md index 6230c72..443dd9e 100644 --- a/README.md +++ b/README.md @@ -6,29 +6,80 @@ > **Relight your images without re-generating them.** -ReLight is a single, self-contained ComfyUI node that adds up to 3 positionable light sources to any image — colored additive light or precise color correction, with presets, directional gradients, rim lighting, and mask-aware 3D occlusion. It's fast and deterministic: pure image processing, no diffusion pass, no models to download. +ReLight is a single, self-contained ComfyUI node that adds up to 3 positionable light sources to any image — colored additive light, precise color correction, or both — with presets, directional gradients, rim lighting, and mask-aware 3D occlusion including a cast shadow. It's fast and deterministic: pure image processing, no diffusion pass, no models to download. -**Built on the ComfyUI v3 node schema.** -![ReLight Node Example](https://github.com/user-attachments/assets/34fa5b9f-65e6-4953-8bd4-65a349ed9455) +![The ReLight node in a ComfyUI workflow: an image and a mask in, the relit result and the debug view out](docs/images/relight-workflow.jpg) + +Two coloured lights, one behind the subject: a warm key from the upper right and a cool fill from the lower left. The third output, wired to the second preview, is the debug view — it shows where each light sits and which zones it covers, and it only draws when something is connected to it. + +![Before, and the same frame under two presets](docs/images/before-and-after.jpg) + +## 🔧 Install + +**ComfyUI-Manager (recommended):** open the Manager, search for **ReLight**, click Install, restart ComfyUI. + +**Manually:** + +```bash +cd path/to/ComfyUI/custom_nodes +git clone https://github.com/EnragedAntelope/comfyui-relight +# Restart ComfyUI - there is nothing else to install +``` + +**Requirements: ComfyUI 0.3.48 or newer, and nothing else.** ReLight is built on the ComfyUI v3 node schema (`comfy_api`), which first shipped in that release; on older builds the node will not load. It declares no dependencies of its own — `numpy`, `Pillow`, `scipy` and `torch` all ship with ComfyUI core, so a working ComfyUI already satisfies them. + +For good masks, [ComfyUI Essentials](https://github.com/cubiq/ComfyUI_essentials) is worth having alongside it — its RemBG nodes are what the bundled example workflow uses. + +## 🚀 Quick start + +1. **Add the ReLight 💡 node** (category: `image/lighting`) +2. **Connect your image** to `image` +3. **Connect a foreground mask** to `mask` — white = subject, black = background. Optional, but required for `subject_interaction` ("in front of" / "behind") and for `remove_background`. It is resized to the image automatically +4. **Pick a preset** — "Rim Light (Behind)" is the one that shows off what the node does — or leave `preset` on `None` and build the light yourself +5. **Turn `effect_strength` down or up.** It scales whatever the preset does: `1.0` is the preset as designed, `0.5` is half, `0.0` is untouched +6. **Wire `debug_image` to a Preview Image** if you want to see where the lights actually are. There is no toggle — connecting the output is the whole gesture + +> **Presets override the widgets below them.** The values shown on the node are ignored for whatever the preset defines, so ReLight greys those controls out and writes the preset's own value into the label — `mask_blur → 30` — instead of leaving a number you can drag that changes nothing. Set `preset` back to `None` to tune by hand, or turn on `preserve_positioning` to keep your own light positions and radii while the preset supplies everything else. `effect_strength` is the one exception: a preset sets a baseline and this widget *scales* it, so it always stays live. + +### Sample workflow + +A ready-to-load workflow is included: [example_workflows/relight_basic.json](https://github.com/EnragedAntelope/comfyui-relight/blob/main/example_workflows/relight_basic.json). It loads an image, builds a mask with ComfyUI Essentials' RemBG nodes, runs ReLight in "Warm Sunset Glow" behind the subject, and previews both the result and the debug view. ## 🌟 Features ### Powerful Lighting Control - **Multiple Light Sources** - Place up to 3 independent light sources anywhere in your image -- **Dynamic Lighting Modes**: - - 🎨 **Colored Lights** - Add RGB light with controllable intensity - - 🔄 **Color Correction** - Apply precise adjustments to brightness, contrast, saturation, temperature, tint and gamma -- **Flexible Mask Shapes**: - - 🔵 **Circular Falloff** - Natural radial lighting with inner/outer radius control - - ↗️ **Gradient** - Directional lighting for effects like sunset rays or window light +- **Three lighting modes** (`lighting_mode`): + - 🔄 **Color Correction** - Precise adjustments to brightness, contrast, saturation, temperature, tint and gamma + - 🎨 **Colored Light** - Additive RGB light with controllable intensity + - ✨ **Both** - The colour goes on first and the grade is applied to the result +- **Flexible Mask Shapes** (`mask_shape`): + - 🔵 **Radial falloff** - Natural radial lighting with inner/outer radius control + - ↗️ **Directional gradient** - Light arriving from one side, for sunset rays or window light ### Advanced 3D Lighting Simulation -- **Subject Interaction** (when used with mask input): - - 🔆 **Front Lighting** - Light illuminates the subject more strongly than background - - ✨ **Rim Lighting** - Creates dramatic edge highlighting with background glow - - 🌐 **Standard Lighting** - Traditional lighting without subject occlusion +- **Subject Interaction** (`subject_interaction`, needs a mask): + - 🌐 **None** - Lights the whole frame evenly, no mask needed + - 🔆 **Light in front of subject** - The subject catches more light than the background + - ✨ **Light behind subject (rim)** - A rim highlight along the subject's edge, a background glow with real falloff, and a shadow the subject casts across the background (`shadow_strength`, `shadow_length`) + +### A node that shows only what is live + +Lights 2 and 3 appear when `num_light_sources` asks for them, colour controls appear in the modes that use them, grading controls in the modes that use *those*, and the rim and shadow controls only when the light is behind the subject. The node resizes to fit, so the same node is a short panel or a tall one depending on what you actually asked it to do. + +![The same node in two configurations: one light in grading-only mode, and three lights in Both with the light behind the subject](docs/images/node-shows-only-live-controls.png) + +Anything the selected preset has taken over is greyed out rather than hidden — so picking a preset never reshuffles the node under your pointer — and the greyed label carries the preset's own value, which is how you learn what "Spotlight" is actually doing. + +![preset None beside preset Spotlight, whose overridden controls are greyed and labelled with the preset's values](docs/images/preset-greyed-controls.png) + +### Visual debugging with nothing to switch on + +Connect the `debug_image` output to a preview and you get a view of where every light sits and which zones it covers. Disconnect it and the node stops drawing it. There is no toggle to remember, and everything drawn scales with the frame, so it stays readable at full render resolution rather than becoming a dark rectangle in a preview thumbnail. + +![The debug view: a legend, the inner and outer mask zones tinted red and blue, and a labelled marker at each light's position](docs/images/debug-view.jpg) ### Production-Ready Features @@ -42,66 +93,7 @@ ReLight is a single, self-contained ComfyUI node that adds up to 3 positionable - "Spotlight" - Focused dramatic lighting - "Negative Light (Darken)" - Creative darkening effects -- **Visual Debugging** - See exactly where your lights are positioned and how they interact -- **Fine-Tuning Controls** - Perfect your lighting with precision adjustments for blur, strength, and rim amplification - -## 🔧 Installation - -### Using ComfyUI-Manager (Recommended) - -1. Open ComfyUI and navigate to the Manager -2. Search for "ReLight" in the available custom nodes -3. Click Install -4. Restart ComfyUI - -### Manual Installation - -```bash -# Navigate to your ComfyUI custom_nodes directory -cd path/to/ComfyUI/custom_nodes - -# Clone this repository -git clone https://github.com/EnragedAntelope/comfyui-relight - -# Install dependencies -pip install -r comfyui-relight/requirements.txt - -# Restart ComfyUI -``` - -### Requirements - -**ComfyUI 0.3.48 or newer.** ReLight is built on the ComfyUI v3 node schema (`comfy_api`), which first shipped in that release. On older builds the node will not load. - -ReLight needs `numpy`, `Pillow`, and `scipy` (installed automatically from `requirements.txt`; `torch` is provided by ComfyUI itself). - -ReLight works best with high-quality foreground masks. We recommend installing: - -- **[ComfyUI Essentials](https://github.com/cubiq/ComfyUI_essentials)** - Provides enhanced mask generation and background removal tools - -## 🚀 Quick Start Guide - -1. **Add the ReLight 💡 node** to your workflow (found under category "image/lighting") -2. **Connect your source image** -3. **Connect a foreground mask** (white = subject, black = background) — optional, but required for occlusion ("Behind Subject" / "In Front of Subject") and for `remove_background` compositing. It is resized automatically if it does not match the image -4. **Select a preset** like "Rim Light (Behind)" or design your own lighting -5. **Adjust settings** to taste -6. **Preview your results** in real-time - -> **Note on presets:** a preset overrides the widgets below it. The values shown on the node are ignored for whatever the preset defines, so don't be surprised when editing `inner_brightness` does nothing while a preset is active. Set `preset` back to `None` to tune by hand, or turn on `preserve_positioning` to keep your own light positions and radii while the preset supplies everything else. The one exception is `effect_strength`: it *scales* the preset rather than being replaced by it, so `1.0` is the preset as designed, `0.5` is half-strength and `0.0` is untouched. - -### Sample Workflow - -A ready-to-load workflow is included: [example_workflows/relight_basic.json](https://github.com/EnragedAntelope/comfyui-relight/blob/main/example_workflows/relight_basic.json) - -The repository includes a sample workflow that demonstrates: - -1. Loading an image -2. Removing the background using ComfyUI Essentials' RemBG nodes -3. Applying the ReLight node with "Warm Sunset Glow" preset in "Behind Subject" mode -4. Viewing the results through both standard preview and debug visualization - -Simply load this workflow in ComfyUI to see ReLight in action! +- **Fine-Tuning Controls** - Precision adjustments for blur, strength, rim amplification and shadow ## 📸 Examples @@ -113,7 +105,7 @@ This example uses three colored lights to create a purposely over the top striki - **Main Settings**: - 3 light sources - - "Behind Subject" light direction + - `subject_interaction`: "Light behind subject (rim)" - 2.0 effect strength - **Light 1 (Red)**: @@ -131,7 +123,7 @@ This example uses three colored lights to create a purposely over the top striki - RGB Color: (0, 0, 255) - Low intensity (0.2) -This setup creates vibrant color separation while maintaining the "Behind Subject" mode to emphasize the edges of the figure with dramatic rim lighting. +This setup creates vibrant color separation while the behind-subject mode emphasizes the edges of the figure with dramatic rim lighting. ### Other Lighting Ideas to Try @@ -145,27 +137,28 @@ Create dramatic portrait lighting with a strong contrast between light and shado - Large outer radius (0.8) - High contrast (25) - Reduced saturation (-15) -- "In Front of Subject" light direction +- `subject_interaction`: "Light in front of subject" #### Sunset Silhouette ![Spidey outlined by the sun](https://github.com/user-attachments/assets/8ed5851a-3607-46c1-a1ba-eb3d297733d4) Create a beautiful sunset silhouette effect: - Light positioned low and centered (0.5, 0.9) -- Warm colors (255, 180, 100) (enable "use colored lights") -- "Behind Subject" light direction +- Warm colors (255, 180, 100), `lighting_mode`: "Colored Light" +- `subject_interaction`: "Light behind subject (rim)" - High rim amplification (3.0) - Moderate mask blur (60) +- A long, soft shadow: `shadow_length` 0.6, `shadow_strength` 0.5 #### Atmospheric Fog Light ![Creature with foggy lighting](https://github.com/user-attachments/assets/db0d51da-6e9f-4359-9af0-2213954e010e) Simulate light breaking through fog or mist: - Light positioned high (0.5, 0.1) -- Cool blue-white color (200, 220, 255) (enable "use colored lights") -- "In Front of Subject" light direction +- Cool blue-white color (200, 220, 255), `lighting_mode`: "Colored Light" +- `subject_interaction`: "Light in front of subject" - High mask blur (100) -- Gradient mode enabled +- `mask_shape`: "Directional gradient" - Medium intensity (1.5) #### Moonlight Through Window @@ -173,8 +166,8 @@ Simulate light breaking through fog or mist: Simulate soft moonlight streaming through a window: - Light positioned at upper corner (0.8, 0.2) -- Cool blue color (120, 150, 255) (enable "use colored lights") -- Gradient mode enabled +- Cool blue color (120, 150, 255), `lighting_mode`: "Both" so the colour and the grade both land +- `mask_shape`: "Directional gradient" - Low brightness (-10) - High blue cast (Temperature -30) @@ -182,14 +175,15 @@ Simulate soft moonlight streaming through a window: ## 💡 Pro Tips - **Layer Multiple Lights** - Use several ReLight nodes in sequence for complex lighting setups -- **Debug View** - Enable `show_debug_info` to visualize light positions and better understand the effect +- **Debug View** - Wire the `debug_image` output to a Preview Image node to see light positions and mask zones; unwire it when you are done - **Mask Quality Matters** - The better your foreground mask, the more realistic your lighting effects - **Combine with ControlNet** - Use ReLight results as input for ControlNet for guided image generation - **Perfect Rim Lighting** - For the best rim effects: - 1. Position light behind subject - 2. Use "Behind Subject" light direction - 3. Increase rim_amplification for stronger edges - 4. Keep mask_blur values low (20-40) for crisp edges + 1. Position the light behind the subject + 2. Set `subject_interaction` to "Light behind subject (rim)" + 3. Increase `rim_amplification` for stronger edges + 4. Keep `mask_blur` low (20-40) for crisp edges + 5. Raise `shadow_length` to throw the subject's shadow further across the background, and `shadow_strength` to deepen it ## 📝 Parameter Guide @@ -201,12 +195,15 @@ Simulate soft moonlight streaming through a window: | **mask** | Foreground mask (white=subject, black=background). Resized to the image automatically | | **preset** | Select from pre-configured lighting setups. Overrides the widgets it defines | | **num_light_sources** | How many lights to use (1-3). Lights 2 and 3 have their own position, radius and color, but in color-correction mode they reuse Light 1's correction settings | -| **use_colored_lights** | Toggle between additive color and correction modes | -| **light_direction** | How light interacts with subject ("No Occlusion", "In Front", "Behind"). This is the control to use; `apply_3d_lighting` is just a master off-switch | +| **lighting_mode** | "Color Correction", "Colored Light", or "Both" (colour first, then grade the result) | +| **mask_shape** | "Radial falloff" (a lamp) or "Directional gradient" (light from one side) | +| **subject_interaction** | "None", "Light in front of subject", or "Light behind subject (rim)". The last two need a mask | | **remove_background** | Composite the lit result back over the untouched original using the mask, so only the subject is relit. Despite the name it removes nothing. Off by default | | **effect_strength** | Master intensity control for all lighting effects, gamma included. `0.0` leaves the image untouched. Scales a preset rather than being overridden by it. Does not scale `rim_amplification` or `mask_blur`, which have their own controls | | **mask_blur** | Controls softness of light edges and transitions | -| **rim_amplification** | Specifically enhances rim light intensity | +| **rim_amplification** | Strength of the rim highlight along the subject's edge (behind-subject mode only) | +| **shadow_strength** | How dark the shadow the subject casts across the background is; `0.0` casts none (behind-subject mode only) | +| **shadow_length** | How far that shadow reaches, as a fraction of the image's shorter side (behind-subject mode only) | ### A note on gamma @@ -228,48 +225,63 @@ Simulate soft moonlight streaming through a window: |---------|----------| | No visible effect | Increase effect_strength or light_intensity | | Light too strong | Decrease effect_strength or specific intensity/brightness values | -| Occlusion not working | Set light_direction to "Behind Subject" or "In Front of Subject" and connect a mask | +| Occlusion not working | Set `subject_interaction` away from "None" and connect a mask | | Only the subject changes, background untouched | `remove_background` is on — turn it off to light the whole frame | | Editing a slider does nothing | A preset is active and overrides it. Set preset to "None" (`effect_strength` still works — it scales the preset) | | Preset ignores its own light position/radius | `preserve_positioning` is on — turn it off to let the preset place its light | -| Black debug image | Check ComfyUI console for errors | -| Node fails to load | Ensure scipy is installed and ComfyUI is 0.3.48 or newer | +| Debug view is a bordered panel of text | Nothing is connected to `debug_image`. Wire it to a preview; it draws itself as soon as something consumes it | +| Node fails to load | ComfyUI must be 0.3.48 or newer. ReLight installs nothing of its own; numpy, Pillow, scipy and torch all come with ComfyUI | | Poor mask quality | Use RemBG from ComfyUI Essentials for better masks | -| Preset not working as expected | Try toggling use_colored_lights or apply_3d_lighting | +| Preset not working as expected | Check `lighting_mode` — the preset sets it, and "Colored Light" ignores every `inner_*`/`outer_*` value | | Subject looks unlit / effect appears reversed | Your mask may be inverted — ReLight expects white=subject, black=background (invert it upstream with an InvertMask node) | -| Debug image not showing correctly | Enable show_debug_info and check console logs | +| Half the controls are missing | They are hidden because they do nothing in the current mode. Raise `num_light_sources`, or change `lighting_mode` / `subject_interaction`, and they come back | +| A workflow saved before v4.0.0 looks wrong | Reload the page. The migration runs when the workflow loads and needs ReLight's frontend files, which arrive on a ComfyUI restart after updating | ## 📚 Detailed Parameters Reference ### Core Inputs -- **image**: Input image to apply lighting effects -- **mask** (optional): Foreground mask (White=Subject, Black=Background). Required for occlusion modes and background compositing +- **image**: Input image to apply lighting effects. RGB or RGBA; an alpha channel passes through untouched +- **mask** (optional): Foreground mask (white=subject, black=background). Needed for `subject_interaction` and for `remove_background`. Resized to the image automatically -### Global Behavior -- **preset**: Pre-configured starting points. Overrides the widgets it defines -- **num_light_sources**: Use 1, 2, or 3 lights -- **preserve_positioning**: Keep your own light positions and radii when a preset is selected, instead of letting the preset set them. Off by default so presets apply as designed -- **show_debug_info**: Output visualization showing base masks and light positions (first image of the batch). While it is off, the `debug_image` output is a placeholder frame telling you to turn it on — that output is never a rendered view unless this is enabled +### Preset +- **preset**: Pre-configured starting points. A preset overrides the widgets it names, and those widgets are greyed out on the node and relabelled with the preset's own value (`mask_blur → 30`), so you can read what it set without being able to fight it. The label goes back to the plain widget name the moment the preset is cleared +- **preserve_positioning**: Keep your own light positions and radii when a preset is selected, instead of letting the preset set them. Off by default, so presets apply as designed. Turning it on hands the geometry widgets back -### Lighting Mode & Occlusion -- **use_colored_lights**: Use additive colored light instead of color correction. The two are exclusive: with this on, every `inner_*` / `outer_*` correction value is ignored and only the light's RGB and `light_intensity` apply. "Warm Sunset Glow", "Cool Blue Moonlight" and "Rim Light (Behind)" switch it on, so those presets deliver a colored glow rather than a color grade — turn it off to get their grading half instead -- **use_gradient_mode**: Use directional gradient masks instead of radial -- **apply_3d_lighting**: Master switch for occlusion. Leave it on and drive the behaviour with `light_direction` -- **light_direction**: How light interacts with subject. "Behind"/"In Front" require a mask -- **remove_background**: Composite the lit result back over the untouched original using the mask. Ignored for "Behind Subject" and "In Front of Subject", which already light foreground and background separately +### Mode +- **lighting_mode**: + - **Color Correction** — grades an inner zone with the `inner_*` values and the ring around it with the `outer_*` values. The light's RGB and intensity are unused + - **Colored Light** — adds coloured light on top of the image, using the light's RGB and `light_intensity`. The grading values are unused + - **Both** — the coloured light goes on first, then the grade is applied to the lit result. Identical to chaining two ReLight nodes with the same settings, one in each single mode +- **mask_shape**: **Radial falloff** is a lamp — full strength inside `inner_circle_radius`, fading to nothing at `outer_circle_radius`. **Directional gradient** is light arriving from one side, for sunset rays and window light -### Global Modifiers -- **effect_strength**: Overall intensity multiplier for lighting, gamma included. `0.0` is a true no-op, with or without a preset. Presets set a baseline that this widget scales, so `1.0` gives the preset as designed. It does not scale `rim_amplification` or `mask_blur` — each has its own control +### Subject interaction +- **subject_interaction**: + - **None** — lights the whole frame evenly. No mask needed + - **Light in front of subject** — the subject catches more light than the background + - **Light behind subject (rim)** — three things at once: a rim highlight along the subject's edge, a background glow with radial falloff, and a shadow the subject casts across the background. Background light is masked off the subject *after* the blur, so it can never wash back over the silhouette +- **remove_background**: Composite the lit result back over the untouched original using the mask, so only the subject is relit. Despite the name it removes nothing. Ignored by the two subject-aware modes, which already light foreground and background separately + +### Global modifiers +- **num_light_sources**: 1, 2 or 3. Lights 2 and 3 have their own position, radius and colour, but in a grading mode they reuse Light 1's correction values +- **effect_strength**: Overall intensity multiplier for lighting, gamma included. `0.0` is a true no-op, with or without a preset. A preset sets a baseline that this widget scales, so `1.0` gives the preset as designed. It does not scale `rim_amplification` or `mask_blur` — each has its own control - **mask_blur**: Blur radius for light mask edges -- **rim_amplification**: Boost specifically for rim light component +- **rim_amplification**: Strength of the rim highlight. Only used behind the subject +- **shadow_strength**: How dark the cast shadow is, 0 to 1. `0.0` casts no shadow. Only used behind the subject +- **shadow_length**: How far the cast shadow reaches, as a fraction of the image's shorter side. Only used behind the subject -### Light Specific Settings (per light) -- **Position**: light_position_x/_y coordinates -- **Shape**: inner_circle_radius/outer_circle_radius -- **Color** (when using colored lights): light_color_r/_g/_b, light_intensity — available for all three lights -- **Corrections** (when using color correction): Brightness, Contrast, Saturation, Temperature, Tint, Gamma. These belong to Light 1; lights 2 and 3 reuse them at their own positions +### Light-specific settings (per light) +- **Position**: `light_position_x` / `_y`, normalized 0-1 +- **Shape**: `inner_circle_radius` (core) and `outer_circle_radius` (extent of the falloff) +- **Colour** (Colored Light / Both): `light_color_r` / `_g` / `_b`, `light_intensity` — each of the three lights has its own +- **Grading** (Color Correction / Both): brightness, contrast, saturation, temperature, tint and gamma, for the inner zone and the outer ring. These belong to Light 1; lights 2 and 3 reuse them at their own positions -In color-correction mode with `light_direction` on "No Occlusion", the `inner_*` settings apply inside `inner_circle_radius` and the `outer_*` settings apply in the ring out to `outer_circle_radius`. Outside that ring the image is untouched. The two occlusion modes ("Behind Subject" and "In Front of Subject") build a single subject-aware light mask instead, and apply the `inner_*` settings through it — the `outer_*` settings are not used there. +With `subject_interaction` on "None", the `inner_*` settings apply inside `inner_circle_radius` and the `outer_*` settings apply in the ring out to `outer_circle_radius`. Outside that ring the image is untouched. The two subject-aware modes build a single subject-aware light mask instead and apply the `inner_*` settings through it — the `outer_*` settings are not used there. + +### The debug view + +There is no toggle. Connect the third output, `debug_image`, to a Preview Image node and the node draws the visualization; disconnect it and the node stops. While nothing is connected, that output carries a bordered panel of text saying so, rather than a black frame that would look like a crash. + +The node carries one input you will not see: `debug_output_connected`, which ReLight's own frontend keeps in step with the wiring and hides from the node body. It exists because ComfyUI decides whether a node needs re-running from its *inputs*, so without it, connecting an *output* would replay the cached placeholder into your new preview. If you drive ReLight through the `/prompt` API rather than the UI, the node reads the submitted prompt instead and reaches the same answer. ## 📜 License @@ -284,14 +296,32 @@ Contributions are welcome! Please feel free to submit a Pull Request. The node can be tested without a ComfyUI install — `tests/stubs` provides a stand-in for `comfy_api`: ```bash -pip install -r requirements.txt -pip install torch pytest ruff +pip install -r requirements-dev.txt pytest -q ruff check . ``` ### 🔄 Updates +- **v4.0.0** - A breaking release: four controls merged or renamed, three presets retuned, and "behind subject" finally occludes + + **Old workflows still load correctly.** ReLight now ships frontend JavaScript that remaps a pre-v4 save's widget values by name when the workflow opens, so every value lands on the widget it belongs to. They are *not* guaranteed to render identically — see the drift note at the end. + + - **Fixed: "Behind Subject" did not occlude anything.** Three separate causes, all of them now addressed. The silhouette was subtracted from the background light *before* the blur, so a 50px blur smeared background light straight back across the edge onto the subject's face; the two halves are now blurred separately and the silhouette re-applied afterwards, so background light can never land on the subject. The subject cast no shadow at all, so the near side of a head was lit exactly as brightly as the far side; it now casts one, traced back toward the light with the new `shadow_strength` and `shadow_length` controls. And the background "glow" was a hard-edged disc with no falloff; it now uses the same radial falloff as every other light + - **Fixed: colored light and color correction were mutually exclusive.** `use_colored_lights` became `lighting_mode`, with a third option, **Both**, that applies the coloured light and then grades the result. Three presets — "Warm Sunset Glow", "Cool Blue Moonlight" and "Rim Light (Behind)" — set a colour *and* a full grading block, so 12 values in each of them did nothing at all. They now run in **Both**, with their colour intensities pulled down to account for the grade no longer being discarded + - **Fixed: "Rim Light (Behind)" lit everything green.** Its light colour had been `(200, 255, 200)` since v1.0 — a green-tinted white that put a +20/255 green cast on the rim and the background glow. It was easy to miss while the grading block was still being discarded; once `inner_saturation` started landing, the preset rendered a distinctly green edge. The light is now neutral white, with `light_intensity` 1.2 → 1.0 so that removing the tint does not also make the preset brighter — mean brightness lands within 2% of what v3.1.2 produced + - **Fixed: the debug view was invisible at real resolutions.** v3.1.2 replaced the black `debug_image` frame with a placeholder, then drew it at 13px on a full-resolution canvas — 1.7% of the height of a 768px render, which inside a preview thumbnail is a dark rectangle. Everything drawn on the debug view now scales with the frame, with a visible border, so it reads as a panel rather than a dead output. The same applies to the real debug view's legend, labels and light markers + - **The debug view has no toggle any more.** Connect `debug_image` to a preview and it draws; disconnect it and it stops. `show_debug_info` is gone + - **Fixed: "Fix node (recreate)" duplicated the node.** That menu entry comes from ComfyUI-Manager, whose implementation passes a string node id to `connect()` and throws part-way through, leaving both the original and its replacement on the canvas. ReLight now ships its own correct version and takes the broken entry out of its own nodes' menus (upstream: Comfy-Org/ComfyUI-Manager#3126) + - **The node now shows only the controls that are doing something.** Lights 2 and 3 appear when `num_light_sources` asks for them, colour controls appear in the modes that use them, grading controls in the modes that use *those*, and rim/shadow controls only when the light is behind the subject. Anything the selected preset has taken over is greyed out rather than hidden, and carries the preset's own value in its label — `mask_blur → 30` — because the ComfyUI frontend blanks the displayed value of any disabled widget, so greying alone would have left a row of empty bars. The node resizes to fit + - **Fixed: a light near the right edge lost its debug label.** The `L1`/`L2` marker labels are drawn to the right of the marker, so a light at `light_position_x` 0.9 — which is where "Warm Sunset Glow" puts one — pushed the text off the frame and it was silently clipped. The label now flips to the left of the marker when there is no room on the right + - **Renamed and merged controls.** `use_colored_lights` → `lighting_mode`; `use_gradient_mode` → `mask_shape`; `apply_3d_lighting` + `light_direction` → `subject_interaction` (the master switch existed only to force "No Occlusion", so it collapsed into the choice it was gating); `show_debug_info` → removed. New: `shadow_strength`, `shadow_length` + - **Packaging: ReLight declares no dependencies.** `requirements.txt` is gone. numpy, Pillow, scipy and torch all ship with ComfyUI core at versions at or above anything this node needs, so declaring them again could only ever pull a different version into a working install. Manual installation is now a `git clone` and a restart + + **Output drift, measured rather than claimed.** Sweeping all 8 presets across all 3 subject interactions and both single lighting modes — 54 combinations — 24 come back bit-identical to v3.1.2. Every changed combination is one of exactly two things: it uses the behind-subject path, or it uses one of the three retuned presets. Nothing else moved by a single bit. Peak difference is 242/255 on "Rim Light (Behind)", which is the intended effect of that preset finally rendering both of its halves, dropping its green cast and casting a shadow + + **If you drive ReLight through the `/prompt` API** rather than the ComfyUI editor, note that the migration is a frontend feature: an API-format prompt that still names the old inputs will have them ignored and pick up the new defaults instead. Update those prompts to the new control names + - **v3.1.2** - The debug output explains itself instead of going black - **Fixed: `debug_image` was a solid black frame whenever `show_debug_info` was off**, which is indistinguishable from a crashed node if you have that output wired to a preview. It now renders a legible placeholder naming the toggle that fills it. The same placeholder explains the other two empty cases: no light masks were generated, or the debug view failed to draw (with a console pointer) - Documented that `use_colored_lights` and the `inner_*`/`outer_*` correction values are mutually exclusive, and which presets ship with colored light on diff --git a/__init__.py b/__init__.py index 12170c1..06d2d6e 100644 --- a/__init__.py +++ b/__init__.py @@ -7,4 +7,10 @@ else: # does this because the repo root is itself a package). from relight import comfy_entrypoint -__all__ = ["comfy_entrypoint"] +#: Frontend JavaScript: the legacy-workflow migration, the conditional widget +#: visibility, and a working "Fix node (recreate)". Served by ComfyUI from this +#: directory; without it a pre-v4 workflow loads its widget values positionally +#: into a schema that has moved. +WEB_DIRECTORY = "./web" + +__all__ = ["comfy_entrypoint", "WEB_DIRECTORY"] diff --git a/docs/images/before-and-after.jpg b/docs/images/before-and-after.jpg new file mode 100644 index 0000000..fa867dc Binary files /dev/null and b/docs/images/before-and-after.jpg differ diff --git a/docs/images/debug-view.jpg b/docs/images/debug-view.jpg new file mode 100644 index 0000000..4798add Binary files /dev/null and b/docs/images/debug-view.jpg differ diff --git a/docs/images/node-shows-only-live-controls.png b/docs/images/node-shows-only-live-controls.png new file mode 100644 index 0000000..159db8b Binary files /dev/null and b/docs/images/node-shows-only-live-controls.png differ diff --git a/docs/images/preset-greyed-controls.png b/docs/images/preset-greyed-controls.png new file mode 100644 index 0000000..0931ce0 Binary files /dev/null and b/docs/images/preset-greyed-controls.png differ diff --git a/docs/images/relight-workflow.jpg b/docs/images/relight-workflow.jpg new file mode 100644 index 0000000..bd81f51 Binary files /dev/null and b/docs/images/relight-workflow.jpg differ diff --git a/example_workflows/relight_basic.json b/example_workflows/relight_basic.json index 0f656e6..7b2564f 100644 --- a/example_workflows/relight_basic.json +++ b/example_workflows/relight_basic.json @@ -215,7 +215,7 @@ ], "size": [ 317.4000244140625, - 1226 + 1250 ], "flags": {}, "order": 3, @@ -260,17 +260,18 @@ }, "widgets_values": [ "Warm Sunset Glow", + true, + "Color Correction", + "Radial falloff", + "Light behind subject (rim)", + true, 1, - true, - true, - false, - false, - true, - "Behind Subject", - true, 1, 50, 2, + 0.6, + 0.35, + true, 0.26000000000000006, 0.31000000000000005, 0.4, @@ -380,4 +381,4 @@ } }, "version": 0.4 -} \ No newline at end of file +} diff --git a/pyproject.toml b/pyproject.toml index aff9bcf..ef76395 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,14 @@ [project] name = "ReLight" -description = "Relight images without re-generating them: up to 3 positionable light sources, colored or corrective light, presets, rim lighting, and mask-aware 3D occlusion - fast, deterministic, no models to download." -version = "3.1.2" +description = "Relight images without re-generating them: up to 3 positionable light sources, colored light, color correction or both, presets, rim lighting, and mask-aware occlusion with a cast shadow - fast, deterministic, no models to download." +version = "4.0.0" requires-python = ">=3.10" license = {file = "LICENSE"} -# Floors mirror requirements.txt; keep the two in sync. -dependencies = ["numpy>=1.19.0", "Pillow>=9.0.0", "scipy>=1.7.0"] +# Deliberately empty. numpy, Pillow, scipy and torch are all supplied by +# ComfyUI core's own requirements.txt at floors at or above anything this node +# needs, so declaring them again only risks Manager/Registry resolving a +# different version into a working install. Test-only deps: requirements-dev.txt. +dependencies = [] [project.urls] Repository = "https://github.com/EnragedAntelope/comfyui-relight" diff --git a/relight.py b/relight.py index 07c545b..b05bc7d 100644 --- a/relight.py +++ b/relight.py @@ -43,6 +43,11 @@ GAMMA_MAX = 5.0 # up on the first mask of every run. Never store per-run state on the class. _COORD_CACHE = {} +# The cast-shadow trace runs at or below this resolution and is upsampled. +# The result is blurred immediately afterwards, so tracing a 4K frame at full +# size buys nothing visible and costs 60x the work of tracing it at 512. +_SHADOW_TRACE_MAX = 512 + def _supports_advanced(): """Whether this ComfyUI build accepts `advanced=` on widget inputs. @@ -80,6 +85,31 @@ def _wrap_debug_text(draw, text, font, max_width): return lines +def _debug_font_size(height): + """Type size for the debug overlays, as a fraction of the frame height. + + Fixed 13px type is legible on the 96x64 fixture the tests used and invisible + on a real 1300px render - 1.7% of the height, which inside a ComfyUI preview + thumbnail is a dark rectangle. That is exactly how the v3.1.2 placeholder + shipped "working" and was reported as a black frame. Scale with the frame, + with a floor so small images stay readable and a cap so a 4K render does not + get billboard type. + """ + return int(min(64, max(13, round(height * 0.035)))) + + +def _load_debug_font(size): + """The default font at `size`, falling back through older Pillow APIs.""" + try: + return ImageFont.load_default(size=size) + except Exception: + try: + return ImageFont.load_default() + except Exception as err: # pragma: no cover - no usable font at all + logger.debug(f" Could not load a default font: {err}") + return None + + def _adv(**kwargs): """Pass input kwargs through, dropping `advanced` where unsupported.""" if not _ADVANCED_SUPPORTED: @@ -94,6 +124,25 @@ class ReLight(io.ComfyNode): and 3D lighting simulation with subject occlusion. Requires SciPy. """ + # --- Mode vocabularies ------------------------------------------------- + # v4.0.0 replaced four booleans (use_colored_lights, use_gradient_mode, + # apply_3d_lighting, show_debug_info) with these named choices. The option + # strings are part of the saved-workflow format and are mirrored in + # web/relight_migrate.js - change one and you must change the other. + MODE_CORRECTION = "Color Correction" + MODE_COLORED = "Colored Light" + MODE_BOTH = "Both" + LIGHTING_MODES = (MODE_CORRECTION, MODE_COLORED, MODE_BOTH) + + SHAPE_RADIAL = "Radial falloff" + SHAPE_GRADIENT = "Directional gradient" + MASK_SHAPES = (SHAPE_RADIAL, SHAPE_GRADIENT) + + SUBJECT_NONE = "None" + SUBJECT_FRONT = "Light in front of subject" + SUBJECT_RIM = "Light behind subject (rim)" + SUBJECT_INTERACTIONS = (SUBJECT_NONE, SUBJECT_FRONT, SUBJECT_RIM) + # Gamma follows the display convention used by Photoshop's Levels midtone # slider, ImageMagick's -gamma and ffmpeg's eq filter: output = input^(1/gamma), # so values above 1.0 brighten and values below 1.0 darken. Preset gammas below @@ -116,15 +165,15 @@ class ReLight(io.ComfyNode): "light_position_x": 0.9, "light_position_y": 0.4, "inner_circle_radius": 0.5, "outer_circle_radius": 0.8, "inner_brightness": 3, "inner_contrast": 5, "inner_saturation": 10, "inner_temperature": 25, "inner_tint": -5, "inner_gamma": 1.05, "outer_brightness": -10, "outer_contrast": 0, "outer_saturation": -5, "outer_temperature": 15, "outer_tint": -5, "outer_gamma": 0.91, - "mask_blur": 75, "use_colored_lights": True, "light_color_r": 255, "light_color_g": 200, "light_color_b": 120, - "rim_amplification": 1.0, "use_gradient_mode": True + "mask_blur": 75, "lighting_mode": MODE_BOTH, "light_color_r": 255, "light_color_g": 200, "light_color_b": 120, + "light_intensity": 0.45, "rim_amplification": 1.0, "mask_shape": SHAPE_GRADIENT }, "Cool Blue Moonlight": { "light_position_x": 0.8, "light_position_y": 0.2, "inner_circle_radius": 0.4, "outer_circle_radius": 0.7, "inner_brightness": -5, "inner_contrast": 5, "inner_saturation": -5, "inner_temperature": -20, "inner_tint": 0, "inner_gamma": 0.91, "outer_brightness": -20, "outer_contrast": 0, "outer_saturation": -10, "outer_temperature": -30, "outer_tint": 0, "outer_gamma": 0.83, - "mask_blur": 60, "use_colored_lights": True, "light_color_r": 120, "light_color_g": 150, "light_color_b": 255, - "rim_amplification": 1.0 + "mask_blur": 60, "lighting_mode": MODE_BOTH, "light_color_r": 120, "light_color_g": 150, "light_color_b": 255, + "light_intensity": 0.35, "rim_amplification": 1.0 }, "Studio Key Light": { "light_position_x": 0.4, "light_position_y": 0.3, "inner_circle_radius": 0.6, "outer_circle_radius": 0.9, @@ -134,11 +183,17 @@ class ReLight(io.ComfyNode): }, "Rim Light (Behind)": { "light_position_x": 0.5, "light_position_y": 0.1, "inner_circle_radius": 0.3, "outer_circle_radius": 0.6, - "apply_3d_lighting": True, "light_direction": "Behind Subject", "use_colored_lights": True, - "light_color_r": 200, "light_color_g": 255, "light_color_b": 200, "light_intensity": 1.2, - "inner_brightness": 0, "inner_contrast": 0, "inner_saturation": 0, "inner_temperature": 0, "inner_tint": 0, "inner_gamma": 1.0, + "subject_interaction": SUBJECT_RIM, "lighting_mode": MODE_BOTH, + # Neutral white. This shipped as (200, 255, 200) from v1.0, which put a + # +20/255 green cast on the rim and the background glow - invisible while + # the grading block was being discarded, obvious once inner_saturation + # landed. Intensity drops 1.2 -> 1.0 so removing the tint does not also + # make the preset brighter: mean lift stays within 2% of what shipped. + "light_color_r": 255, "light_color_g": 255, "light_color_b": 255, "light_intensity": 1.0, + "inner_brightness": 0, "inner_contrast": 8, "inner_saturation": 12, "inner_temperature": 0, "inner_tint": 0, "inner_gamma": 1.0, "outer_brightness": 0, "outer_contrast": 0, "outer_saturation": 0, "outer_temperature": 0, "outer_tint": 0, "outer_gamma": 1.0, - "mask_blur": 25, "effect_strength": 1.5, "rim_amplification": 2.5 + "mask_blur": 25, "effect_strength": 1.5, "rim_amplification": 2.5, + "shadow_strength": 0.7, "shadow_length": 0.45 }, "Spotlight": { "light_position_x": 0.5, "light_position_y": 0.4, "inner_circle_radius": 0.1, "outer_circle_radius": 0.25, @@ -154,6 +209,12 @@ class ReLight(io.ComfyNode): } } + # Three presets ("Warm Sunset Glow", "Cool Blue Moonlight", "Rim Light + # (Behind)") set a colour *and* a grading block. Up to v3.1.2 the two were + # mutually exclusive, so 12 values in each did nothing; they now run as + # MODE_BOTH, and the colour intensities were pulled down to compensate for + # the grade no longer being discarded. + # Scaled by the user's widget rather than overridden outright; see _load_preset. STRENGTH_KEY = "effect_strength" @@ -168,60 +229,69 @@ class ReLight(io.ComfyNode): def define_schema(cls) -> io.Schema: """Define node schema for ComfyUI v3. - NOTE: saved workflows store widget values *positionally*. Adding, - removing or reordering an input silently corrupts every workflow already - saved against this node. Append new inputs at the end. + Widget order is grouped for reading, not frozen: saved workflows store + widget values *positionally*, and what keeps pre-v4 files loading is + ``web/relight_migrate.js``, which remaps them by name. Any change to + this list - adding, removing, renaming or reordering - must be paired + with a check that the migration still produces the right mapping, and + with the legacy order pinned in ``tests/test_relight.py``. """ return io.Schema( node_id="ReLight", display_name="ReLight 💡", category="image/lighting", description="Creates realistic lighting effects with multiple light sources, colored lights, and 3D lighting simulation with subject occlusion", + hidden=[io.Hidden.prompt, io.Hidden.unique_id], inputs=[ # --- Core Inputs --- io.Image.Input("image", tooltip="The input image to apply lighting effects to"), - io.Mask.Input("mask", optional=True, tooltip="Foreground mask (white=subject, black=background). Needed for occlusion ('Behind Subject' / 'In Front of Subject') and for 'remove_background' compositing. Resized automatically if it does not match the image"), + io.Mask.Input("mask", optional=True, tooltip="Foreground mask (white=subject, black=background). Needed for subject interaction (in front of / behind the subject) and for remove_background compositing. Resized automatically if it does not match the image"), - # --- Global Behavior --- - io.Combo.Input("preset", options=list(cls.PRESETS.keys()), default="None", tooltip="Select a preset or 'None' for custom settings. NOTE: a preset overrides the widgets below - the values shown on the node are ignored for whatever the preset defines. The one exception is 'effect_strength', which scales the preset instead of being replaced by it"), - io.Int.Input("num_light_sources", default=1, min=1, max=3, step=1, tooltip="Number of light sources (1-3). Lights 2 and 3 have their own position, radius and color, but in color-correction mode they reuse Light 1's correction settings"), + # --- Preset --- + io.Combo.Input("preset", options=list(cls.PRESETS.keys()), default="None", tooltip="Select a preset or None for custom settings. NOTE: a preset overrides the widgets below - the values shown on the node are ignored for whatever the preset defines. The one exception is effect_strength, which scales the preset instead of being replaced by it"), io.Boolean.Input("preserve_positioning", **_adv(default=False, advanced=True, tooltip="Keep your own light positions and radii when a preset is selected, instead of letting the preset set them")), - io.Boolean.Input("show_debug_info", **_adv(default=False, advanced=True, tooltip="Output a debug visualization image (first image of the batch; zone overlays show Light 1, position and radius indicators are drawn for every light)")), - # --- Lighting Mode & Occlusion --- - io.Boolean.Input("use_colored_lights", default=False, tooltip="Use additive colored light instead of color correction?"), - io.Boolean.Input("use_gradient_mode", default=False, tooltip="Use directional gradient masks instead of radial?"), - io.Boolean.Input("apply_3d_lighting", **_adv(default=True, advanced=True, tooltip="Master switch for occlusion. Leave on and use 'light_direction' to choose the behaviour; turning this off forces 'No Occlusion'")), - io.Combo.Input("light_direction", options=["Behind Subject", "In Front of Subject", "No Occlusion"], default="No Occlusion", tooltip="How light interacts with the subject. 'Behind'/'In Front' require a mask"), - io.Boolean.Input("remove_background", default=False, tooltip="Composite the lit result back over the untouched original using the mask, so only the subject is relit. Does not remove anything. Ignored for 'Behind Subject' and 'In Front of Subject'"), + # --- Mode --- + io.Combo.Input("lighting_mode", options=list(cls.LIGHTING_MODES), default=cls.MODE_CORRECTION, tooltip="What the light does. Color Correction grades an inner zone and the ring around it. Colored Light adds coloured light on top of the image. Both applies the coloured light first and then the grade"), + io.Combo.Input("mask_shape", options=list(cls.MASK_SHAPES), default=cls.SHAPE_RADIAL, tooltip="Shape of the light mask. Radial falloff is a lamp; Directional gradient is light arriving from one side (sunset rays, window light)"), - # --- Global Modifiers --- - io.Float.Input("effect_strength", default=1.0, min=0.0, max=5.0, step=0.1, tooltip="Overall intensity multiplier for lighting adjustments/colors, gamma included. 0.0 leaves the image untouched. With a preset active this scales the preset's own strength, so 1.0 is the preset as designed. Does not scale rim_amplification or mask_blur - those have their own controls"), + # --- Subject interaction --- + io.Combo.Input("subject_interaction", options=list(cls.SUBJECT_INTERACTIONS), default=cls.SUBJECT_NONE, tooltip="How the light interacts with the masked subject. None lights the whole frame evenly. The other two need a mask connected"), + io.Boolean.Input("remove_background", default=False, tooltip="Composite the lit result back over the untouched original using the mask, so only the subject is relit. Does not remove anything. Ignored when the light is in front of or behind the subject"), + + # --- Global modifiers --- + io.Int.Input("num_light_sources", default=1, min=1, max=3, step=1, tooltip="Number of light sources (1-3). Lights 2 and 3 have their own position, radius and color, but in color-correction mode they reuse Light 1 correction settings"), + io.Float.Input("effect_strength", default=1.0, min=0.0, max=5.0, step=0.1, tooltip="Overall intensity multiplier for lighting adjustments/colors, gamma included. 0.0 leaves the image untouched. With a preset active this scales the preset own strength, so 1.0 is the preset as designed. Does not scale rim_amplification or mask_blur - those have their own controls"), io.Float.Input("mask_blur", default=50.0, min=0.0, max=200.0, step=1.0, tooltip="Blur radius for light mask edges (smoother transitions)"), - io.Float.Input("rim_amplification", default=2.0, min=0.0, max=10.0, step=0.1, tooltip="Intensity boost specifically for rim light component (when 'Behind Subject')"), + io.Float.Input("rim_amplification", default=2.0, min=0.0, max=10.0, step=0.1, tooltip="Intensity boost for the rim highlight along the subject edge. Only used when the light is behind the subject"), + io.Float.Input("shadow_strength", default=0.6, min=0.0, max=1.0, step=0.05, tooltip="How dark the shadow the subject casts across the background is. 0.0 casts no shadow. Only used when the light is behind the subject"), + io.Float.Input("shadow_length", default=0.35, min=0.0, max=1.0, step=0.01, tooltip="How far the cast shadow reaches, as a fraction of the image shorter side. Only used when the light is behind the subject"), + io.Boolean.Input("debug_output_connected", **_adv(default=False, optional=True, advanced=True, tooltip="Managed by ReLight itself - the node UI hides this and keeps it in step with whether the debug_image output is wired to anything. It exists because a widget value is what ComfyUI hashes to decide a node needs re-running, so without it, wiring the debug output after a run would just replay the cached placeholder. Nothing to set by hand")), - # --- Light 1 Settings --- - # Position & Shape + # --- Light 1: position and shape --- io.Float.Input("light_position_x", default=0.5, min=0.0, max=1.0, step=0.01, tooltip="Light 1: Horizontal position (0=left, 1=right)"), io.Float.Input("light_position_y", default=0.5, min=0.0, max=1.0, step=0.01, tooltip="Light 1: Vertical position (0=top, 1=bottom)"), io.Float.Input("inner_circle_radius", default=0.4, min=0.0, max=1.0, step=0.01, tooltip="Light 1: Inner radius (strongest effect area)"), io.Float.Input("outer_circle_radius", default=0.7, min=0.0, max=1.0, step=0.01, tooltip="Light 1: Outer radius (falloff area)"), - # Colored Light Mode - io.Int.Input("light_color_r", default=255, min=0, max=255, step=1, tooltip="Light 1: Red color (if 'Use Colored Lights' is True)"), - io.Int.Input("light_color_g", default=255, min=0, max=255, step=1, tooltip="Light 1: Green color (if 'Use Colored Lights' is True)"), - io.Int.Input("light_color_b", default=255, min=0, max=255, step=1, tooltip="Light 1: Blue color (if 'Use Colored Lights' is True)"), - io.Float.Input("light_intensity", default=1.0, min=0.0, max=3.0, step=0.1, tooltip="Light 1: Intensity (if 'Use Colored Lights' is True)"), - # Color Correction Mode (Inner Area) - io.Float.Input("inner_brightness", default=10.0, min=-100.0, max=100.0, step=1.0, tooltip="Light 1: Inner area brightness (Color Correction mode)"), - io.Float.Input("inner_contrast", default=5.0, min=-100.0, max=100.0, step=1.0, tooltip="Light 1: Inner area contrast (Color Correction mode)"), - io.Float.Input("inner_saturation", default=5.0, min=-100.0, max=100.0, step=1.0, tooltip="Light 1: Inner area saturation (Color Correction mode)"), + + # --- Light 1: colour (Colored Light / Both) --- + io.Int.Input("light_color_r", default=255, min=0, max=255, step=1, tooltip="Light 1: Red color (Colored Light / Both)"), + io.Int.Input("light_color_g", default=255, min=0, max=255, step=1, tooltip="Light 1: Green color (Colored Light / Both)"), + io.Int.Input("light_color_b", default=255, min=0, max=255, step=1, tooltip="Light 1: Blue color (Colored Light / Both)"), + io.Float.Input("light_intensity", default=1.0, min=0.0, max=3.0, step=0.1, tooltip="Light 1: Intensity (Colored Light / Both)"), + + # --- Light 1: grading, inner zone (Color Correction / Both) --- + io.Float.Input("inner_brightness", default=10.0, min=-100.0, max=100.0, step=1.0, tooltip="Light 1: Inner area brightness (Color Correction / Both)"), + io.Float.Input("inner_contrast", default=5.0, min=-100.0, max=100.0, step=1.0, tooltip="Light 1: Inner area contrast (Color Correction / Both)"), + io.Float.Input("inner_saturation", default=5.0, min=-100.0, max=100.0, step=1.0, tooltip="Light 1: Inner area saturation (Color Correction / Both)"), io.Float.Input("inner_temperature", default=0.0, min=-100.0, max=100.0, step=1.0, tooltip="Light 1: Inner area temperature (-100=cool, 100=warm)"), io.Float.Input("inner_tint", default=0.0, min=-100.0, max=100.0, step=1.0, tooltip="Light 1: Inner area tint (-100=magenta, 100=green)"), io.Float.Input("inner_gamma", default=1.0, min=GAMMA_MIN, max=GAMMA_MAX, step=0.05, tooltip="Light 1: Inner area gamma. Above 1.0 brightens midtones, below 1.0 darkens them"), - # Color Correction Mode (Outer Area) - io.Float.Input("outer_brightness", **_adv(default=-10.0, min=-100.0, max=100.0, step=1.0, advanced=True, tooltip="Light 1: Outer area brightness (Color Correction mode)")), - io.Float.Input("outer_contrast", **_adv(default=0.0, min=-100.0, max=100.0, step=1.0, advanced=True, tooltip="Light 1: Outer area contrast (Color Correction mode)")), - io.Float.Input("outer_saturation", **_adv(default=-10.0, min=-100.0, max=100.0, step=1.0, advanced=True, tooltip="Light 1: Outer area saturation (Color Correction mode)")), + + # --- Light 1: grading, outer ring (Color Correction / Both) --- + io.Float.Input("outer_brightness", **_adv(default=-10.0, min=-100.0, max=100.0, step=1.0, advanced=True, tooltip="Light 1: Outer area brightness (Color Correction / Both)")), + io.Float.Input("outer_contrast", **_adv(default=0.0, min=-100.0, max=100.0, step=1.0, advanced=True, tooltip="Light 1: Outer area contrast (Color Correction / Both)")), + io.Float.Input("outer_saturation", **_adv(default=-10.0, min=-100.0, max=100.0, step=1.0, advanced=True, tooltip="Light 1: Outer area saturation (Color Correction / Both)")), io.Float.Input("outer_temperature", **_adv(default=0.0, min=-100.0, max=100.0, step=1.0, advanced=True, tooltip="Light 1: Outer area temperature")), io.Float.Input("outer_tint", **_adv(default=0.0, min=-100.0, max=100.0, step=1.0, advanced=True, tooltip="Light 1: Outer area tint")), io.Float.Input("outer_gamma", **_adv(default=0.91, min=GAMMA_MIN, max=GAMMA_MAX, step=0.05, advanced=True, tooltip="Light 1: Outer area gamma. Above 1.0 brightens midtones, below 1.0 darkens them")), @@ -234,7 +304,7 @@ class ReLight(io.ComfyNode): io.Int.Input("light2_color_r", **_adv(default=180, min=0, max=255, step=1, optional=True, advanced=True, tooltip="Light 2: Red color")), io.Int.Input("light2_color_g", **_adv(default=180, min=0, max=255, step=1, optional=True, advanced=True, tooltip="Light 2: Green color")), io.Int.Input("light2_color_b", **_adv(default=255, min=0, max=255, step=1, optional=True, advanced=True, tooltip="Light 2: Blue color")), - io.Float.Input("light2_intensity", **_adv(default=0.7, min=0.0, max=3.0, step=0.1, optional=True, advanced=True, tooltip="Light 2: Intensity (Colored mode)")), + io.Float.Input("light2_intensity", **_adv(default=0.7, min=0.0, max=3.0, step=0.1, optional=True, advanced=True, tooltip="Light 2: Intensity (Colored Light / Both)")), # --- Light 3 Settings (Optional) --- io.Float.Input("light3_position_x", **_adv(default=0.3, min=0.0, max=1.0, step=0.01, optional=True, advanced=True, tooltip="Light 3: Horizontal position")), @@ -244,12 +314,12 @@ class ReLight(io.ComfyNode): io.Int.Input("light3_color_r", **_adv(default=255, min=0, max=255, step=1, optional=True, advanced=True, tooltip="Light 3: Red color")), io.Int.Input("light3_color_g", **_adv(default=150, min=0, max=255, step=1, optional=True, advanced=True, tooltip="Light 3: Green color")), io.Int.Input("light3_color_b", **_adv(default=120, min=0, max=255, step=1, optional=True, advanced=True, tooltip="Light 3: Blue color")), - io.Float.Input("light3_intensity", **_adv(default=0.5, min=0.0, max=3.0, step=0.1, optional=True, advanced=True, tooltip="Light 3: Intensity (Colored mode)")), + io.Float.Input("light3_intensity", **_adv(default=0.5, min=0.0, max=3.0, step=0.1, optional=True, advanced=True, tooltip="Light 3: Intensity (Colored Light / Both)")), ], outputs=[ io.Image.Output("image", display_name="image", tooltip="The relit image"), io.Mask.Output("mask", display_name="mask", tooltip="Pass-through of the input mask, normalised to (batch, height, width) and resized to the image (black if none connected)"), - io.Image.Output("debug_image", display_name="debug_image", tooltip="Visualization of light positions and masks (enable 'show_debug_info')"), + io.Image.Output("debug_image", display_name="debug_image", tooltip="Visualization of light positions and masks. Drawn whenever this output is connected to something - there is no toggle to remember"), ] ) @@ -465,6 +535,74 @@ class ReLight(io.ComfyNode): result = image + color_light * intensity * mask return torch.clamp(result, 0.0, 1.0) + @classmethod + def cast_shadow_mask(cls, fg_mask, light_position_x, light_position_y, shadow_length, steps=24): + """How much of the subject stands between each pixel and the light. + + A light behind a subject should leave a shadow across the background, + and up to v3.1.2 it left none at all: the subject blocked nothing, so + the near side of a head was lit exactly as brightly as the far side. + + For every pixel, march back toward the light and take the largest + foreground value found along the way, out to `shadow_length` of the + frame's shorter side. A pixel whose path to the light crosses the + subject comes back 1; one with a clear line of sight comes back 0. + Marching stops at the light itself, so a pixel closer to the light than + the subject is never shadowed by it. + + Vectorised through `grid_sample` rather than a per-pixel loop: + deterministic, identical on CPU and GPU, and no SciPy. Returns a + (batch, height, width) mask on the input's device. + """ + batch, height, width = fg_mask.shape + device = fg_mask.device + if shadow_length <= 0 or steps < 1: + return torch.zeros_like(fg_mask) + + # Trace small and upsample; the caller blurs the result anyway. + scale = min(1.0, _SHADOW_TRACE_MAX / max(height, width)) + trace_h = max(8, int(round(height * scale))) + trace_w = max(8, int(round(width * scale))) + source = fg_mask.unsqueeze(1) + if (trace_h, trace_w) != (height, width): + source = F.interpolate(source, size=(trace_h, trace_w), mode="bilinear", align_corners=False) + + rows = torch.arange(trace_h, device=device, dtype=torch.float32).view(trace_h, 1) + cols = torch.arange(trace_w, device=device, dtype=torch.float32).view(1, trace_w) + light_x = light_position_x * trace_w + light_y = light_position_y * trace_h + to_light_x = light_x - cols + to_light_y = light_y - rows + distance = torch.sqrt(to_light_x * to_light_x + to_light_y * to_light_y).clamp(min=1e-6) + step_x = to_light_x / distance + step_y = to_light_y / distance + reach = shadow_length * min(trace_w, trace_h) + + shadow = torch.zeros((batch, trace_h, trace_w), device=device, dtype=torch.float32) + denom_x = max(trace_w - 1, 1) + denom_y = max(trace_h - 1, 1) + for step in range(1, steps + 1): + travelled = torch.clamp(distance, max=reach * step / steps) + sample_x = cols + step_x * travelled + sample_y = rows + step_y * travelled + grid = torch.stack( + [ + (sample_x / denom_x * 2.0 - 1.0).expand(trace_h, trace_w), + (sample_y / denom_y * 2.0 - 1.0).expand(trace_h, trace_w), + ], + dim=-1, + ).unsqueeze(0).expand(batch, -1, -1, -1) + sampled = F.grid_sample( + source, grid, mode="bilinear", padding_mode="zeros", align_corners=True + ) + shadow = torch.maximum(shadow, sampled.squeeze(1)) + + if (trace_h, trace_w) != (height, width): + shadow = F.interpolate( + shadow.unsqueeze(1), size=(height, width), mode="bilinear", align_corners=False + ).squeeze(1) + return shadow.clamp(0.0, 1.0) + @classmethod def calculate_rim_mask(cls, light_mask_np, fg_mask_np, light_position_x, light_position_y): """ @@ -602,27 +740,35 @@ class ReLight(io.ComfyNode): # --- Draw Indicators & Legend --- draw_debug = ImageDraw.Draw(debug_img) - font = None - font_size = 10 - try: - font = ImageFont.load_default(size=12) - font_size = 12 - except Exception: - try: - font = ImageFont.load_default() - except Exception as font_err: - logger.debug(f" Could not load default font: {font_err}") + # Same scaling rule as the placeholder: at 1344x768 the old fixed + # 12px legend and 5px dots were unreadable in a preview thumbnail. + font_size = _debug_font_size(height) + font = _load_debug_font(font_size) + marker_radius = max(4, font_size // 2) + ring_width = max(1, font_size // 8) for i, light in enumerate(light_sources): try: x, y = int(light["position_x"] * width), int(light["position_y"] * height) color = tuple(light.get("color", [255, 255, 255])) + (220,) inner_r_px = int(light["inner_radius"] * min(width, height)) outer_r_px = int(light["outer_radius"] * min(width, height)) - draw_debug.ellipse((x-5, y-5, x+5, y+5), fill=color, outline=(0, 0, 0, 200)) - draw_debug.ellipse((x-inner_r_px, y-inner_r_px, x+inner_r_px, y+inner_r_px), outline=(255, 255, 0, 150), width=1) - draw_debug.ellipse((x-outer_r_px, y-outer_r_px, x+outer_r_px, y+outer_r_px), outline=(0, 255, 255, 150), width=1) + draw_debug.ellipse((x-marker_radius, y-marker_radius, x+marker_radius, y+marker_radius), fill=color, outline=(0, 0, 0, 200), width=ring_width) + draw_debug.ellipse((x-inner_r_px, y-inner_r_px, x+inner_r_px, y+inner_r_px), outline=(255, 255, 0, 150), width=ring_width) + draw_debug.ellipse((x-outer_r_px, y-outer_r_px, x+outer_r_px, y+outer_r_px), outline=(0, 255, 255, 150), width=ring_width) label = f"L{i+1}" - text_pos = (x + 10, y - font_size // 2 - 2) + # The label sits to the right of the marker, but a light near + # the right edge (a rim light at x=0.9, say) would push it off + # the frame, so it flips to the left side instead. Both axes + # are then clamped, because a marker in a corner can still run + # a tall glyph past the top or bottom. + gap = marker_radius + ring_width * 2 + label_w = draw_debug.textlength(label, font=font) if font else font_size + text_x = x + gap + if text_x + label_w > width: + text_x = x - gap - label_w + text_x = max(0, min(text_x, width - label_w)) + text_y = max(0, min(y - font_size // 2 - 2, height - font_size)) + text_pos = (text_x, text_y) if font: bbox = draw_debug.textbbox(text_pos, label, font=font) draw_debug.rectangle(bbox, fill=(0, 0, 0, 180)) @@ -635,20 +781,27 @@ class ReLight(io.ComfyNode): legend_items = [("Inner Mask Area", (255, 0, 0, 128)), ("Outer Mask Area (Ring)", (0, 0, 255, 128))] if fg_mask_tensor is not None: legend_items.append(("Foreground Mask", (0, 255, 0, 100))) - legend_x, legend_y = 10, 10 - line_height = font_size + 6 + pad = max(5, font_size // 2) + swatch = font_size + legend_x = legend_y = pad * 2 + line_height = int(font_size * 1.5) max_width = 0 for text, _ in legend_items: try: - text_w = draw_debug.textlength(text, font=font) if font else len(text) * 7 + text_w = draw_debug.textlength(text, font=font) if font else len(text) * font_size * 0.55 except Exception: - text_w = len(text) * 7 + text_w = len(text) * font_size * 0.55 max_width = max(max_width, text_w) - legend_box = (legend_x - 5, legend_y - 5, legend_x + max_width + 25, legend_y + len(legend_items) * line_height) + legend_box = ( + legend_x - pad, + legend_y - pad, + legend_x + swatch + pad + max_width + pad, + legend_y + len(legend_items) * line_height, + ) draw_debug.rectangle(legend_box, fill=(0, 0, 0, 190)) for text, color in legend_items: - draw_debug.rectangle((legend_x, legend_y, legend_x + 12, legend_y + 12), fill=color) - draw_debug.text((legend_x + 18, legend_y + 1), text, fill=(255, 255, 255, 220), font=font) + draw_debug.rectangle((legend_x, legend_y, legend_x + swatch, legend_y + swatch), fill=color) + draw_debug.text((legend_x + swatch + pad, legend_y), text, fill=(255, 255, 255, 220), font=font) legend_y += line_height except Exception: logger.exception(" ERROR drawing legend") @@ -669,9 +822,15 @@ class ReLight(io.ComfyNode): A solid black frame is indistinguishable from a crash: users wire the debug output to a preview, see black, and reasonably conclude the node - failed. So the placeholder states why it is empty and names the widget - that fills it. Falls back to plain black only if the text cannot be - drawn (no font, or an image too small to hold a line of type). + failed. So the placeholder states why it is empty and how to fill it. + + Everything here scales with the frame. v3.1.2 drew 13px type on a + full-resolution canvas, which is legible at 96x64 and invisible at 1344 + wide, so the placeholder was reported as the very black frame it was + written to replace. The inset border is the other half of that: it means + even an unreadable thumbnail is visibly a deliberate panel rather than a + dead output. Falls back to plain black only if the text cannot be drawn + (no font, or an image too small to hold a line of type). """ height, width = image.shape[1], image.shape[2] blank = torch.zeros((1, height, width, 3), device=image.device, dtype=torch.float32) @@ -680,19 +839,32 @@ class ReLight(io.ComfyNode): try: canvas = Image.new("RGB", (width, height), (24, 24, 28)) draw = ImageDraw.Draw(canvas) - try: - font = ImageFont.load_default(size=13) - except Exception: - font = ImageFont.load_default() - lines = _wrap_debug_text(draw, reason, font, width - 24) - line_height = 17 - y = max(8, (height - line_height * len(lines)) // 2) + font_size = _debug_font_size(height) + font = _load_debug_font(font_size) + + border = max(2, font_size // 6) + inset = max(4, font_size // 2) + draw.rectangle( + (inset, inset, width - 1 - inset, height - 1 - inset), + outline=(96, 96, 112), + width=border, + ) + + margin = inset + border + font_size + lines = _wrap_debug_text(draw, reason, font, max(font_size, width - 2 * margin)) + line_height = int(font_size * 1.4) + y = max(margin, (height - line_height * len(lines)) // 2) for line in lines: try: text_width = draw.textlength(line, font=font) except Exception: - text_width = len(line) * 7 - draw.text((max(8, (width - text_width) // 2), y), line, fill=(190, 190, 200), font=font) + text_width = len(line) * font_size * 0.55 + draw.text( + (max(margin, int((width - text_width) // 2)), y), + line, + fill=(198, 198, 210), + font=font, + ) y += line_height placeholder = np.array(canvas).astype(np.float32) / 255.0 return torch.from_numpy(placeholder).unsqueeze(0).to(image.device) @@ -700,6 +872,46 @@ class ReLight(io.ComfyNode): logger.debug("Could not render the debug placeholder text; using a black frame.") return blank + #: Output slot the debug view is on. A downstream node consuming it appears + #: in the submitted prompt as the pair ``[, 2]``. + DEBUG_OUTPUT_SLOT = 2 + + @classmethod + def _debug_output_is_consumed(cls): + """Does the submitted prompt wire anything to this node's debug output? + + The node's own `debug_output_connected` widget is what makes the UI feel + immediate (see web/relight_debug.js - a widget value is what ComfyUI + hashes to decide a node must re-run). This is the independent check for + everything that never loads that file: an API caller posting to /prompt, + or a UI where the frontend extension failed to load. Either signal is + enough; neither is required. + + Deliberately total: hidden inputs are absent outside a running ComfyUI + and empty in some internal calls, so every failure here means "no", not + an exception on a path the user never asked about. + """ + hidden = getattr(cls, "hidden", None) + prompt = getattr(hidden, "prompt", None) + unique_id = getattr(hidden, "unique_id", None) + if not prompt or unique_id is None: + return False + wanted = str(unique_id) + try: + for node in prompt.values(): + for value in (node.get("inputs") or {}).values(): + if ( + isinstance(value, (list, tuple)) + and len(value) == 2 + and str(value[0]) == wanted + and value[1] == cls.DEBUG_OUTPUT_SLOT + ): + return True + except (AttributeError, TypeError): + # A prompt shape we do not recognise is not worth crashing a render. + logger.debug("ReLight: could not read the prompt to check debug connectivity.") + return False + # --- Mask preparation --- @classmethod @@ -780,19 +992,28 @@ class ReLight(io.ComfyNode): preset = params.get('preset', 'None') remove_background = params.get('remove_background', False) - apply_3d_lighting = params.get('apply_3d_lighting', True) - light_direction = params.get('light_direction', 'No Occlusion') + subject_interaction = params.get('subject_interaction', cls.SUBJECT_NONE) + lighting_mode = params.get('lighting_mode', cls.MODE_CORRECTION) + mask_shape = params.get('mask_shape', cls.SHAPE_RADIAL) + debug_output_connected = bool(params.get('debug_output_connected', False)) or cls._debug_output_is_consumed() effect_strength = params.get('effect_strength', 1.0) rim_amplification = params.get('rim_amplification', 2.0) + shadow_strength = params.get('shadow_strength', 0.6) + shadow_length = params.get('shadow_length', 0.35) num_light_sources = params.get('num_light_sources', 1) - use_colored_lights = params.get('use_colored_lights', False) - use_gradient_mode = params.get('use_gradient_mode', False) mask_blur = params.get('mask_blur', 50.0) - show_debug_info = params.get('show_debug_info', False) input_mask = params.get('mask', None) - logger.debug(f"Mode: Preset='{preset}', 3D Lighting={apply_3d_lighting}, Direction='{light_direction}', Colored={use_colored_lights}, Gradient={use_gradient_mode}") - logger.debug(f"Settings: Strength={effect_strength:.2f}, Rim Amp={rim_amplification:.2f}, Mask Blur={mask_blur:.1f}, Debug={show_debug_info}") + # The three lighting modes are two independent switches underneath: a + # coloured additive pass and a two-zone grade. 'Both' runs the colour + # first and grades the result, which is what the three presets that set + # a colour *and* a full correction block always looked like they meant. + apply_colored = lighting_mode in (cls.MODE_COLORED, cls.MODE_BOTH) + apply_correction = lighting_mode in (cls.MODE_CORRECTION, cls.MODE_BOTH) + use_gradient_mode = mask_shape == cls.SHAPE_GRADIENT + + logger.debug(f"Mode: Preset='{preset}', Lighting='{lighting_mode}', Shape='{mask_shape}', Subject='{subject_interaction}'") + logger.debug(f"Settings: Strength={effect_strength:.2f}, Rim Amp={rim_amplification:.2f}, Mask Blur={mask_blur:.1f}, Debug={debug_output_connected}") # --- Mask Handling --- fg_mask = None @@ -804,7 +1025,7 @@ class ReLight(io.ComfyNode): else: output_mask = torch.zeros((batch_size, height, width), device=device, dtype=torch.float32) - occlusion_requested = apply_3d_lighting and light_direction != "No Occlusion" + occlusion_requested = subject_interaction != cls.SUBJECT_NONE if fg_mask is None and (occlusion_requested or remove_background): logger.info("ReLight: no mask connected - occlusion and background compositing are disabled for this run.") occlusion_active = occlusion_requested and fg_mask is not None @@ -860,8 +1081,10 @@ class ReLight(io.ComfyNode): # own mask). Both broadcast against the (B, H, W, C) image. inner_zone_mask = None ring_zone_mask = None + colored_light_mask = None + correction_mask = None - if occlusion_active and light_direction == "Behind Subject": + if occlusion_active and subject_interaction == cls.SUBJECT_RIM: logger.debug(" Processing as Behind Subject (Rim + Background Glow)...") light_mask_np = outer_mask_base.cpu().numpy() @@ -873,12 +1096,36 @@ class ReLight(io.ComfyNode): rim_per_image.append(np.clip(raw_rim_mask_np * rim_amplification, 0.0, 1.0)) amplified_rim = torch.from_numpy(np.stack(rim_per_image)).to(device) - background_light_mask = outer_mask_base.unsqueeze(0) * (1.0 - fg_mask) - combined_mask_unblurred = torch.clamp(amplified_rim + background_light_mask, 0, 1) - final_light_mask = cls.apply_mask_blur(combined_mask_unblurred, mask_blur) + # The background glow gets real falloff. Up to v3.1.2 it was the + # hard 0/1 outer disc, so "glow" was a flat slab with whatever + # softness mask_blur happened to put on its rim. + if use_gradient_mode: + background_base = outer_mask_base + else: + background_base = cls.create_falloff_mask( + width, height, light["position_x"], light["position_y"], + light["inner_radius"], light["outer_radius"], + ).to(device) + + background_light_mask = background_base.unsqueeze(0).expand(batch_size, -1, -1) + if shadow_strength > 0.0 and shadow_length > 0.0: + shadow = cls.cast_shadow_mask( + fg_mask, light["position_x"], light["position_y"], shadow_length + ) + background_light_mask = background_light_mask * (1.0 - shadow * shadow_strength) + + # Blur the two halves SEPARATELY, and re-apply the silhouette to + # the blurred background. v3.1.2 subtracted the subject before + # the blur, so the blur smeared background light straight back + # across the silhouette edge onto the face - the single most + # direct cause of "Behind Subject does not occlude". + blurred_rim = cls.apply_mask_blur(amplified_rim, mask_blur) + blurred_background = cls.apply_mask_blur(background_light_mask, mask_blur) * (1.0 - fg_mask) + final_light_mask = torch.clamp(blurred_rim + blurred_background, 0, 1) + colored_light_mask = correction_mask = final_light_mask logger.debug(f" Final Behind Subject Mask: Max={torch.max(final_light_mask):.3f}") - elif occlusion_active and light_direction == "In Front of Subject": + elif occlusion_active and subject_interaction == cls.SUBJECT_FRONT: logger.debug(" Processing as Front Subject Light...") light_mask_np = outer_mask_base.cpu().numpy() fg_mask_np = fg_mask.cpu().numpy() @@ -892,11 +1139,12 @@ class ReLight(io.ComfyNode): np.clip(light_mask_np[None, ...] * occlusion_factor_mask_np, 0, 1).astype(np.float32) ).to(device) final_light_mask = cls.apply_mask_blur(combined_mask_unblurred, mask_blur) + colored_light_mask = correction_mask = final_light_mask logger.debug(f" Final Front Subject Mask: Max={torch.max(final_light_mask):.3f}") - else: # Standard (No Occlusion) or occlusion unavailable + else: # Standard (no subject interaction, or no mask to do it with) logger.debug(" Processing as Standard Light...") - if use_colored_lights: + if apply_colored: # Light area with real falloff: full strength inside the inner # radius, fading to zero at the outer radius. Gradient masks # already encode their own falloff. @@ -907,8 +1155,8 @@ class ReLight(io.ComfyNode): width, height, light["position_x"], light["position_y"], light["inner_radius"], light["outer_radius"], ).to(device) - final_light_mask = cls.apply_mask_blur(combined_mask_unblurred, mask_blur).unsqueeze(0) - else: + colored_light_mask = cls.apply_mask_blur(combined_mask_unblurred, mask_blur).unsqueeze(0) + if apply_correction: # Two-zone color correction: inner_* params apply inside the # inner mask, outer_* params in the ring between inner and outer. # Both zones blur in one convolution - this is the hot path. @@ -917,20 +1165,17 @@ class ReLight(io.ComfyNode): ) inner_zone_mask, outer_zone_full = zones[0:1], zones[1:2] ring_zone_mask = torch.clamp(outer_zone_full - inner_zone_mask, 0, 1) - final_light_mask = torch.clamp(inner_zone_mask + ring_zone_mask, 0, 1) - logger.debug(f" Final Standard Mask: Max={torch.max(final_light_mask):.3f}") + correction_mask = torch.clamp(inner_zone_mask + ring_zone_mask, 0, 1) - # --- Apply Lighting Effect using the single final_light_mask --- - if torch.max(final_light_mask) <= 1e-4: - logger.debug(" Skipping light application (final mask is near-empty).") - continue - - final_mask_expanded = final_light_mask.unsqueeze(-1) - if use_colored_lights: + # --- Apply the lighting effect(s) this mode calls for --- + # In 'Both' the colour goes on first and the grade is applied to the + # result, so the grade sees the lit image rather than the original. + if apply_colored and colored_light_mask is not None and torch.max(colored_light_mask) > 1e-4: logger.debug(f" Applying colored light (RGB: {light['color']}, Intensity: {light['intensity']:.2f})...") effective_intensity = light['intensity'] * effect_strength - result_tensor = cls.apply_colored_light(result_tensor, final_mask_expanded, light['color'], effective_intensity) - else: + result_tensor = cls.apply_colored_light(result_tensor, colored_light_mask.unsqueeze(-1), light['color'], effective_intensity) + + if apply_correction and correction_mask is not None and torch.max(correction_mask) > 1e-4: logger.debug(" Applying color correction...") corrected_inner = cls.apply_color_correction( result_tensor, @@ -968,27 +1213,30 @@ class ReLight(io.ComfyNode): else: # Behind/Front modes: single combined mask with inner params # (documented simplification). - result_tensor = result_tensor + (corrected_inner - result_tensor) * final_mask_expanded + result_tensor = result_tensor + (corrected_inner - result_tensor) * correction_mask.unsqueeze(-1) # --- Final Steps --- final_result = result_tensor if composite_active: - # Gate on whether occlusion actually ran, not on light_direction alone: - # with apply_3d_lighting off the lighting is plain, so it still needs - # compositing even if light_direction is left on an occlusion mode. + # Gate on whether occlusion actually ran, not on the widget alone: + # with no mask connected the lighting is plain, so it still needs + # compositing even if subject_interaction asks for occlusion. if not occlusion_active: logger.debug("Compositing lit foreground onto original background...") final_result = image + (result_tensor - image) * fg_mask.unsqueeze(-1) else: - logger.debug(f"Skipping final compositing for '{light_direction}' mode (lighting applied to FG/BG directly).") + logger.debug(f"Skipping final compositing for '{subject_interaction}' (lighting applied to FG/BG directly).") else: logger.debug("Skipping final compositing (remove_background=False or no mask).") - # Debug Image Generation - if not show_debug_info: + # Debug Image Generation. There is no toggle: the debug view is drawn + # whenever something is consuming this output, and skipped otherwise. + if not debug_output_connected: debug_image = cls._blank_debug_image( - image, "ReLight debug view is off. Turn on 'show_debug_info' to see light positions and mask zones." + image, + "Nothing is connected to ReLight's debug_image output, so there was nothing to draw. " + "Wire it to a preview to see light positions and mask zones.", ) elif all_inner_base_masks_for_debug and all_outer_base_masks_for_debug: debug_image = cls.create_debug_image( diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..6c27fa7 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,12 @@ +# Development / test dependencies. ReLight itself declares no runtime +# dependencies: numpy, Pillow, scipy and torch all ship with ComfyUI core +# (see its own requirements.txt), at floors at or above anything this node +# needs. Nothing here is installed by ComfyUI Manager or the Registry. +numpy +Pillow +scipy +pytest +# Pinned to match .github/workflows/test.yml, and the rule set is pinned in +# pyproject.toml: ruff changes its default selection between releases, so an +# unpinned local ruff gives a false red/green against what CI runs. +ruff==0.16.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index fac0b9f..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -numpy>=1.19.0 -Pillow>=9.0.0 -scipy>=1.7.0 diff --git a/scripts/dump_frontend_fixture.py b/scripts/dump_frontend_fixture.py new file mode 100644 index 0000000..cd51321 --- /dev/null +++ b/scripts/dump_frontend_fixture.py @@ -0,0 +1,112 @@ +"""Generate the two files the frontend derives from the live node schema. + +Both would otherwise be hand-maintained copies of what ``relight.py`` already +says, and a silent drift in either is a frontend that lies about the node: + +* ``tests/frontend/fixtures/schema.json`` - widget names, order and defaults, + which the JS tests build their fake nodes from. +* ``web/relight_presets.js`` - which widgets each preset overrides, so + ``relight_ui.js`` can grey exactly those and no others. + +``--check`` fails if either committed file is stale, and +``tests/test_relight.py`` runs that check. + + python scripts/dump_frontend_fixture.py # rewrite both + python scripts/dump_frontend_fixture.py --check # fail if either is stale +""" +import argparse +import json +import pathlib +import sys + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +FIXTURE = REPO_ROOT / "tests" / "frontend" / "fixtures" / "schema.json" +PRESETS_MODULE = REPO_ROOT / "web" / "relight_presets.js" + +# The stub must win over any real ComfyUI on the path, exactly as in conftest. +sys.path.insert(0, str(REPO_ROOT / "tests" / "stubs")) +sys.path.insert(1, str(REPO_ROOT)) + +from relight import ReLight # noqa: E402 + + +def build(): + schema = ReLight.define_schema() + widgets = [] + for spec in schema.inputs: + if spec.id in ("image", "mask"): + continue # link inputs, never widgets + entry = {"name": spec.id, "default": spec.default} + options = spec.kwargs.get("options") + if options is not None: + entry["options"] = list(options) + widgets.append(entry) + return {"node_class": schema.node_id, "widgets": widgets} + + +def build_presets_module(): + """`web/relight_presets.js`: which widgets each preset takes control of.""" + keys = {name: sorted(preset) for name, preset in ReLight.PRESETS.items()} + values = { + name: {key: preset[key] for key in sorted(preset)} + for name, preset in ReLight.PRESETS.items() + } + keys_body = json.dumps(keys, indent=4, sort_keys=False) + values_body = json.dumps(values, indent=4, sort_keys=False) + return ( + "/*\n" + " * GENERATED by scripts/dump_frontend_fixture.py - do not edit.\n" + " *\n" + " * Which widgets each preset overrides, and what it sets them to.\n" + " * relight_ui.js greys exactly these while a preset is selected, because a\n" + " * control a preset has taken over is one whose value on the node is being\n" + " * ignored - and a widget you can still drag that changes nothing is worse\n" + " * than one that looks unavailable.\n" + " *\n" + " * The values are here because a greyed widget cannot show its own: the\n" + " * ComfyUI frontend blanks `_displayValue` for any disabled widget. Without\n" + " * them a greyed row is an empty bar. relight_ui.js writes the preset's\n" + " * value into the widget's label instead, which still paints.\n" + " *\n" + " * Derived from ReLight.PRESETS so it cannot drift; the --check run in\n" + " * tests/test_relight.py fails if this file is stale.\n" + " */\n\n" + f"export const PRESET_KEYS = {keys_body};\n\n" + "/** What each preset sets those widgets to, for the greyed-out labels. */\n" + f"export const PRESET_VALUES = {values_body};\n\n" + "/** Widgets a preset may name but that `preserve_positioning` hands back. */\n" + f"export const GEOMETRY_KEYS = {json.dumps(sorted(ReLight.GEOMETRY_KEYS), indent=4)};\n\n" + "/** The widget a preset scales rather than replaces. */\n" + f"export const STRENGTH_KEY = {json.dumps(ReLight.STRENGTH_KEY)};\n" + ) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="fail if a generated file is stale") + args = parser.parse_args(argv) + + generated = { + FIXTURE: json.dumps(build(), indent=2) + "\n", + PRESETS_MODULE: build_presets_module(), + } + + if args.check: + stale = [ + path + for path, payload in generated.items() + if not path.exists() or path.read_text(encoding="utf-8") != payload + ] + for path in stale: + print(f"{path.relative_to(REPO_ROOT).as_posix()} is stale; re-run this script") + return 1 if stale else 0 + + for path, payload in generated.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(payload, encoding="utf-8") + print(f"wrote {path.relative_to(REPO_ROOT).as_posix()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index 4703908..17e68fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,6 +52,38 @@ def node(): return _lock(_shallow_clone(ReLight)) +class _HiddenHolder: + """Stand-in for ``comfy_api``'s HiddenHolder. + + The real one returns None for anything it was not given, which the node + relies on, so this does too. + """ + + def __init__(self, prompt=None, unique_id=None): + self.prompt = prompt + self.unique_id = unique_id + + def __getattr__(self, name): + return None + + +@pytest.fixture +def node_with_prompt(): + """A locked clone carrying hidden inputs, the way ComfyUI builds one. + + ``PREPARE_CLASS_CLONE`` sets ``hidden`` on the clone *before* the class is + locked; a test cannot assign it afterwards, because the lock rejects every + class-attribute write (that is the whole point of the fixture above). + """ + + def _make(prompt=None, unique_id=None): + clone = _shallow_clone(ReLight) + clone.hidden = _HiddenHolder(prompt, unique_id) + return _lock(clone) + + return _make + + @pytest.fixture def defaults(): """Every widget input at its schema default, keyed by input id. diff --git a/tests/fixtures/legacy_v3.1.2_workflow.json b/tests/fixtures/legacy_v3.1.2_workflow.json new file mode 100644 index 0000000..0f656e6 --- /dev/null +++ b/tests/fixtures/legacy_v3.1.2_workflow.json @@ -0,0 +1,383 @@ +{ + "id": "deca62b2-6f4f-4720-bdc5-7d23c2f67b5b", + "revision": 0, + "last_node_id": 41, + "last_link_id": 149, + "nodes": [ + { + "id": 2, + "type": "LoadImage", + "pos": [ + -239.72842407226562, + -95.34012603759766 + ], + "size": [ + 315, + 314 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 32, + 144 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "flux1DevFp8_v10_Ossum_Possum_679077792491536.png", + "image", + "" + ] + }, + { + "id": 14, + "type": "MaskPreview+", + "pos": [ + 568.0902099609375, + 204.9829864501953 + ], + "size": [ + 352.767822265625, + 300.72760009765625 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "mask", + "type": "MASK", + "link": 147 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "MaskPreview+" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 10, + "type": "PreviewImage", + "pos": [ + 582.1401977539062, + 563.9623413085938 + ], + "size": [ + 329.576171875, + 338.90484619140625 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 148 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 41, + "type": "SaveImage", + "pos": [ + 595.3690795898438, + -111.95968627929688 + ], + "size": [ + 315, + 270 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 149 + } + ], + "outputs": [], + "properties": {}, + "widgets_values": [ + "Relight", + "" + ] + }, + { + "id": 16, + "type": "ImageRemoveBackground+", + "pos": [ + -267.90655517578125, + 340.4074401855469 + ], + "size": [ + 327.5999755859375, + 46 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "rembg_session", + "type": "REMBG_SESSION", + "link": 31 + }, + { + "name": "image", + "type": "IMAGE", + "link": 32 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [] + }, + { + "name": "MASK", + "type": "MASK", + "links": [ + 145 + ] + } + ], + "properties": { + "Node name for S&R": "ImageRemoveBackground+" + }, + "widgets_values": [] + }, + { + "id": 8, + "type": "RemBGSession+", + "pos": [ + -607.244140625, + 341.5400695800781 + ], + "size": [ + 315, + 82 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "REMBG_SESSION", + "type": "REMBG_SESSION", + "links": [ + 31 + ] + } + ], + "properties": { + "Node name for S&R": "RemBGSession+" + }, + "widgets_values": [ + "isnet-general-use: general purpose", + "CPU" + ] + }, + { + "id": 40, + "type": "ReLight", + "pos": [ + 143.11851501464844, + -21.831321716308594 + ], + "size": [ + 317.4000244140625, + 1226 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 144 + }, + { + "name": "mask", + "type": "MASK", + "link": 145 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 149 + ] + }, + { + "name": "mask", + "type": "MASK", + "links": [ + 147 + ] + }, + { + "name": "debug_image", + "type": "IMAGE", + "links": [ + 148 + ] + } + ], + "properties": { + "Node name for S&R": "ReLight" + }, + "widgets_values": [ + "Warm Sunset Glow", + 1, + true, + true, + false, + false, + true, + "Behind Subject", + true, + 1, + 50, + 2, + 0.26000000000000006, + 0.31000000000000005, + 0.4, + 0.7, + 255, + 255, + 255, + 1, + 10, + 5, + 5, + 0, + 0, + 1, + -10, + 0, + -10, + 0, + 0, + 1.1, + 0.8, + 0.2, + 0.3, + 0.6, + 180, + 180, + 255, + 0.7, + 0.3, + 0.8, + 0.25, + 0.5, + 255, + 150, + 120, + 0.5 + ] + } + ], + "links": [ + [ + 31, + 8, + 0, + 16, + 0, + "REMBG_SESSION" + ], + [ + 32, + 2, + 0, + 16, + 1, + "IMAGE" + ], + [ + 144, + 2, + 0, + 40, + 0, + "IMAGE" + ], + [ + 145, + 16, + 1, + 40, + 1, + "MASK" + ], + [ + 147, + 40, + 1, + 14, + 0, + "MASK" + ], + [ + 148, + 40, + 2, + 10, + 0, + "IMAGE" + ], + [ + 149, + 40, + 0, + 41, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.7627768444385813, + "offset": [ + 1799.079848930125, + 266.1060250604858 + ] + } + }, + "version": 0.4 +} \ No newline at end of file diff --git a/tests/frontend/debug.test.mjs b/tests/frontend/debug.test.mjs new file mode 100644 index 0000000..b852d0b --- /dev/null +++ b/tests/frontend/debug.test.mjs @@ -0,0 +1,138 @@ +/* + * The debug view has no toggle: connecting the output is the whole gesture. + * + * These pin the plumbing that makes that true - `debug_output_connected` must + * track the wiring, must stay hidden, and must not dirty the graph when nothing + * changed. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { syncDebugFlag } from "../../web/relight_debug.js"; +import { app, getExtension } from "./stubs/app.js"; +import { makeNode, schema } from "./fake_node.mjs"; + +const FLAG = "debug_output_connected"; + +/** A node with the three real outputs, debug_image last. */ +function nodeWithOutputs(debugLinks) { + const node = makeNode(); + node.outputs = [ + { name: "image", links: [] }, + { name: "mask", links: [] }, + { name: "debug_image", links: debugLinks }, + ]; + return node; +} + +function widget(node) { + return node.widgets.find((w) => w.name === FLAG); +} + +test("the schema still carries the flag the UI drives", () => { + assert.ok( + schema.widgets.some((w) => w.name === FLAG), + `${FLAG} is gone from the schema; relight_debug.js has nothing to write` + ); +}); + +test("a wired debug output sets the flag", () => { + const node = nodeWithOutputs([12]); + assert.equal(syncDebugFlag(node), true, "no change reported"); + assert.equal(widget(node).value, true); +}); + +test("an unwired debug output clears the flag", () => { + const node = nodeWithOutputs([]); + widget(node).value = true; + assert.equal(syncDebugFlag(node), true); + assert.equal(widget(node).value, false); +}); + +test("syncing an unchanged node does not report a change", () => { + // The draw loop and every connection event call this; an unconditional + // write would mark the workflow modified continuously. + const node = nodeWithOutputs([]); + assert.equal(syncDebugFlag(node), false); + assert.equal(syncDebugFlag(node), false); +}); + +test("a node with no outputs yet is not an error", () => { + const node = makeNode(); + delete node.outputs; + assert.doesNotThrow(() => syncDebugFlag(node)); +}); + +test("nodeCreated hides the flag widget and tracks connections", async () => { + const extension = getExtension("ReLight.debugView"); + assert.ok(extension, "the debug view extension did not register"); + + const node = nodeWithOutputs([]); + await extension.nodeCreated(node, app); + + const flag = widget(node); + // Both halves: older LiteGraph skips unknown widget types, newer frontends + // honour `hidden`. One without the other leaves it painted somewhere. + assert.equal(flag.hidden, true, "flag widget is not hidden"); + assert.ok(flag.type.startsWith("hidden-"), "flag widget type was not swapped"); + assert.deepEqual(flag.computeSize(), [0, -4], "flag widget still reserves a row"); + + node.outputs[2].links = [3]; + node.onConnectionsChange(); + assert.equal(flag.value, true, "connecting the output did not set the flag"); + + node.outputs[2].links = []; + node.onConnectionsChange(); + assert.equal(flag.value, false, "disconnecting the output did not clear the flag"); +}); + +test("setting a node up reads the wiring once the graph has settled", async () => { + // Connections are restored after nodeCreated when a workflow loads, so the + // flag has to be read again on the next frame. requestAnimationFrame is a + // browser global and absent here - a bare call throws and takes the rest of + // nodeCreated with it, which is what this pins. + const extension = getExtension("ReLight.debugView"); + const node = nodeWithOutputs([]); + await extension.nodeCreated(node, app); + node.outputs[2].links = [5]; + + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(widget(node).value, true, "the deferred connection read did not run"); +}); + +test("an upstream onConnectionsChange handler still runs", async () => { + const extension = getExtension("ReLight.debugView"); + const node = nodeWithOutputs([]); + let called = 0; + node.onConnectionsChange = () => { + called += 1; + return "upstream"; + }; + await extension.nodeCreated(node, app); + + assert.equal(node.onConnectionsChange(), "upstream", "upstream return value lost"); + assert.equal(called, 1, "upstream handler was not called exactly once"); +}); + +test("a throwing upstream handler does not stop the flag from syncing", async () => { + const extension = getExtension("ReLight.debugView"); + const node = nodeWithOutputs([7]); + node.onConnectionsChange = () => { + throw new Error("some other pack"); + }; + await extension.nodeCreated(node, app); + + assert.doesNotThrow(() => node.onConnectionsChange()); + assert.equal(widget(node).value, true); +}); + +test("nodes from other packs are left alone", async () => { + const extension = getExtension("ReLight.debugView"); + const node = nodeWithOutputs([1]); + node.comfyClass = "SomeoneElsesNode"; + node.type = "SomeoneElsesNode"; + const before = node.onConnectionsChange; + await extension.nodeCreated(node, app); + assert.equal(node.onConnectionsChange, before); + assert.equal(widget(node).value, false); +}); diff --git a/tests/frontend/fake_node.mjs b/tests/frontend/fake_node.mjs new file mode 100644 index 0000000..d949a3c --- /dev/null +++ b/tests/frontend/fake_node.mjs @@ -0,0 +1,109 @@ +/* + * A LiteGraph-shaped fake node, built from the real schema fixture. + * + * Deliberately minimal and deliberately not helpful: it models only what the + * pack's own frontend code touches, and does not synthesise conveniences the + * real frontend does not provide (see the `serialize` note in + * `comfyui-identity-forge`'s harness). Being more generous than the real thing + * is how a harness hides the bug it was written to catch. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = path.resolve(HERE, "..", ".."); + +export const schema = JSON.parse( + fs.readFileSync(path.join(HERE, "fixtures", "schema.json"), "utf8") +); + +/** + * `nodeData` in the shape `beforeRegisterNodeDef` receives it. + * + * Verified against what this ComfyUI build actually serves at /object_info: a + * combo arrives as `["COMBO", {options: [...], default: ...}]`, NOT as the + * older `[[...options], {...}]`. A fake that emitted the old shape would let a + * `collectDefaults` that only understood the old shape pass here and fail live. + */ +export function makeNodeData() { + const required = {}; + for (const widget of schema.widgets) { + required[widget.name] = widget.options + ? ["COMBO", { default: widget.default, options: [...widget.options], multiselect: false }] + : [typeof widget.default === "boolean" ? "BOOLEAN" : "FLOAT", { default: widget.default }]; + } + return { name: schema.node_class, input: { required } }; +} + +/** LiteGraph's widget `type`, which the real frontend always sets. */ +function widgetType(widget) { + if (widget.options) return "combo"; + if (typeof widget.default === "boolean") return "toggle"; + return "number"; +} + +/** Row height LiteGraph reserves for one widget. */ +const WIDGET_ROW = 24; + +/** A node instance carrying one widget per schema entry, at its default. */ +export function makeNode(overrides = {}) { + const widgets = schema.widgets.map((widget) => ({ + name: widget.name, + type: widgetType(widget), + value: Object.prototype.hasOwnProperty.call(overrides, widget.name) + ? overrides[widget.name] + : widget.default, + options: widget.options ? { values: [...widget.options] } : {}, + callback: undefined, + })); + return { + comfyClass: schema.node_class, + type: schema.node_class, + widgets, + size: [317, 1250], + // LiteGraph sums each widget's own computeSize when it defines one and + // reserves a fixed row otherwise, which is what makes a hidden widget's + // zero-size stub shrink the node. A fake that just counted widgets + // would report a node that never changes height. + computeSize() { + const rows = this.widgets.reduce((total, widget) => { + if (typeof widget.computeSize === "function") return total + widget.computeSize()[1]; + return total + WIDGET_ROW; + }, 0); + return [210, 30 + Math.max(0, rows)]; + }, + setSize(size) { + this.size = size; + }, + widgetValue(name) { + return this.widgets.find((w) => w.name === name)?.value; + }, + }; +} + +/** + * Drive a `beforeRegisterNodeDef` extension the way ComfyUI does. + * + * The hook wraps methods on `nodeType.prototype`, so it needs a real class with + * a real prototype - a plain object will not do. + */ +export async function driveBeforeRegisterNodeDef(extension, nodeData) { + class FakeNodeType {} + await extension.beforeRegisterNodeDef(FakeNodeType, nodeData); + return FakeNodeType; +} + +/** Load a workflow JSON from the repo. */ +export function loadWorkflow(...segments) { + return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, ...segments), "utf8")); +} + +/** The single ReLight node in a workflow graph. */ +export function relightNodeOf(graph) { + const nodes = graph.nodes.filter((node) => node.type === "ReLight"); + if (nodes.length !== 1) { + throw new Error(`expected exactly one ReLight node, found ${nodes.length}`); + } + return nodes[0]; +} diff --git a/tests/frontend/fixtures/schema.json b/tests/frontend/fixtures/schema.json new file mode 100644 index 0000000..789c8a8 --- /dev/null +++ b/tests/frontend/fixtures/schema.json @@ -0,0 +1,226 @@ +{ + "node_class": "ReLight", + "widgets": [ + { + "name": "preset", + "default": "None", + "options": [ + "None", + "Soft Window Light", + "Dramatic Side Light", + "Warm Sunset Glow", + "Cool Blue Moonlight", + "Studio Key Light", + "Rim Light (Behind)", + "Spotlight", + "Negative Light (Darken)" + ] + }, + { + "name": "preserve_positioning", + "default": false + }, + { + "name": "lighting_mode", + "default": "Color Correction", + "options": [ + "Color Correction", + "Colored Light", + "Both" + ] + }, + { + "name": "mask_shape", + "default": "Radial falloff", + "options": [ + "Radial falloff", + "Directional gradient" + ] + }, + { + "name": "subject_interaction", + "default": "None", + "options": [ + "None", + "Light in front of subject", + "Light behind subject (rim)" + ] + }, + { + "name": "remove_background", + "default": false + }, + { + "name": "num_light_sources", + "default": 1 + }, + { + "name": "effect_strength", + "default": 1.0 + }, + { + "name": "mask_blur", + "default": 50.0 + }, + { + "name": "rim_amplification", + "default": 2.0 + }, + { + "name": "shadow_strength", + "default": 0.6 + }, + { + "name": "shadow_length", + "default": 0.35 + }, + { + "name": "debug_output_connected", + "default": false + }, + { + "name": "light_position_x", + "default": 0.5 + }, + { + "name": "light_position_y", + "default": 0.5 + }, + { + "name": "inner_circle_radius", + "default": 0.4 + }, + { + "name": "outer_circle_radius", + "default": 0.7 + }, + { + "name": "light_color_r", + "default": 255 + }, + { + "name": "light_color_g", + "default": 255 + }, + { + "name": "light_color_b", + "default": 255 + }, + { + "name": "light_intensity", + "default": 1.0 + }, + { + "name": "inner_brightness", + "default": 10.0 + }, + { + "name": "inner_contrast", + "default": 5.0 + }, + { + "name": "inner_saturation", + "default": 5.0 + }, + { + "name": "inner_temperature", + "default": 0.0 + }, + { + "name": "inner_tint", + "default": 0.0 + }, + { + "name": "inner_gamma", + "default": 1.0 + }, + { + "name": "outer_brightness", + "default": -10.0 + }, + { + "name": "outer_contrast", + "default": 0.0 + }, + { + "name": "outer_saturation", + "default": -10.0 + }, + { + "name": "outer_temperature", + "default": 0.0 + }, + { + "name": "outer_tint", + "default": 0.0 + }, + { + "name": "outer_gamma", + "default": 0.91 + }, + { + "name": "light2_position_x", + "default": 0.8 + }, + { + "name": "light2_position_y", + "default": 0.2 + }, + { + "name": "light2_inner_radius", + "default": 0.3 + }, + { + "name": "light2_outer_radius", + "default": 0.6 + }, + { + "name": "light2_color_r", + "default": 180 + }, + { + "name": "light2_color_g", + "default": 180 + }, + { + "name": "light2_color_b", + "default": 255 + }, + { + "name": "light2_intensity", + "default": 0.7 + }, + { + "name": "light3_position_x", + "default": 0.3 + }, + { + "name": "light3_position_y", + "default": 0.8 + }, + { + "name": "light3_inner_radius", + "default": 0.25 + }, + { + "name": "light3_outer_radius", + "default": 0.5 + }, + { + "name": "light3_color_r", + "default": 255 + }, + { + "name": "light3_color_g", + "default": 150 + }, + { + "name": "light3_color_b", + "default": 120 + }, + { + "name": "light3_intensity", + "default": 0.5 + } + ] +} diff --git a/tests/frontend/hooks.mjs b/tests/frontend/hooks.mjs new file mode 100644 index 0000000..25fc09b --- /dev/null +++ b/tests/frontend/hooks.mjs @@ -0,0 +1,41 @@ +/* + * Redirect ComfyUI's frontend module specifiers to the local stubs. + * + * `web/relight_*.js` import "../../scripts/app.js", which resolves to nothing + * outside a running ComfyUI. `module.registerHooks` (not the deprecated + * `module.register`, which runs in a separate loader worker and warns) lets a + * --import'd file rewrite the specifier before resolution, so `node --test` can + * import the real, byte-for-byte unmodified pack files. + * + * Matched by SUFFIX, not by exact path, so it keeps working for files at + * different relative depths. + * + * node --import ./tests/frontend/hooks.mjs --test "tests/frontend/*.test.mjs" + * + * A glob, not the directory. On Node 24 `--test tests/frontend` puts the + * directory itself through the ESM resolver, and the hook below hands it to + * `nextResolve`, which throws ERR_UNSUPPORTED_DIR_IMPORT and fails the whole + * run before a single test loads. Newer Node happens not to, so this passes + * locally and fails in CI. The glob resolves to files and works on both. + */ +import module from "node:module"; +import { pathToFileURL } from "node:url"; + +const STUBS = new URL("./stubs/", import.meta.url); + +const REDIRECTS = [ + ["/scripts/app.js", new URL("app.js", STUBS).href], +]; + +module.registerHooks({ + resolve(specifier, context, nextResolve) { + for (const [suffix, target] of REDIRECTS) { + if (specifier.endsWith(suffix)) { + return { url: target, shortCircuit: true }; + } + } + return nextResolve(specifier, context); + }, +}); + +export { pathToFileURL }; diff --git a/tests/frontend/migrate.test.mjs b/tests/frontend/migrate.test.mjs new file mode 100644 index 0000000..56e5d68 --- /dev/null +++ b/tests/frontend/migrate.test.mjs @@ -0,0 +1,203 @@ +/* + * The legacy-workflow migration, driven against the real v3.1.2 save. + * + * This is the load-bearing piece of v4.0.0: if it is wrong, every workflow in + * the wild loads plausible-looking garbage with no error. A synthetic 48-value + * array could be made to satisfy a wrong migration, so the input here is the + * workflow ReLight actually shipped at v3.1.2, kept verbatim in + * tests/fixtures/. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + LEGACY_ORDER, + looksLegacy, + migrateLegacyWidgetValues, + collectDefaults, + growToFitWidgets, +} from "../../web/relight_migrate.js"; +import { getExtension } from "./stubs/app.js"; +import { + driveBeforeRegisterNodeDef, + loadWorkflow, + makeNode, + makeNodeData, + relightNodeOf, + schema, +} from "./fake_node.mjs"; + +const legacyGraph = loadWorkflow("tests", "fixtures", "legacy_v3.1.2_workflow.json"); +const legacyValues = relightNodeOf(legacyGraph).widgets_values; +const nodeData = makeNodeData(); +const defaults = collectDefaults(nodeData); + +/** The legacy array as a name -> value map, for asserting against. */ +const legacy = Object.fromEntries(LEGACY_ORDER.map((name, i) => [name, legacyValues[i]])); + +test("defaults are read out of the real /object_info shape", () => { + // The server sends a combo as ["COMBO", {options, default}]. Reading the + // default out of the wrong element would silently reset every new widget + // to undefined during a migration. + assert.equal(defaults.lighting_mode, "Color Correction"); + assert.equal(defaults.subject_interaction, "None"); + assert.equal(defaults.debug_output_connected, false); + assert.equal(typeof defaults.shadow_strength, "number"); +}); + +test("the shipped v3.1.2 workflow is recognised as legacy", () => { + assert.equal(legacyValues.length, 48); + assert.equal(looksLegacy(legacyValues), true); +}); + +test("a current save is not mistaken for a legacy one", () => { + const current = schema.widgets.map((w) => w.default); + assert.equal(looksLegacy(current), false); +}); + +test("a 48-long array that is not a legacy save is left alone", () => { + // Same length, wrong sentinels: index 0 is not a preset name. + const impostor = new Array(48).fill(0); + assert.equal(looksLegacy(impostor), false); +}); + +test("every legacy value lands on the widget of the same name", () => { + const node = makeNode(); + assert.equal(migrateLegacyWidgetValues(node, legacyValues, defaults), true); + + const renamed = new Set([ + "show_debug_info", // dropped outright + "use_colored_lights", + "use_gradient_mode", + "apply_3d_lighting", + "light_direction", + ]); + for (const name of LEGACY_ORDER) { + if (renamed.has(name)) continue; + assert.equal( + node.widgetValue(name), + legacy[name], + `${name} did not survive the migration` + ); + } +}); + +test("the four booleans become the right combo values", () => { + // The shipped save has apply_3d_lighting on, "Behind Subject", + // use_colored_lights off, use_gradient_mode off and show_debug_info on. + assert.equal(legacy.apply_3d_lighting, true); + assert.equal(legacy.light_direction, "Behind Subject"); + assert.equal(legacy.use_colored_lights, false); + + const node = makeNode(); + migrateLegacyWidgetValues(node, legacyValues, defaults); + + assert.equal(node.widgetValue("subject_interaction"), "Light behind subject (rim)"); + assert.equal(node.widgetValue("lighting_mode"), "Color Correction"); + assert.equal(node.widgetValue("mask_shape"), "Radial falloff"); +}); + +test("show_debug_info is dropped, not carried into a new widget", () => { + // v4 has no debug toggle: the debug view follows the wiring of the + // debug_image output. A legacy true must not resurrect as a control. + for (const wasOn of [true, false]) { + const values = [...legacyValues]; + values[LEGACY_ORDER.indexOf("show_debug_info")] = wasOn; + const node = makeNode(); + migrateLegacyWidgetValues(node, values, defaults); + assert.equal(node.widgets.some((w) => w.name === "debug_view"), false); + assert.equal( + node.widgetValue("debug_output_connected"), + defaults.debug_output_connected, + "the connectivity flag is the UI's to set, not the migration's" + ); + } +}); + +test("apply_3d_lighting off wins over whatever light_direction said", () => { + const values = [...legacyValues]; + values[LEGACY_ORDER.indexOf("apply_3d_lighting")] = false; + // light_direction stays "Behind Subject" - the master switch overrode it. + const node = makeNode(); + migrateLegacyWidgetValues(node, values, defaults); + assert.equal(node.widgetValue("subject_interaction"), "None"); +}); + +test("each legacy light_direction maps to its own interaction", () => { + const cases = [ + ["Behind Subject", "Light behind subject (rim)"], + ["In Front of Subject", "Light in front of subject"], + ["No Occlusion", "None"], + ]; + for (const [direction, expected] of cases) { + const values = [...legacyValues]; + values[LEGACY_ORDER.indexOf("light_direction")] = direction; + const node = makeNode(); + migrateLegacyWidgetValues(node, values, defaults); + assert.equal(node.widgetValue("subject_interaction"), expected, direction); + } +}); + +test("colored lights migrate to Colored Light, not Both", () => { + // "Both" would change the render of every colour workflow, because the + // grading block was inert whenever use_colored_lights was on. + const values = [...legacyValues]; + values[LEGACY_ORDER.indexOf("use_colored_lights")] = true; + const node = makeNode(); + migrateLegacyWidgetValues(node, values, defaults); + assert.equal(node.widgetValue("lighting_mode"), "Colored Light"); +}); + +test("widgets new in v4 get their schema default, not a shifted legacy value", () => { + const node = makeNode({ shadow_strength: 999, shadow_length: 999 }); + migrateLegacyWidgetValues(node, legacyValues, defaults); + assert.equal(node.widgetValue("shadow_strength"), defaults.shadow_strength); + assert.equal(node.widgetValue("shadow_length"), defaults.shadow_length); + assert.notEqual(defaults.shadow_strength, undefined); +}); + +test("a node saved too short is grown to fit its widgets", () => { + const node = makeNode(); + node.size = [317, 100]; + growToFitWidgets(node); + assert.ok(node.size[1] >= node.computeSize()[1], "node was not grown"); +}); + +test("a deliberately widened node is not shrunk", () => { + const node = makeNode(); + node.size = [900, 4000]; + growToFitWidgets(node); + assert.deepEqual(node.size, [900, 4000]); +}); + +test("onConfigure runs the migration when a workflow is loaded", async () => { + const extension = getExtension("ReLight.migrate"); + assert.ok(extension, "the migration extension did not register"); + + const NodeType = await driveBeforeRegisterNodeDef(extension, nodeData); + const node = makeNode(); + NodeType.prototype.onConfigure.call(node, { widgets_values: legacyValues }); + + assert.equal(node.widgetValue("preset"), legacy.preset); + assert.equal(node.widgetValue("subject_interaction"), "Light behind subject (rim)"); + assert.equal(node.widgetValue("light_position_x"), legacy.light_position_x); +}); + +test("onConfigure leaves a current workflow untouched", async () => { + const extension = getExtension("ReLight.migrate"); + const NodeType = await driveBeforeRegisterNodeDef(extension, nodeData); + + const currentGraph = loadWorkflow("example_workflows", "relight_basic.json"); + const currentValues = relightNodeOf(currentGraph).widgets_values; + assert.equal(currentValues.length, schema.widgets.length); + + const node = makeNode(); + schema.widgets.forEach((widget, index) => { + node.widgets[index].value = currentValues[index]; + }); + NodeType.prototype.onConfigure.call(node, { widgets_values: currentValues }); + + schema.widgets.forEach((widget, index) => { + assert.equal(node.widgetValue(widget.name), currentValues[index], widget.name); + }); +}); diff --git a/tests/frontend/recreate.test.mjs b/tests/frontend/recreate.test.mjs new file mode 100644 index 0000000..823ba07 --- /dev/null +++ b/tests/frontend/recreate.test.mjs @@ -0,0 +1,259 @@ +/* + * "Fix node (recreate)". + * + * ComfyUI-Manager's version passes a STRING node id to `connect()`, which only + * resolves numbers, so it throws after adding the replacement and before + * removing the original - leaving two nodes on the canvas. These pin the three + * rules that make ours correct: the node object rather than its id, widget + * values by name, links by slot name. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { app, getExtension } from "./stubs/app.js"; +import { makeNode, schema } from "./fake_node.mjs"; + +const MENU_LABEL = "Fix node (recreate)"; + +/** A minimal LiteGraph-shaped graph holding nodes and links. */ +function makeGraph() { + const graph = { + nodes: [], + links: {}, + nextLink: 1, + add(node) { + node.graph = graph; + node.id = node.id ?? String(graph.nodes.length + 100); + graph.nodes.push(node); + }, + remove(node) { + graph.nodes = graph.nodes.filter((n) => n !== node); + }, + getNodeById(id) { + return graph.nodes.find((n) => String(n.id) === String(id)) ?? null; + }, + getLink(id) { + return graph.links[id] ?? null; + }, + }; + return graph; +} + +/** A plain node with named input/output slots. */ +function makePlainNode(id, inputs, outputs) { + const node = { + id, + type: "Plain" + id, + widgets: [], + pos: [0, 0], + size: [200, 60], + inputs: inputs.map((name) => ({ name, link: null })), + outputs: outputs.map((name) => ({ name, links: [] })), + findInputSlot(name) { + return this.inputs.findIndex((s) => s.name === name); + }, + findOutputSlot(name) { + return this.outputs.findIndex((s) => s.name === name); + }, + connect(originSlot, target, targetSlot) { + // The real LiteGraph resolves its second argument to a node ONLY + // when it is a number. A string id sails past and blows up here - + // which is the upstream bug. Model that faithfully. + if (typeof target !== "object" || target === null) { + throw new TypeError("target.findInputSlot is not a function"); + } + const id = this.graph.nextLink++; + this.graph.links[id] = { + origin_id: this.id, + origin_slot: originSlot, + target_id: target.id, + target_slot: targetSlot, + }; + this.outputs[originSlot].links.push(id); + target.inputs[targetSlot].link = id; + return this.graph.links[id]; + }, + }; + return node; +} + +/** A ReLight node with real slots, wired into a graph. */ +function makeRelight(graph, overrides = {}) { + const node = makeNode(overrides); + node.pos = [40, 60]; + node.inputs = [ + { name: "image", link: null }, + { name: "mask", link: null }, + ]; + node.outputs = [ + { name: "image", links: [] }, + { name: "mask", links: [] }, + { name: "debug_image", links: [] }, + ]; + node.findInputSlot = makePlainNode("x", [], []).findInputSlot; + node.findOutputSlot = makePlainNode("x", [], []).findOutputSlot; + node.connect = makePlainNode("x", [], []).connect; + graph.add(node); + return node; +} + +function withLiteGraph(graph, factory, fn) { + const previous = globalThis.window; + globalThis.window = { + LiteGraph: { + createNode(type) { + assert.equal(type, schema.node_class); + return factory(); + }, + }, + }; + const previousGraph = app.graph; + app.graph = graph; + try { + return fn(); + } finally { + globalThis.window = previous; + app.graph = previousGraph; + } +} + +const { recreateNode, copyWidgetValues, replaceRecreateOption } = await import( + "../../web/relight_recreate.js" +); + +test("a recreate replaces the node instead of duplicating it", () => { + const graph = makeGraph(); + const source = makePlainNode("1", [], ["IMAGE"]); + graph.add(source); + const relight = makeRelight(graph, { preset: "Spotlight" }); + source.connect(0, relight, 0); + + const before = graph.nodes.length; + const ok = withLiteGraph(graph, () => makeRelight(makeGraph()), () => recreateNode(relight)); + + assert.equal(ok, true); + assert.equal(graph.nodes.length, before, "the original node was left on the canvas"); + assert.equal(graph.nodes.includes(relight), false, "the original node was not removed"); +}); + +test("input links are restored, by slot name", () => { + const graph = makeGraph(); + const source = makePlainNode("1", [], ["IMAGE", "MASK"]); + graph.add(source); + const relight = makeRelight(graph); + source.connect(0, relight, 0); + source.connect(1, relight, 1); + + let fresh; + withLiteGraph(graph, () => (fresh = makeRelight(makeGraph())), () => recreateNode(relight)); + + assert.ok(fresh.inputs[0].link, "image input was not reconnected"); + assert.ok(fresh.inputs[1].link, "mask input was not reconnected"); +}); + +test("output links are restored, by slot name", () => { + const graph = makeGraph(); + const relight = makeRelight(graph); + const preview = makePlainNode("9", ["images"], []); + graph.add(preview); + relight.connect(2, preview, 0); // debug_image -> preview + + let fresh; + withLiteGraph(graph, () => (fresh = makeRelight(makeGraph())), () => recreateNode(relight)); + + assert.equal(fresh.outputs[2].links.length, 1, "the debug_image link was lost"); + assert.ok(preview.inputs[0].link); +}); + +test("connect is given the node object, never its id", () => { + // The fake `connect` throws on a non-object exactly as LiteGraph does. If + // this pack ever regressed to passing an id, this test is the crash. + const graph = makeGraph(); + const source = makePlainNode("1", [], ["IMAGE"]); + graph.add(source); + const relight = makeRelight(graph); + source.connect(0, relight, 0); + + assert.doesNotThrow(() => { + withLiteGraph(graph, () => makeRelight(makeGraph()), () => recreateNode(relight)); + }); +}); + +test("widget values are copied by name, not by index", () => { + // The reason to recreate a node is that its schema moved, so an + // index-based copy writes every value into the wrong widget. + const from = makeNode({ preset: "Spotlight", light_position_x: 0.12, num_light_sources: 3 }); + const to = makeNode(); + to.widgets.reverse(); + + const dropped = copyWidgetValues(from, to); + + assert.deepEqual(dropped, []); + assert.equal(to.widgetValue("preset"), "Spotlight"); + assert.equal(to.widgetValue("light_position_x"), 0.12); + assert.equal(to.widgetValue("num_light_sources"), 3); +}); + +test("a combo value the fresh node no longer offers is dropped, not forced", () => { + const from = makeNode({ preset: "A Preset That Was Removed" }); + const to = makeNode(); + const dropped = copyWidgetValues(from, to); + assert.deepEqual(dropped, ["preset"]); + assert.equal(to.widgetValue("preset"), "None", "an invalid value was written onto the widget"); +}); + +test("hidden widgets still carry their values across", () => { + // relight_ui.js hides by swapping widget.type; those widgets hold real + // values and skipping them would silently reset half the node. + const from = makeNode({ light2_position_x: 0.77 }); + from.widgets.find((w) => w.name === "light2_position_x").type = "hidden-number"; + const to = makeNode(); + copyWidgetValues(from, to); + assert.equal(to.widgetValue("light2_position_x"), 0.77); +}); + +test("the menu entry replaces another pack's broken one", () => { + const node = makeNode(); + const options = [ + { content: "Title" }, + { content: MENU_LABEL, callback: () => "manager" }, + { content: "Remove" }, + ]; + replaceRecreateOption(node, options); + + const entries = options.filter((entry) => entry?.content === MENU_LABEL); + assert.equal(entries.length, 1, "two recreate entries in the menu"); + assert.equal(entries[0].callback.toString().includes("manager"), false); +}); + +test("nodeCreated installs the entry on ReLight nodes only", async () => { + const extension = getExtension("ReLight.recreate"); + assert.ok(extension, "the recreate extension did not register"); + + const relight = makeNode(); + await extension.nodeCreated(relight, app); + const options = []; + relight.getExtraMenuOptions(app.canvas, options); + assert.equal(options.filter((o) => o.content === MENU_LABEL).length, 1); + + const other = makeNode(); + other.comfyClass = "SomeoneElsesNode"; + other.type = "SomeoneElsesNode"; + await extension.nodeCreated(other, app); + assert.equal(other.getExtraMenuOptions, undefined); +}); + +test("an upstream menu handler still contributes its entries", async () => { + const extension = getExtension("ReLight.recreate"); + const node = makeNode(); + node.getExtraMenuOptions = function (canvas, options) { + options.push({ content: "Someone else's entry" }); + return "upstream"; + }; + await extension.nodeCreated(node, app); + + const options = []; + assert.equal(node.getExtraMenuOptions(app.canvas, options), "upstream"); + assert.ok(options.some((o) => o.content === "Someone else's entry")); + assert.ok(options.some((o) => o.content === MENU_LABEL)); +}); diff --git a/tests/frontend/stubs/app.js b/tests/frontend/stubs/app.js new file mode 100644 index 0000000..4214eda --- /dev/null +++ b/tests/frontend/stubs/app.js @@ -0,0 +1,31 @@ +/* + * Stand-in for ComfyUI's ../../scripts/app.js. + * + * A pack's frontend module imports `app` from a path that only resolves inside + * a running ComfyUI. `hooks.mjs` redirects that specifier here so the real, + * unmodified web/*.js can be imported by `node --test`. + * + * Only the surface ReLight's own modules touch is modelled; everything records + * what it was given so a test can drive it. + */ + +export const registeredExtensions = []; + +export const app = { + registerExtension(extension) { + registeredExtensions.push(extension); + }, + graph: { + findNodesByType: () => [], + setDirtyCanvas() {}, + }, + canvas: { + setDirty() {}, + }, + extensionManager: undefined, +}; + +/** The extension a module registered, by its `name`. */ +export function getExtension(name) { + return registeredExtensions.find((ext) => ext.name === name); +} diff --git a/tests/frontend/ui.test.mjs b/tests/frontend/ui.test.mjs new file mode 100644 index 0000000..1a0391b --- /dev/null +++ b/tests/frontend/ui.test.mjs @@ -0,0 +1,329 @@ +/* + * Conditional widget visibility. + * + * Every rule here is a control that looked live and was not: Light 2/3 painted + * with one light source, the grading block painted in colour mode, a preset + * silently overriding a dozen fully draggable values. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyVisibility, + presetLabel, + widgetsToDisable, + widgetsToHide, +} from "../../web/relight_ui.js"; +import { PRESET_KEYS, PRESET_VALUES } from "../../web/relight_presets.js"; +import { app, getExtension } from "./stubs/app.js"; +import { makeNode, schema } from "./fake_node.mjs"; + +const DEFAULTS = Object.fromEntries(schema.widgets.map((w) => [w.name, w.default])); + +function values(overrides) { + return { ...DEFAULTS, ...overrides }; +} + +function widget(node, name) { + return node.widgets.find((w) => w.name === name); +} + +test("light 2 and 3 are hidden with a single light source", () => { + const hidden = widgetsToHide(values({ num_light_sources: 1 })); + assert.ok(hidden.has("light2_position_x")); + assert.ok(hidden.has("light3_intensity")); +}); + +test("light 2 appears at two sources, light 3 only at three", () => { + const two = widgetsToHide(values({ num_light_sources: 2, lighting_mode: "Colored Light" })); + assert.equal(two.has("light2_position_x"), false); + assert.ok(two.has("light3_position_x")); + + const three = widgetsToHide(values({ num_light_sources: 3, lighting_mode: "Colored Light" })); + assert.equal(three.has("light3_position_x"), false); +}); + +test("colour controls are hidden in Color Correction", () => { + const hidden = widgetsToHide(values({ lighting_mode: "Color Correction" })); + for (const name of ["light_color_r", "light_color_g", "light_color_b", "light_intensity"]) { + assert.ok(hidden.has(name), name); + } + assert.equal(hidden.has("inner_brightness"), false); +}); + +test("the grading block is hidden in Colored Light", () => { + const hidden = widgetsToHide(values({ lighting_mode: "Colored Light" })); + for (const name of ["inner_brightness", "inner_gamma", "outer_saturation", "outer_gamma"]) { + assert.ok(hidden.has(name), name); + } + assert.equal(hidden.has("light_color_r"), false); +}); + +test("Both shows colour and grading together", () => { + const hidden = widgetsToHide(values({ lighting_mode: "Both" })); + assert.equal(hidden.has("light_color_r"), false); + assert.equal(hidden.has("inner_brightness"), false); +}); + +test("rim and shadow controls only appear for a light behind the subject", () => { + for (const interaction of ["None", "Light in front of subject"]) { + const hidden = widgetsToHide(values({ subject_interaction: interaction })); + for (const name of ["rim_amplification", "shadow_strength", "shadow_length"]) { + assert.ok(hidden.has(name), `${interaction}: ${name}`); + } + } + const rim = widgetsToHide(values({ subject_interaction: "Light behind subject (rim)" })); + for (const name of ["rim_amplification", "shadow_strength", "shadow_length"]) { + assert.equal(rim.has(name), false, name); + } +}); + +test("the connectivity flag is never shown", () => { + assert.ok(widgetsToHide(values({})).has("debug_output_connected")); +}); + +test("a preset greys the widgets it overrides, and only those", () => { + const disabled = widgetsToDisable(values({ preset: "Spotlight" })); + const expected = PRESET_KEYS["Spotlight"].filter((k) => k !== "effect_strength"); + assert.deepEqual([...disabled].sort(), [...expected].sort()); +}); + +test("effect_strength stays live under a preset", () => { + // A preset scales it rather than replacing it - that is the documented + // exception in _load_preset, and greying it would contradict the node. + for (const name of Object.keys(PRESET_KEYS)) { + assert.equal( + widgetsToDisable(values({ preset: name })).has("effect_strength"), + false, + name + ); + } +}); + +test("no preset is selected, nothing is greyed", () => { + assert.equal(widgetsToDisable(values({ preset: "None" })).size, 0); +}); + +test("preserve_positioning hands the geometry back", () => { + const off = widgetsToDisable(values({ preset: "Spotlight", preserve_positioning: false })); + const on = widgetsToDisable(values({ preset: "Spotlight", preserve_positioning: true })); + assert.ok(off.has("light_position_x")); + assert.equal(on.has("light_position_x"), false); + // Non-geometry overrides are still the preset's. + assert.ok(on.has("inner_brightness")); +}); + +test("hiding and showing a widget round-trips", () => { + // The classic failure: computeSize is restored by reassigning a saved + // `undefined`, which leaves the zero-size stub and the widget never returns. + const node = makeNode({ num_light_sources: 1 }); + const originalType = widget(node, "light2_position_x").type; + applyVisibility(node); + const light2 = widget(node, "light2_position_x"); + assert.equal(light2.hidden, true); + assert.ok(light2.type.startsWith("hidden-")); + + widget(node, "num_light_sources").value = 2; + applyVisibility(node); + assert.equal(light2.hidden, false); + assert.equal(light2.type, originalType, "the original widget type was not restored"); + assert.equal( + Object.prototype.hasOwnProperty.call(light2, "computeSize"), + false, + "computeSize stub was reassigned instead of deleted" + ); +}); + +test("the node shrinks when a block is hidden and grows when it comes back", () => { + const node = makeNode({ num_light_sources: 3, lighting_mode: "Both" }); + applyVisibility(node); + const tall = node.size[1]; + + widget(node, "num_light_sources").value = 1; + applyVisibility(node); + assert.ok(node.size[1] < tall, "the node kept its full height with two blocks hidden"); + + widget(node, "num_light_sources").value = 3; + applyVisibility(node); + assert.equal(node.size[1], tall, "the node did not grow back"); +}); + +test("a deliberately widened node keeps its width", () => { + const node = makeNode(); + node.size = [900, 100]; + applyVisibility(node); + assert.equal(node.size[0], 900); +}); + +test("applying visibility twice reports no second change", () => { + // The draw loop calls this every frame; a steady state must be a no-op or + // the canvas is marked dirty forever. + const node = makeNode(); + applyVisibility(node); + assert.equal(applyVisibility(node), false); +}); + +test("nodeCreated wires both the callback and the draw loop", async () => { + const extension = getExtension("ReLight.ui"); + assert.ok(extension, "the ui extension did not register"); + + const node = makeNode({ num_light_sources: 1 }); + await extension.nodeCreated(node, app); + + assert.equal(widget(node, "light2_position_x").hidden, true); + + // The immediate path: changing a value through the widget's callback. + const count = widget(node, "num_light_sources"); + count.value = 2; + count.callback(2); + assert.equal(widget(node, "light2_position_x").hidden, false); + + // The safety net: a value changed with no callback at all, as a workflow + // load or an undo does it. + count.value = 1; + node.onDrawForeground(); + assert.equal(widget(node, "light2_position_x").hidden, true); +}); + +test("an upstream widget callback still runs", async () => { + const extension = getExtension("ReLight.ui"); + const node = makeNode(); + let called = 0; + widget(node, "num_light_sources").callback = () => { + called += 1; + return "upstream"; + }; + await extension.nodeCreated(node, app); + assert.equal(widget(node, "num_light_sources").callback(2), "upstream"); + assert.equal(called, 1); +}); + +test("nodes from other packs are left alone", async () => { + const extension = getExtension("ReLight.ui"); + const node = makeNode(); + node.comfyClass = "SomeoneElsesNode"; + node.type = "SomeoneElsesNode"; + const before = node.onDrawForeground; + await extension.nodeCreated(node, app); + assert.equal(node.onDrawForeground, before); +}); + +/* + * A greyed widget cannot show its own value: the ComfyUI frontend's + * `_displayValue` getter returns "" for anything with `computedDisabled` set, + * so `disabled = true` on its own paints an empty bar. These pin the label + * carrying the preset's value instead - the whole point of greying rather than + * hiding is that you can read what the preset did. + */ + +test("a greyed widget shows the preset's value in its label", () => { + const node = makeNode({ preset: "Spotlight" }); + applyVisibility(node); + const blur = widget(node, "mask_blur"); + assert.equal(blur.disabled, true); + assert.equal(blur.label, presetLabel("mask_blur", PRESET_VALUES["Spotlight"].mask_blur)); + assert.ok(blur.label.includes(String(PRESET_VALUES["Spotlight"].mask_blur))); +}); + +test("the label carries the preset's value, not the widget's own", () => { + // The widget still holds whatever the user last set - which is exactly the + // number the preset is ignoring, so showing it would be a lie. + const node = makeNode({ preset: "Spotlight" }); + const blur = widget(node, "mask_blur"); + blur.value = 999; + applyVisibility(node); + assert.equal(blur.label.includes("999"), false, "the label showed the ignored widget value"); + assert.ok(blur.label.includes(String(PRESET_VALUES["Spotlight"].mask_blur))); +}); + +test("clearing the preset restores the original label", () => { + const node = makeNode({ preset: "Spotlight" }); + const blur = widget(node, "mask_blur"); + const original = blur.label; + applyVisibility(node); + assert.notEqual(blur.label, original); + + widget(node, "preset").value = "None"; + applyVisibility(node); + assert.equal(blur.disabled, false); + assert.equal(blur.label, original, "the label was not restored when the preset was cleared"); +}); + +test("preserve_positioning hands back the label as well as the control", () => { + const node = makeNode({ preset: "Spotlight" }); + applyVisibility(node); + const x = widget(node, "light_position_x"); + assert.equal(x.disabled, true); + + widget(node, "preserve_positioning").value = true; + applyVisibility(node); + assert.equal(x.disabled, false); + assert.equal(x.label, undefined, "a geometry widget kept its preset label"); +}); + +test("effect_strength is never relabelled - a preset scales it, not replaces it", () => { + const node = makeNode({ preset: "Spotlight" }); + applyVisibility(node); + const strength = widget(node, "effect_strength"); + assert.ok(!strength.disabled); + assert.equal(strength.label, undefined); +}); + +test("a preset is a steady state too", () => { + // Relabelling must be idempotent, or the draw loop marks the canvas dirty + // on every frame for as long as a preset is selected. + const node = makeNode({ preset: "Spotlight" }); + applyVisibility(node); + assert.equal(applyVisibility(node), false); +}); + +test("every preset value survives the round trip into a label", () => { + for (const [name, keys] of Object.entries(PRESET_KEYS)) { + const node = makeNode({ preset: name }); + applyVisibility(node); + for (const key of keys) { + if (key === "effect_strength") continue; + const w = widget(node, key); + assert.ok(w, `${name} names a widget that does not exist: ${key}`); + assert.equal( + w.label, + presetLabel(key, PRESET_VALUES[name][key]), + `${name}.${key} did not get a labelled value` + ); + } + } +}); + +test("a stale preset label is cleared even with no bookkeeping behind it", () => { + // A workflow saved mid-edit, or an undo that restores the label but not our + // stash, must not leave a preset's value showing on a live control. + const node = makeNode({ preset: "None" }); + const blur = widget(node, "mask_blur"); + blur.label = presetLabel("mask_blur", 30); + assert.equal(applyVisibility(node), true, "the stale label did not register as a change"); + assert.equal(blur.label, undefined); +}); + +test("a label the user set themselves is left alone", () => { + // Only labels shaped like ours are ours to clear. + const node = makeNode({ preset: "None" }); + const blur = widget(node, "mask_blur"); + blur.label = "Softness"; + applyVisibility(node); + assert.equal(blur.label, "Softness"); +}); + +test("a user label survives a preset being applied and cleared", () => { + const node = makeNode({ preset: "None" }); + const blur = widget(node, "mask_blur"); + blur.label = "Softness"; + applyVisibility(node); + + widget(node, "preset").value = "Spotlight"; + applyVisibility(node); + assert.equal(blur.label, presetLabel("mask_blur", PRESET_VALUES["Spotlight"].mask_blur)); + + widget(node, "preset").value = "None"; + applyVisibility(node); + assert.equal(blur.label, "Softness", "the user's own label was not restored"); +}); diff --git a/tests/stubs/comfy_api/latest/__init__.py b/tests/stubs/comfy_api/latest/__init__.py index 8262e0c..bac16ff 100644 --- a/tests/stubs/comfy_api/latest/__init__.py +++ b/tests/stubs/comfy_api/latest/__init__.py @@ -38,6 +38,17 @@ def _comfy_type(name): class io: + class Hidden: + """The subset of ``comfy_api.latest.io.Hidden`` the schema names. + + The real thing is a ``str`` Enum; ReLight only ever puts these members + in ``Schema(hidden=...)`` and reads ``cls.hidden.`` at runtime, + so plain string constants model it exactly. + """ + + prompt = "PROMPT" + unique_id = "UNIQUE_ID" + Image = _comfy_type("Image") Mask = _comfy_type("Mask") Combo = _comfy_type("Combo") @@ -52,7 +63,10 @@ class io: self.outputs = kwargs.get("outputs", []) class ComfyNode: - pass + # ComfyUI attaches a HiddenHolder to the per-run class clone. Outside a + # running ComfyUI nothing sets it, and the node must cope with that - + # so the stub leaves it None rather than faking a prompt. + hidden = None class NodeOutput: def __init__(self, *args): diff --git a/tests/test_relight.py b/tests/test_relight.py index faebc86..7c6b9cf 100644 --- a/tests/test_relight.py +++ b/tests/test_relight.py @@ -6,6 +6,8 @@ Each test names the audit finding it locks in. They run headless against the import json import pathlib +import re +import sys import torch @@ -48,66 +50,168 @@ def test_every_input_has_a_tooltip(node): assert missing == [] -def test_saved_workflow_widget_order_is_stable(node): - """Saved workflows store widget values positionally. +#: Widget order exactly as v3.1.2 serialised it. Not a schema expectation - the +#: v4 schema deliberately reorders - but the input to `web/relight_migrate.js`, +#: which is the only thing keeping pre-v4 workflows loading correctly. +LEGACY_WIDGET_ORDER = [ + "preset", + "num_light_sources", + "preserve_positioning", + "show_debug_info", + "use_colored_lights", + "use_gradient_mode", + "apply_3d_lighting", + "light_direction", + "remove_background", + "effect_strength", + "mask_blur", + "rim_amplification", + "light_position_x", + "light_position_y", + "inner_circle_radius", + "outer_circle_radius", + "light_color_r", + "light_color_g", + "light_color_b", + "light_intensity", + "inner_brightness", + "inner_contrast", + "inner_saturation", + "inner_temperature", + "inner_tint", + "inner_gamma", + "outer_brightness", + "outer_contrast", + "outer_saturation", + "outer_temperature", + "outer_tint", + "outer_gamma", + "light2_position_x", + "light2_position_y", + "light2_inner_radius", + "light2_outer_radius", + "light2_color_r", + "light2_color_g", + "light2_color_b", + "light2_intensity", + "light3_position_x", + "light3_position_y", + "light3_inner_radius", + "light3_outer_radius", + "light3_color_r", + "light3_color_g", + "light3_color_b", + "light3_intensity", +] - Reordering or inserting an input silently corrupts every workflow already in - the wild, so the order is pinned here deliberately. If this test fails, - that is the change to reconsider - not the expectation. +#: Widgets renamed or merged away in v4.0.0, and what the migration turns them +#: into. Pinned here so a rename in relight.py without a matching change in the +#: JS goes red. +LEGACY_WIDGETS_REMOVED = { + # show_debug_info has no v4 counterpart at all: the debug view now follows + # whether the debug_image output is wired, so there is nothing to carry over. + "show_debug_info": None, + "use_colored_lights": "lighting_mode", + "use_gradient_mode": "mask_shape", + "apply_3d_lighting": "subject_interaction", + "light_direction": "subject_interaction", +} + + +def _js_string_array(source, name): + """Pull `export const = [ "a", "b" ];` out of the migration module.""" + match = re.search( + r"export const " + name + r" = \[(.*?)\];", source, re.DOTALL + ) + assert match, f"{name} not found in web/relight_migrate.js" + return re.findall(r'"((?:[^"\\]|\\.)*)"', match.group(1)) + + +def test_frontend_schema_fixture_is_current(): + """The JS tests build their fake nodes from a dump of this schema. + + A stale dump means the frontend suite is testing a node shape that no + longer exists - green, and proving nothing. + """ + sys.path.insert(0, str(REPO_ROOT / "scripts")) + try: + import dump_frontend_fixture + finally: + sys.path.pop(0) + assert dump_frontend_fixture.main(["--check"]) == 0 + + +def test_legacy_widget_order_matches_the_migration_constant(): + """The JS migration and this file must agree on the pre-v4 widget order. + + The whole migration is a name-for-index lookup against that order. If the + two copies drift, every saved workflow loads plausible-looking garbage - + no error, wrong values. So they are pinned against each other here. + """ + source = (REPO_ROOT / "web" / "relight_migrate.js").read_text(encoding="utf-8") + assert _js_string_array(source, "LEGACY_ORDER") == LEGACY_WIDGET_ORDER + + +def test_migration_knows_every_legacy_preset_name(node): + """The legacy-save sentinel checks index 0 against this list.""" + source = (REPO_ROOT / "web" / "relight_migrate.js").read_text(encoding="utf-8") + assert _js_string_array(source, "LEGACY_PRESETS") == list(node.PRESETS) + + +def test_migration_covers_every_legacy_widget(node): + """Every pre-v4 widget either still exists by name, or has a translation.""" + current = {spec.id for spec in node.define_schema().inputs} + unhandled = [ + name + for name in LEGACY_WIDGET_ORDER + if name not in current and name not in LEGACY_WIDGETS_REMOVED + ] + assert unhandled == [], "no migration path for these pre-v4 widgets" + + +def test_removed_widgets_map_onto_widgets_that_exist(node): + current = {spec.id for spec in node.define_schema().inputs} + missing = sorted( + { + target + for target in LEGACY_WIDGETS_REMOVED.values() + if target is not None and target not in current + } + ) + assert missing == [] + + +def test_current_widget_count_cannot_be_mistaken_for_a_legacy_save(node): + """The migration's length check must not fire on a current save. + + `looksLegacy` gates on a 48-value array plus two sentinels. If the v4 schema + ever had exactly 48 widgets the length gate would be load-bearing on the + sentinels alone, which is a thinner guarantee than intended. """ widget_ids = [ spec.id for spec in node.define_schema().inputs if spec.id not in ("image", "mask") ] - assert widget_ids == [ - "preset", - "num_light_sources", - "preserve_positioning", - "show_debug_info", - "use_colored_lights", - "use_gradient_mode", - "apply_3d_lighting", - "light_direction", - "remove_background", - "effect_strength", - "mask_blur", - "rim_amplification", - "light_position_x", - "light_position_y", - "inner_circle_radius", - "outer_circle_radius", - "light_color_r", - "light_color_g", - "light_color_b", - "light_intensity", - "inner_brightness", - "inner_contrast", - "inner_saturation", - "inner_temperature", - "inner_tint", - "inner_gamma", - "outer_brightness", - "outer_contrast", - "outer_saturation", - "outer_temperature", - "outer_tint", - "outer_gamma", - "light2_position_x", - "light2_position_y", - "light2_inner_radius", - "light2_outer_radius", - "light2_color_r", - "light2_color_g", - "light2_color_b", - "light2_intensity", - "light3_position_x", - "light3_position_y", - "light3_inner_radius", - "light3_outer_radius", - "light3_color_r", - "light3_color_g", - "light3_color_b", - "light3_intensity", - ] + assert len(widget_ids) != len(LEGACY_WIDGET_ORDER) + + +def test_legacy_fixture_workflow_is_a_real_pre_v4_save(): + """The migration fixture must be a genuine v3.1.2 save, not a synthetic array. + + A hand-written array can be made to satisfy any migration, including a wrong + one. This is the shipped v3.1.2 example workflow, kept verbatim. + """ + graph = json.loads( + (REPO_ROOT / "tests" / "fixtures" / "legacy_v3.1.2_workflow.json").read_text() + ) + relight = [n for n in graph["nodes"] if n["type"] == "ReLight"] + assert len(relight) == 1 + values = relight[0]["widgets_values"] + assert len(values) == len(LEGACY_WIDGET_ORDER) + assert values[LEGACY_WIDGET_ORDER.index("light_direction")] in ( + "Behind Subject", + "In Front of Subject", + "No Occlusion", + ) def test_shipped_example_workflows_match_the_schema(node): @@ -127,11 +231,12 @@ def test_shipped_example_workflows_match_the_schema(node): assert len(values) == len(widget_ids), f"{path.name}: widget count drifted" settings = dict(zip(widget_ids, values)) assert settings["preset"] in node.PRESETS, f"{path.name}: unknown preset" - assert settings["light_direction"] in ( - "Behind Subject", - "In Front of Subject", - "No Occlusion", - ), f"{path.name}: unknown light_direction" + for widget, allowed in ( + ("lighting_mode", node.LIGHTING_MODES), + ("mask_shape", node.MASK_SHAPES), + ("subject_interaction", node.SUBJECT_INTERACTIONS), + ): + assert settings[widget] in allowed, f"{path.name}: unknown {widget}" # --- basic contract ------------------------------------------------------- @@ -163,7 +268,7 @@ def test_input_image_is_not_mutated(run, image): def test_mask_smaller_than_image_is_resized(run, image): mask = torch.ones(1, 32, 48) out_image, out_mask, _ = run( - image, mask=mask, light_direction="Behind Subject", remove_background=True + image, mask=mask, subject_interaction="Light behind subject (rim)", remove_background=True ) assert out_image.shape == image.shape assert out_mask.shape == (1, 64, 96) @@ -188,7 +293,7 @@ def test_rgba_image_survives_and_keeps_alpha(run): def test_rgba_image_in_colored_mode(run): rgba = torch.rand(1, 32, 32, 4) - out_image = run(rgba, use_colored_lights=True)[0] + out_image = run(rgba, lighting_mode="Colored Light")[0] assert out_image.shape == rgba.shape @@ -206,8 +311,8 @@ def test_batch_uses_each_frames_own_mask(run): out = run( batch, mask=mask, - light_direction="Behind Subject", - use_colored_lights=True, + subject_interaction="Light behind subject (rim)", + lighting_mode="Colored Light", )[0] delta = (out - batch).abs().mean(dim=(1, 2, 3)) @@ -234,7 +339,7 @@ def test_batch_composite_is_per_frame(run): def test_mask_passes_through_when_both_consumers_are_off(run, image): mask = torch.ones(1, 64, 96) out_mask = run( - image, mask=mask, apply_3d_lighting=False, remove_background=False + image, mask=mask, subject_interaction="None", remove_background=False )[1] assert torch.allclose(out_mask, mask) @@ -286,7 +391,7 @@ def test_zero_effect_strength_is_a_no_op_with_a_preset(run, image): def test_zero_intensity_colored_light_is_a_no_op(run, image): - out_image = run(image, use_colored_lights=True, light_intensity=0.0)[0] + out_image = run(image, lighting_mode="Colored Light", light_intensity=0.0)[0] assert torch.allclose(out_image, image, atol=1e-6) @@ -353,14 +458,13 @@ def test_remove_background_still_composites_when_enabled(run, image): def test_remove_background_applies_when_occlusion_is_switched_off(run, image): - """apply_3d_lighting=False means plain lighting, which still needs compositing.""" + """No subject interaction means plain lighting, which still needs compositing.""" mask = torch.zeros(1, 64, 96) mask[:, 16:48, 24:72] = 1.0 out_image = run( image, mask=mask, - apply_3d_lighting=False, - light_direction="Behind Subject", + subject_interaction="None", remove_background=True, inner_brightness=60.0, )[0] @@ -408,6 +512,220 @@ def test_preset_shadow_zones_actually_darken(node): assert out.mean() < 0.5, f"{name}: outer zone should darken" +# --- finding 4: "Behind Subject" did not occlude --------------------------- + +RIM = "Light behind subject (rim)" + + +def _subject_mask(height=64, width=96, top=12, bottom=52, left=32, right=64): + """A solid rectangular subject in the middle of the frame.""" + mask = torch.zeros(1, height, width) + mask[:, top:bottom, left:right] = 1.0 + return mask + + +def test_behind_subject_does_not_light_the_subject(run): + """Background light must not land on the subject it is behind. + + Up to v3.1.2 the silhouette was subtracted *before* the blur, so a 50px + blur smeared background light straight back across the edge onto the face. + That was the reported "Behind Subject does not occlude". + """ + flat = torch.full((1, 64, 96, 3), 0.25) + mask = _subject_mask() + lit = run( + flat, + mask=mask, + subject_interaction=RIM, + lighting_mode="Colored Light", + rim_amplification=0.0, # isolate the background glow from the rim + light_position_x=0.5, + light_position_y=0.5, + inner_circle_radius=0.5, + outer_circle_radius=1.0, + mask_blur=50.0, + )[0] + + inside = mask[0].bool() + changed = (lit[0] - flat[0]).abs().sum(-1) + assert float(changed[inside].max()) < 1e-5, "background light leaked onto the subject" + assert float(changed[~inside].max()) > 0.01, "no background light at all" + + +def test_behind_subject_still_rims_the_subject_edge(run): + """Occluding the background must not switch the rim highlight off.""" + flat = torch.full((1, 64, 96, 3), 0.25) + mask = _subject_mask() + lit = run( + flat, + mask=mask, + subject_interaction=RIM, + lighting_mode="Colored Light", + rim_amplification=4.0, + mask_blur=5.0, + )[0] + changed = (lit[0] - flat[0]).abs().sum(-1) + assert float(changed[mask[0].bool()].max()) > 0.01, "the rim highlight is gone" + + +def test_the_subject_casts_a_shadow_away_from_the_light(run): + """With the light above, the background below the subject is darker. + + v3.1.2 cast no shadow at all: the subject blocked nothing, so the near side + of a head was lit exactly as brightly as the far side. + """ + flat = torch.full((1, 64, 96, 3), 0.25) + mask = _subject_mask() + lit = run( + flat, + mask=mask, + subject_interaction=RIM, + lighting_mode="Colored Light", + rim_amplification=0.0, + light_position_x=0.5, + light_position_y=0.05, + inner_circle_radius=0.6, + outer_circle_radius=1.5, + mask_blur=10.0, + shadow_strength=1.0, + shadow_length=0.8, + )[0] + + columns = slice(32, 64) # directly above and below the subject + above = lit[0, 2:10, columns].mean() + below = lit[0, 54:62, columns].mean() + assert below < above, "the subject cast no shadow below it" + + +def test_shadow_strength_zero_casts_no_shadow(run): + flat = torch.full((1, 64, 96, 3), 0.25) + mask = _subject_mask() + common = dict( + mask=mask, + subject_interaction=RIM, + lighting_mode="Colored Light", + rim_amplification=0.0, + light_position_y=0.05, + outer_circle_radius=1.5, + shadow_length=0.8, + ) + off = run(flat, shadow_strength=0.0, **common)[0] + on = run(flat, shadow_strength=1.0, **common)[0] + assert not torch.allclose(off, on), "shadow_strength had no effect" + assert off.mean() > on.mean(), "the shadow should remove light, not add it" + + +def test_shadow_length_zero_casts_no_shadow(run): + flat = torch.full((1, 64, 96, 3), 0.25) + mask = _subject_mask() + common = dict( + mask=mask, + subject_interaction=RIM, + lighting_mode="Colored Light", + rim_amplification=0.0, + light_position_y=0.05, + outer_circle_radius=1.5, + shadow_strength=1.0, + ) + none = run(flat, shadow_length=0.0, **common)[0] + some = run(flat, shadow_length=0.8, **common)[0] + assert none.mean() > some.mean() + + +def test_cast_shadow_mask_shadows_only_behind_the_subject(node): + """The tracer itself: a clear line of sight to the light is never shadowed.""" + mask = _subject_mask() + shadow = node.cast_shadow_mask(mask, 0.5, 0.0, shadow_length=1.0) + assert shadow.shape == mask.shape + assert float(shadow.max()) <= 1.0 and float(shadow.min()) >= 0.0 + # Below the subject, looking up at the light, the subject is in the way. + assert float(shadow[0, 56:62, 40:56].mean()) > 0.5 + # Above it, the light is in plain view. + assert float(shadow[0, 0:8, 40:56].mean()) < 0.05 + + +def test_cast_shadow_mask_is_empty_without_a_subject(node): + empty = torch.zeros(1, 64, 96) + assert float(node.cast_shadow_mask(empty, 0.5, 0.0, 0.5).max()) == 0.0 + + +def test_cast_shadow_mask_matches_a_full_resolution_trace(node): + """The tracer downsamples for speed; that must not change what it draws. + + _SHADOW_TRACE_MAX exists to keep a 4K frame affordable. If the shortcut + disagreed with the honest version the saving would not be worth having. + """ + mask = _subject_mask(height=256, width=256, top=64, bottom=160, left=96, right=160) + fast = node.cast_shadow_mask(mask, 0.5, 0.0, shadow_length=0.6) + slow = node.cast_shadow_mask(mask, 0.5, 0.0, shadow_length=0.6, steps=96) + assert float((fast - slow).abs().mean()) < 0.02 + + +def test_cast_shadow_mask_handles_a_batch(node): + masks = torch.cat([_subject_mask(), torch.zeros(1, 64, 96)], dim=0) + shadow = node.cast_shadow_mask(masks, 0.5, 0.0, 0.6) + assert shadow.shape == (2, 64, 96) + assert float(shadow[1].max()) == 0.0 + assert float(shadow[0].max()) > 0.5 + + +# --- finding 3: colored light and correction were mutually exclusive ------- + + +def test_both_mode_differs_from_either_single_mode(run, image): + common = dict( + light_color_r=255, light_color_g=200, light_color_b=120, light_intensity=0.5, + inner_brightness=20.0, inner_saturation=15.0, inner_temperature=20.0, + ) + colored = run(image, lighting_mode="Colored Light", **common)[0] + correction = run(image, lighting_mode="Color Correction", **common)[0] + both = run(image, lighting_mode="Both", **common)[0] + assert not torch.allclose(both, colored) + assert not torch.allclose(both, correction) + + +def test_both_mode_is_the_correction_applied_to_the_colored_result(run, node, image): + """'Both' is defined as colour first, then grade the lit image. + + Chaining two ReLight nodes - colour, then correction - must land in the + same place, or 'Both' means something other than what it says. + """ + common = dict( + light_color_r=255, light_color_g=200, light_color_b=120, light_intensity=0.5, + inner_brightness=20.0, inner_saturation=15.0, inner_temperature=20.0, + ) + both = run(image, lighting_mode="Both", **common)[0] + colored = run(image, lighting_mode="Colored Light", **common)[0] + chained = run(colored, lighting_mode="Color Correction", **common)[0] + assert torch.allclose(both, chained, atol=1e-6) + + +def test_colored_only_mode_is_unchanged_by_the_both_path(run, image): + """The single modes must keep their exact maths; only 'Both' is new.""" + colored = run(image, lighting_mode="Colored Light", light_intensity=0.5)[0] + manual = run(image, lighting_mode="Colored Light", light_intensity=0.5, inner_brightness=99.0)[0] + assert torch.equal(colored, manual), "correction values leaked into Colored Light" + + +def test_correction_only_mode_ignores_the_light_colour(run, image): + plain = run(image, lighting_mode="Color Correction")[0] + coloured_widgets = run(image, lighting_mode="Color Correction", light_color_r=0, light_color_g=255, light_color_b=0, light_intensity=3.0)[0] + assert torch.equal(plain, coloured_widgets) + + +def test_the_three_retuned_presets_use_both_modes(node): + """The audit finding, pinned: these three set a colour and a grading block. + + Before v4.0.0 the two were mutually exclusive, so 12 values in each preset + were inert. The numbers themselves are a visual judgement; this only pins + that both halves are asked for. + """ + for name in ("Warm Sunset Glow", "Cool Blue Moonlight", "Rim Light (Behind)"): + preset = node.PRESETS[name] + assert preset["lighting_mode"] == node.MODE_BOTH, name + assert any(key.startswith(("inner_", "outer_")) for key in preset), name + + # --- presets and lights --------------------------------------------------- @@ -426,7 +744,7 @@ def test_reported_warm_sunset_glow_run(run, image): def test_reported_warm_sunset_glow_run_in_gradient_mode(run, image): """Same report, gradient masks - the path the traceback died on.""" - out_image = run(image, preset="Warm Sunset Glow", use_gradient_mode=True)[0] + out_image = run(image, preset="Warm Sunset Glow", mask_shape="Directional gradient")[0] assert out_image.shape == image.shape assert torch.isfinite(out_image).all() @@ -440,25 +758,25 @@ def test_all_presets_execute_cleanly(node, run, image): assert torch.isfinite(out_image).all(), name -def test_all_light_directions_execute(run, image): +def test_all_subject_interactions_execute(node, run, image): mask = torch.zeros(1, 64, 96) mask[:, 16:48, 24:72] = 1.0 - for direction in ("No Occlusion", "In Front of Subject", "Behind Subject"): - out_image = run(image, mask=mask, light_direction=direction)[0] - assert torch.isfinite(out_image).all(), direction + for interaction in node.SUBJECT_INTERACTIONS: + out_image = run(image, mask=mask, subject_interaction=interaction)[0] + assert torch.isfinite(out_image).all(), interaction def test_multiple_light_sources(run, image): for count in (1, 2, 3): - out_image = run(image, num_light_sources=count, use_colored_lights=True)[0] + out_image = run(image, num_light_sources=count, lighting_mode="Colored Light")[0] assert torch.isfinite(out_image).all() def test_gradient_mode_with_light_at_exact_centre(run, image): out_image = run( image, - use_gradient_mode=True, - use_colored_lights=True, + mask_shape="Directional gradient", + lighting_mode="Colored Light", light_position_x=0.5, light_position_y=0.5, )[0] @@ -477,56 +795,168 @@ def test_zero_radii(run, image): def test_uniform_masks_do_not_crash(run, image): for mask in (torch.ones(1, 64, 96), torch.zeros(1, 64, 96)): - out_image = run(image, mask=mask, light_direction="Behind Subject")[0] + out_image = run(image, mask=mask, subject_interaction="Light behind subject (rim)")[0] assert torch.isfinite(out_image).all() # --- debug image ---------------------------------------------------------- -def test_debug_view_off_is_not_a_black_frame(run, image): +def test_unconnected_debug_view_is_not_a_black_frame(run, image): """A black debug output is indistinguishable from a crashed node. Users wire debug_image to a preview, see solid black and report the node as - broken (it was: the only signal that `show_debug_info` was off was a black + broken (it was: the only signal that the debug view was off was a black rectangle). The placeholder must render something legible instead. """ - debug = run(image, show_debug_info=False)[2] + debug = run(image, debug_output_connected=False)[2] assert debug.shape == (1, 64, 96, 3) - assert debug.max() > 0.05, "debug view with the toggle off is still a black frame" + assert debug.max() > 0.05, "an unconnected debug output is still a black frame" assert debug.max() < 1.01 -def test_debug_view_off_placeholder_is_calmer_than_the_real_view(run, image): +def test_unconnected_debug_placeholder_is_calmer_than_the_real_view(run, image): """The placeholder must not be mistaken for an actual debug visualization.""" - off = run(image, show_debug_info=False)[2] - on = run(image, show_debug_info=True)[2] + off = run(image, debug_output_connected=False)[2] + on = run(image, debug_output_connected=True)[2] assert off.mean() < on.mean() def test_debug_placeholder_falls_back_to_black_when_too_small(run): """Below a line of type there is nowhere to put the message; stay black.""" tiny = torch.rand(1, 8, 8, 3) - debug = run(tiny, show_debug_info=False)[2] + debug = run(tiny, debug_output_connected=False)[2] assert debug.shape == (1, 8, 8, 3) assert float(debug.max()) == 0.0 +#: The colour ``_blank_debug_image`` paints its ground with. +PLACEHOLDER_GROUND = torch.tensor([24 / 255, 24 / 255, 28 / 255]) + + +def _ink_fraction(frame, inset=0): + """Fraction of pixels that differ from the placeholder's flat ground. + + Text coverage, not `max() > 0`. The v3.1.2 placeholder drew 13px type on a + full-resolution canvas: it passed every brightness assertion and was + invisible in a preview, which is how it was reported as a black frame. + """ + region = frame[inset:frame.shape[0] - inset, inset:frame.shape[1] - inset] if inset else frame + return float(((region - PLACEHOLDER_GROUND).abs().sum(-1) > 0.15).float().mean()) + + +def test_debug_placeholder_is_legible_at_a_realistic_resolution(run): + """Measured inside the border at 1344x768, the size people render at. + + Whole-frame ink is not the measurement to make: the border alone can carry + it past a threshold while the type stays invisible, which is how the v3.1.2 + placeholder passed its own tests. This looks only where the text is. + v3.1.2 scores 0.0027 here; this build scores 0.015. + """ + big = torch.zeros(1, 768, 1344, 3) + debug = run(big, debug_output_connected=False)[2][0] + assert _ink_fraction(debug, inset=48) > 0.008, "the placeholder text is not legible" + + +def test_debug_placeholder_still_reads_as_a_panel_in_a_thumbnail(run): + """The reported symptom was a black rectangle in a ComfyUI preview. + + A preview is a thumbnail, so that is what is measured: downscale the + placeholder the way the UI does and check something is still drawn on it. + v3.1.2 scores 0.010 here; this build scores 0.059. + """ + big = torch.zeros(1, 768, 1344, 3) + debug = run(big, debug_output_connected=False)[2] + thumb = torch.nn.functional.interpolate( + debug.permute(0, 3, 1, 2), size=(55, 96), mode="area" + ).permute(0, 2, 3, 1)[0] + assert _ink_fraction(thumb, inset=4) > 0.03, "the placeholder is a dark rectangle in a preview" + + +def test_debug_view_renders_when_the_prompt_consumes_it(node_with_prompt, defaults, image): + """No toggle: something wired to the debug output is the whole trigger. + + This is the path an API caller takes - it never loads the frontend, so + `debug_output_connected` is False and the prompt is the only signal. + """ + prompt = { + "7": {"class_type": "ReLight", "inputs": {}}, + "9": {"class_type": "PreviewImage", "inputs": {"images": ["7", 2]}}, + } + node = node_with_prompt(prompt=prompt, unique_id="7") + debug = tuple(node.execute(image, **defaults(debug_output_connected=False)))[2] + off = tuple(node_with_prompt().execute(image, **defaults(debug_output_connected=False)))[2] + assert debug.mean() > off.mean(), "the debug view was not drawn for a wired output" + + +def test_a_wire_from_another_output_does_not_trigger_the_debug_view(node_with_prompt, defaults, image): + """Slot 0 is the relit image; consuming it must not cost a debug render.""" + prompt = { + "7": {"class_type": "ReLight", "inputs": {}}, + "9": {"class_type": "SaveImage", "inputs": {"images": ["7", 0]}}, + } + node = node_with_prompt(prompt=prompt, unique_id="7") + debug = tuple(node.execute(image, **defaults(debug_output_connected=False)))[2] + off = tuple(node_with_prompt().execute(image, **defaults(debug_output_connected=False)))[2] + assert torch.equal(debug, off) + + +def test_another_nodes_debug_wire_does_not_trigger_ours(node_with_prompt, defaults, image): + """Two ReLight nodes, only one of them wired.""" + prompt = { + "7": {"class_type": "ReLight", "inputs": {}}, + "8": {"class_type": "ReLight", "inputs": {}}, + "9": {"class_type": "PreviewImage", "inputs": {"images": ["8", 2]}}, + } + node = node_with_prompt(prompt=prompt, unique_id="7") + debug = tuple(node.execute(image, **defaults(debug_output_connected=False)))[2] + off = tuple(node_with_prompt().execute(image, **defaults(debug_output_connected=False)))[2] + assert torch.equal(debug, off) + + +def test_the_connectivity_check_tolerates_an_unusable_prompt(node_with_prompt, defaults, image): + """Hidden inputs are absent outside ComfyUI and empty in some internal calls. + + None of these shapes may raise: a render must not fail over a question the + user never asked. + """ + for prompt, unique_id in ( + (None, None), + ({}, "7"), + ({"7": {}}, "7"), + ({"7": {"inputs": None}}, "7"), + ({"7": {"inputs": {"x": ["7"]}}}, "7"), + ("not a dict", "7"), + ({"7": {"class_type": "ReLight", "inputs": {}}}, None), + ): + node = node_with_prompt(prompt=prompt, unique_id=unique_id) + out = tuple(node.execute(image, **defaults(debug_output_connected=False))) + assert out[2].shape == (1, 64, 96, 3), (prompt, unique_id) + + +def test_the_widget_flag_alone_still_renders_the_debug_view(node_with_prompt, defaults, image): + """The UI's flag and the prompt check are independent; either is enough.""" + node = node_with_prompt() + on = tuple(node.execute(image, **defaults(debug_output_connected=True)))[2] + off = tuple(node.execute(image, **defaults(debug_output_connected=False)))[2] + assert on.mean() > off.mean() + + def test_debug_image_matches_image_dimensions(run, image): - debug = run(image, show_debug_info=True)[2] + debug = run(image, debug_output_connected=True)[2] assert debug.shape == (1, 64, 96, 3) def test_debug_image_with_a_batch(run): batch = torch.rand(4, 48, 64, 3) - out_image, _, debug = run(batch, show_debug_info=True) + out_image, _, debug = run(batch, debug_output_connected=True) assert out_image.shape == batch.shape assert debug.shape == (1, 48, 64, 3) def test_debug_image_for_rgba_input(run): rgba = torch.rand(1, 32, 32, 4) - debug = run(rgba, show_debug_info=True)[2] + debug = run(rgba, debug_output_connected=True)[2] assert debug.shape == (1, 32, 32, 3) @@ -536,7 +966,7 @@ def test_debug_image_for_rgba_input(run): def test_wide_and_tall_images(run): for shape in ((1, 16, 256, 3), (1, 256, 16, 3), (1, 1, 1, 3)): img = torch.rand(*shape) - out_image = run(img, use_colored_lights=True)[0] + out_image = run(img, lighting_mode="Colored Light")[0] assert out_image.shape == img.shape @@ -623,8 +1053,8 @@ def test_single_channel_image_keeps_its_channel_count(run): gray = torch.rand(1, 32, 32, 1) for overrides in ( {}, - {"use_colored_lights": True}, - {"use_colored_lights": True, "use_gradient_mode": True}, + {"lighting_mode": "Colored Light"}, + {"lighting_mode": "Colored Light", "mask_shape": "Directional gradient"}, ): out_image = run(gray, **overrides)[0] assert out_image.shape == gray.shape, overrides @@ -632,7 +1062,7 @@ def test_single_channel_image_keeps_its_channel_count(run): def test_debug_image_is_rgb_for_a_single_channel_image(run): gray = torch.rand(1, 32, 32, 1) - debug = run(gray, show_debug_info=True)[2] + debug = run(gray, debug_output_connected=True)[2] assert debug.shape == (1, 32, 32, 3) assert debug.abs().max() > 0.0, "grayscale input used to fall through to a black debug view" @@ -744,3 +1174,53 @@ def test_preset_values_stay_within_widget_bounds(node): if (low is not None and value < low) or (high is not None and value > high): offenders.append((preset_name, key, value, low, high)) assert offenders == [] + + +def test_a_light_at_the_right_edge_keeps_its_label_on_the_frame(run): + """A rim light sits at x=0.9, and its "L1" ran off the right edge. + + The label is drawn to the right of the marker, so a light near the right + edge pushed the text past `width` and PIL silently clipped it - visible + only in a render, which is how it survived to v4.0.0. It now flips to the + left of the marker instead. + + Measured as the side of the marker the label's black backing box lands on, + in a band across the marker's own row. The frame is deliberately *white*: + on a black one the box is invisible against the input and every threshold + measures the input instead of the label, which is how the first version of + this test passed with the bug still in place. + """ + height, width = 768, 1344 + white = torch.ones(1, height, width, 3) + marker_y = height // 2 + + def label_sides(position_x): + frame = run( + white, + debug_output_connected=True, + light_position_x=position_x, + light_position_y=0.5, + )[2][0] + marker_x = int(position_x * width) + band = frame[marker_y - 40:marker_y + 40] + dark = band.sum(dim=-1) < 0.9 + return int(dark[:, :marker_x].sum()), int(dark[:, marker_x:].sum()) + + # A light with room to its right keeps the label on the right, as always. + left, right = label_sides(0.5) + assert right > left, "the label is not drawn to the right of a centred marker" + + # A light hard against the right edge must flip it, not clip it. + left, right = label_sides(0.97) + assert left > right, ( + "the label stayed on the right of a marker near the right edge, " + "so it was clipped against the frame instead of flipping" + ) + + +def test_a_light_in_the_corner_keeps_its_label_on_the_frame(run): + """Both axes clamp: a marker in a corner can run a glyph off the top too.""" + big = torch.zeros(1, 768, 1344, 3) + corner = run(big, debug_output_connected=True, light_position_x=0.99, light_position_y=0.01)[2] + assert corner.shape == (1, 768, 1344, 3) + assert torch.isfinite(corner).all() diff --git a/web/relight_debug.js b/web/relight_debug.js new file mode 100644 index 0000000..b491761 --- /dev/null +++ b/web/relight_debug.js @@ -0,0 +1,139 @@ +import { app } from "../../scripts/app.js"; + +/* + * ReLight debug view: no toggle, just the wire. + * + * The rule from the user's point of view is the whole feature: connect + * `debug_image` to a preview and you get the debug visualization; don't, and + * you don't. There is nothing to remember, nothing to switch on, and no way to + * have the output wired and a placeholder on screen. + * + * Making that true needs one piece of plumbing, because of how ComfyUI decides + * a node needs re-running. A node's cache key is built from its INPUTS. Wiring + * something to an OUTPUT does not change any input, so the graph would happily + * replay ReLight's cached result - the placeholder - into the preview you just + * connected, and you would have to hit Run twice for no visible reason. + * + * So the node carries a `debug_output_connected` boolean input that this module + * owns end to end: it hides the widget (nobody should see it), and it writes + * true/false into it whenever the debug output's connections change. That write + * is a widget-value change, which is exactly what the cache watches, so the + * node re-runs on the same queue that added the wire. + * + * The Python side has a second, independent check on the submitted prompt, so + * an API caller that never loads this file still gets the debug view when it + * consumes the output. This module is what makes it feel immediate in the UI. + */ + +const NODE_CLASS = "ReLight"; +const FLAG_WIDGET = "debug_output_connected"; +const DEBUG_OUTPUT = "debug_image"; + +function warn(message, error) { + console.warn("[ReLight] " + message, error || ""); +} + +function isReLight(node) { + const type = node && (node.comfyClass || node.type); + return type === NODE_CLASS; +} + +function flagWidget(node) { + return (node.widgets ?? []).find((widget) => widget.name === FLAG_WIDGET); +} + +/** + * Hide a widget from the node body without removing it. + * + * Both halves are needed: older LiteGraph skips widgets whose `type` it does + * not know, newer frontends honour `hidden`. Setting only one leaves the widget + * painted on some versions. `computeSize` is stubbed to zero so the node does + * not reserve a row for it. + */ +function hideWidget(node, widget) { + widget.hidden = true; + if (!widget.type?.startsWith?.("hidden-")) { + widget.type = "hidden-" + (widget.type ?? "toggle"); + } + widget.computeSize = () => [0, -4]; +} + +/** + * Run `fn` once the current frame has settled. + * + * `requestAnimationFrame` is a browser global, so it is absent under + * `node --test` - and a bare call there throws a ReferenceError that takes the + * rest of `nodeCreated` with it. Fall back to a macrotask. + */ +function deferToNextFrame(fn) { + if (typeof requestAnimationFrame === "function") requestAnimationFrame(fn); + else setTimeout(fn, 0); +} + +/** Does the debug output have at least one link? */ +function debugOutputIsConnected(node) { + const output = (node.outputs ?? []).find((slot) => slot.name === DEBUG_OUTPUT); + return Boolean(output?.links?.length); +} + +/** + * Push the current wiring into the flag widget. + * + * Writes only on an actual change: an unconditional write on every draw would + * dirty the graph continuously and mark the workflow modified for no reason. + */ +export function syncDebugFlag(node) { + const widget = flagWidget(node); + if (!widget) return false; + const connected = debugOutputIsConnected(node); + if (widget.value === connected) return false; + widget.value = connected; + return true; +} + +app.registerExtension({ + name: "ReLight.debugView", + + async nodeCreated(node) { + try { + if (!isReLight(node)) return; + + const widget = flagWidget(node); + if (widget) hideWidget(node, widget); + + // An own property on the instance rather than another prototype + // wrapper, so we see the finished state no matter which extension + // loaded last. + const inherited = node.onConnectionsChange; + node.onConnectionsChange = function (...args) { + let result; + if (typeof inherited === "function") { + try { + result = inherited.apply(this, args); + } catch (error) { + warn("an upstream onConnectionsChange handler failed", error); + } + } + try { + syncDebugFlag(this); + } catch (error) { + warn("could not track the debug output connection", error); + } + return result; + }; + + // Connections are restored after nodeCreated when a workflow loads, + // and onConnectionsChange is not always called for those, so take + // one reading once the graph has settled. + deferToNextFrame(() => { + try { + syncDebugFlag(node); + } catch (error) { + warn("could not read the debug output connection on load", error); + } + }); + } catch (error) { + warn("debug view setup failed", error); + } + }, +}); diff --git a/web/relight_migrate.js b/web/relight_migrate.js new file mode 100644 index 0000000..d2402dc --- /dev/null +++ b/web/relight_migrate.js @@ -0,0 +1,274 @@ +import { app } from "../../scripts/app.js"; + +/* + * ReLight legacy-workflow migration. + * + * Why this file exists + * -------------------- + * LiteGraph stores a node's widget values as a bare positional array. Up to + * v3.1.2 ReLight had 48 widgets in one flat order; v4.0.0 regrouped them, + * replaced four booleans with named combos, and added two shadow controls. + * Loading a pre-v4 workflow against the v4 schema therefore lands every value + * from the first changed index onward on the wrong widget - silently, with no + * error, which is worse than a crash because the graph still runs. + * + * `onConfigure` runs after LiteGraph has assigned those values, so this module + * detects a legacy array and rewrites the widgets by NAME, which is + * order-independent by construction. Widgets that did not exist in v3.1.2 are + * reset to their schema default (they are currently holding a shifted legacy + * value). + * + * Ported from EA_LMStudio/web/ea_lmstudio.js (`migrateLegacyWidgetValues`, + * `growToFitWidgets`) with ReLight's four boolean -> combo translations added. + * + * The option strings below are the saved-workflow format. They are mirrored in + * relight.py (`ReLight.LIGHTING_MODES` and friends) and pinned in + * tests/test_relight.py - change one and you must change all three. + */ + +const NODE_CLASS = "ReLight"; + +/* + * Widget order exactly as v3.1.2 serialised it, in LiteGraph order. IMAGE and + * MASK are link inputs, not widgets, so they never appear here. + * + * Pinned as data in tests/test_relight.py (`LEGACY_WIDGET_ORDER`), which reads + * this file and compares - the two cannot drift apart without going red. + */ +export const LEGACY_ORDER = [ + "preset", + "num_light_sources", + "preserve_positioning", + "show_debug_info", + "use_colored_lights", + "use_gradient_mode", + "apply_3d_lighting", + "light_direction", + "remove_background", + "effect_strength", + "mask_blur", + "rim_amplification", + "light_position_x", + "light_position_y", + "inner_circle_radius", + "outer_circle_radius", + "light_color_r", + "light_color_g", + "light_color_b", + "light_intensity", + "inner_brightness", + "inner_contrast", + "inner_saturation", + "inner_temperature", + "inner_tint", + "inner_gamma", + "outer_brightness", + "outer_contrast", + "outer_saturation", + "outer_temperature", + "outer_tint", + "outer_gamma", + "light2_position_x", + "light2_position_y", + "light2_inner_radius", + "light2_outer_radius", + "light2_color_r", + "light2_color_g", + "light2_color_b", + "light2_intensity", + "light3_position_x", + "light3_position_y", + "light3_inner_radius", + "light3_outer_radius", + "light3_color_r", + "light3_color_g", + "light3_color_b", + "light3_intensity", +]; + +/** v3.1.2 preset names. Index 0 of a legacy array is always one of these. */ +export const LEGACY_PRESETS = [ + "None", + "Soft Window Light", + "Dramatic Side Light", + "Warm Sunset Glow", + "Cool Blue Moonlight", + "Studio Key Light", + "Rim Light (Behind)", + "Spotlight", + "Negative Light (Darken)", +]; + +/** v3.1.2 `light_direction` values. Index 7 of a legacy array is one of these. */ +export const LEGACY_LIGHT_DIRECTIONS = [ + "Behind Subject", + "In Front of Subject", + "No Occlusion", +]; + +// v4.0.0 combo values. Kept as constants so the translations below read as a +// mapping rather than as string literals scattered through branches. +const MODE_CORRECTION = "Color Correction"; +const MODE_COLORED = "Colored Light"; +const SHAPE_RADIAL = "Radial falloff"; +const SHAPE_GRADIENT = "Directional gradient"; +const SUBJECT_NONE = "None"; +const SUBJECT_FRONT = "Light in front of subject"; +const SUBJECT_RIM = "Light behind subject (rim)"; + +function warn(message, error) { + console.warn("[ReLight] " + message, error || ""); +} + +/** + * Collect each input's declared default, so a widget that did not exist in + * v3.1.2 can be reset after the remap instead of keeping whatever legacy value + * happened to land at its index. + */ +export function collectDefaults(nodeData) { + const defaults = {}; + for (const group of ["required", "optional"]) { + const inputs = nodeData?.input?.[group] ?? {}; + for (const [name, spec] of Object.entries(inputs)) { + const [type, options] = Array.isArray(spec) ? spec : [spec, undefined]; + if (options && Object.prototype.hasOwnProperty.call(options, "default")) { + defaults[name] = options.default; + } else if (Array.isArray(type)) { + defaults[name] = type[0]; // combo: first entry + } + } + } + return defaults; +} + +/** + * Translate the four v3.1.2 booleans into the v4.0.0 combos. + * + * `legacy` is the legacy array keyed by name. Returns the values for the new + * widgets only; every other widget keeps its legacy value under the same name. + */ +export function translateLegacyModes(legacy) { + const translated = {}; + + // use_colored_lights was the whole mode switch: on meant additive colour + // and the entire inner_*/outer_* grading block was ignored. Migrating to + // "Both" would therefore change the render of every colour workflow, so a + // legacy save maps to the mode that matches what it actually produced. + translated.lighting_mode = legacy.use_colored_lights ? MODE_COLORED : MODE_CORRECTION; + + translated.mask_shape = legacy.use_gradient_mode ? SHAPE_GRADIENT : SHAPE_RADIAL; + + // apply_3d_lighting was a master switch whose only effect was to force + // "No Occlusion", so it collapses into the direction it was gating. + if (!legacy.apply_3d_lighting) { + translated.subject_interaction = SUBJECT_NONE; + } else if (legacy.light_direction === "Behind Subject") { + translated.subject_interaction = SUBJECT_RIM; + } else if (legacy.light_direction === "In Front of Subject") { + translated.subject_interaction = SUBJECT_FRONT; + } else { + translated.subject_interaction = SUBJECT_NONE; + } + + // show_debug_info has no v4 counterpart and is deliberately dropped: the + // debug view now follows the wiring of the debug_image output, which + // relight_debug.js keeps `debug_output_connected` in step with. Whatever + // the old toggle said, the connection is the truth. + + return translated; +} + +/** + * Is this widgets_values array a v3.1.2 (or earlier) save? + * + * Length alone is not enough - v4 has 49 widgets, but a v4 node with one widget + * converted to an input socket would serialise 48 values. So the length test is + * backed by two sentinels that a v4 array cannot satisfy: index 0 must be a + * v3.1.2 preset name, and index 7 must be one of the three legacy + * `light_direction` strings (in v4, index 7 is the numeric effect_strength). + */ +export function looksLegacy(widgetValues) { + if (!Array.isArray(widgetValues)) return false; + if (widgetValues.length !== LEGACY_ORDER.length) return false; + if (!LEGACY_PRESETS.includes(widgetValues[LEGACY_ORDER.indexOf("preset")])) return false; + if (!LEGACY_LIGHT_DIRECTIONS.includes(widgetValues[LEGACY_ORDER.indexOf("light_direction")])) { + return false; + } + return true; +} + +/** + * Repair widget values loaded from a pre-v4 workflow. + * + * Returns true when a remap happened, false when the array was left alone. + */ +export function migrateLegacyWidgetValues(node, widgetValues, defaults) { + if (!looksLegacy(widgetValues)) return false; + + const legacy = {}; + LEGACY_ORDER.forEach((name, index) => { + legacy[name] = widgetValues[index]; + }); + const translated = translateLegacyModes(legacy); + + for (const widget of node.widgets ?? []) { + const name = widget?.name; + if (!name) continue; + if (Object.prototype.hasOwnProperty.call(translated, name)) { + widget.value = translated[name]; + } else if (Object.prototype.hasOwnProperty.call(legacy, name)) { + widget.value = legacy[name]; + } else if (Object.prototype.hasOwnProperty.call(defaults, name)) { + // New in v4.0.0 (shadow_strength, shadow_length) - right now it is + // holding a shifted legacy value. + widget.value = defaults[name]; + } + } + + // No toast on purpose: the realignment is silent housekeeping, and a banner + // on every legacy workflow load is noise users just click away. + return true; +} + +/** + * Grow a node stored smaller than its widgets need. + * + * A workflow stores the node's size and LiteGraph restores it verbatim, without + * re-checking that the widgets still fit. v4.0.0 has one more widget than + * v3.1.2, so every upgraded workflow is a row short and the last widget draws + * outside the frame. Only ever grows, so a deliberately widened node survives. + */ +export function growToFitWidgets(node) { + try { + const [minWidth, minHeight] = node.computeSize(); + if (node.size[0] < minWidth || node.size[1] < minHeight) { + node.setSize([ + Math.max(node.size[0], minWidth), + Math.max(node.size[1], minHeight), + ]); + } + } catch (error) { + warn("could not resize node to fit widgets:", error); + } +} + +app.registerExtension({ + name: "ReLight.migrate", + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== NODE_CLASS) return; + + const defaults = collectDefaults(nodeData); + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + onConfigure?.apply(this, arguments); + try { + migrateLegacyWidgetValues(this, info?.widgets_values, defaults); + } catch (error) { + warn("legacy workflow migration failed:", error); + } + growToFitWidgets(this); + }; + }, +}); diff --git a/web/relight_presets.js b/web/relight_presets.js new file mode 100644 index 0000000..cb574d0 --- /dev/null +++ b/web/relight_presets.js @@ -0,0 +1,409 @@ +/* + * GENERATED by scripts/dump_frontend_fixture.py - do not edit. + * + * Which widgets each preset overrides, and what it sets them to. + * relight_ui.js greys exactly these while a preset is selected, because a + * control a preset has taken over is one whose value on the node is being + * ignored - and a widget you can still drag that changes nothing is worse + * than one that looks unavailable. + * + * The values are here because a greyed widget cannot show its own: the + * ComfyUI frontend blanks `_displayValue` for any disabled widget. Without + * them a greyed row is an empty bar. relight_ui.js writes the preset's + * value into the widget's label instead, which still paints. + * + * Derived from ReLight.PRESETS so it cannot drift; the --check run in + * tests/test_relight.py fails if this file is stale. + */ + +export const PRESET_KEYS = { + "None": [], + "Soft Window Light": [ + "inner_brightness", + "inner_circle_radius", + "inner_contrast", + "inner_gamma", + "inner_saturation", + "inner_temperature", + "inner_tint", + "light_position_x", + "light_position_y", + "mask_blur", + "outer_brightness", + "outer_circle_radius", + "outer_contrast", + "outer_gamma", + "outer_saturation", + "outer_temperature", + "outer_tint", + "rim_amplification" + ], + "Dramatic Side Light": [ + "inner_brightness", + "inner_circle_radius", + "inner_contrast", + "inner_gamma", + "inner_saturation", + "inner_temperature", + "inner_tint", + "light_position_x", + "light_position_y", + "mask_blur", + "outer_brightness", + "outer_circle_radius", + "outer_contrast", + "outer_gamma", + "outer_saturation", + "outer_temperature", + "outer_tint", + "rim_amplification" + ], + "Warm Sunset Glow": [ + "inner_brightness", + "inner_circle_radius", + "inner_contrast", + "inner_gamma", + "inner_saturation", + "inner_temperature", + "inner_tint", + "light_color_b", + "light_color_g", + "light_color_r", + "light_intensity", + "light_position_x", + "light_position_y", + "lighting_mode", + "mask_blur", + "mask_shape", + "outer_brightness", + "outer_circle_radius", + "outer_contrast", + "outer_gamma", + "outer_saturation", + "outer_temperature", + "outer_tint", + "rim_amplification" + ], + "Cool Blue Moonlight": [ + "inner_brightness", + "inner_circle_radius", + "inner_contrast", + "inner_gamma", + "inner_saturation", + "inner_temperature", + "inner_tint", + "light_color_b", + "light_color_g", + "light_color_r", + "light_intensity", + "light_position_x", + "light_position_y", + "lighting_mode", + "mask_blur", + "outer_brightness", + "outer_circle_radius", + "outer_contrast", + "outer_gamma", + "outer_saturation", + "outer_temperature", + "outer_tint", + "rim_amplification" + ], + "Studio Key Light": [ + "inner_brightness", + "inner_circle_radius", + "inner_contrast", + "inner_gamma", + "inner_saturation", + "inner_temperature", + "inner_tint", + "light_position_x", + "light_position_y", + "mask_blur", + "outer_brightness", + "outer_circle_radius", + "outer_contrast", + "outer_gamma", + "outer_saturation", + "outer_temperature", + "outer_tint", + "rim_amplification" + ], + "Rim Light (Behind)": [ + "effect_strength", + "inner_brightness", + "inner_circle_radius", + "inner_contrast", + "inner_gamma", + "inner_saturation", + "inner_temperature", + "inner_tint", + "light_color_b", + "light_color_g", + "light_color_r", + "light_intensity", + "light_position_x", + "light_position_y", + "lighting_mode", + "mask_blur", + "outer_brightness", + "outer_circle_radius", + "outer_contrast", + "outer_gamma", + "outer_saturation", + "outer_temperature", + "outer_tint", + "rim_amplification", + "shadow_length", + "shadow_strength", + "subject_interaction" + ], + "Spotlight": [ + "effect_strength", + "inner_brightness", + "inner_circle_radius", + "inner_contrast", + "inner_gamma", + "inner_saturation", + "inner_temperature", + "inner_tint", + "light_position_x", + "light_position_y", + "mask_blur", + "outer_brightness", + "outer_circle_radius", + "outer_contrast", + "outer_gamma", + "outer_saturation", + "outer_temperature", + "outer_tint", + "rim_amplification" + ], + "Negative Light (Darken)": [ + "effect_strength", + "inner_brightness", + "inner_circle_radius", + "inner_contrast", + "inner_gamma", + "inner_saturation", + "inner_temperature", + "inner_tint", + "light_position_x", + "light_position_y", + "mask_blur", + "outer_brightness", + "outer_circle_radius", + "outer_contrast", + "outer_gamma", + "outer_saturation", + "outer_temperature", + "outer_tint", + "rim_amplification" + ] +}; + +/** What each preset sets those widgets to, for the greyed-out labels. */ +export const PRESET_VALUES = { + "None": {}, + "Soft Window Light": { + "inner_brightness": 10, + "inner_circle_radius": 0.45, + "inner_contrast": 0, + "inner_gamma": 1.0, + "inner_saturation": 0, + "inner_temperature": -5, + "inner_tint": 0, + "light_position_x": 0.7, + "light_position_y": 0.3, + "mask_blur": 80, + "outer_brightness": -15, + "outer_circle_radius": 0.8, + "outer_contrast": 0, + "outer_gamma": 0.83, + "outer_saturation": -10, + "outer_temperature": -5, + "outer_tint": 0, + "rim_amplification": 1.0 + }, + "Dramatic Side Light": { + "inner_brightness": 15, + "inner_circle_radius": 0.3, + "inner_contrast": 10, + "inner_gamma": 1.11, + "inner_saturation": 10, + "inner_temperature": 0, + "inner_tint": 0, + "light_position_x": 0.1, + "light_position_y": 0.5, + "mask_blur": 50, + "outer_brightness": -30, + "outer_circle_radius": 0.6, + "outer_contrast": 10, + "outer_gamma": 0.77, + "outer_saturation": -20, + "outer_temperature": 0, + "outer_tint": 0, + "rim_amplification": 1.0 + }, + "Warm Sunset Glow": { + "inner_brightness": 3, + "inner_circle_radius": 0.5, + "inner_contrast": 5, + "inner_gamma": 1.05, + "inner_saturation": 10, + "inner_temperature": 25, + "inner_tint": -5, + "light_color_b": 120, + "light_color_g": 200, + "light_color_r": 255, + "light_intensity": 0.45, + "light_position_x": 0.9, + "light_position_y": 0.4, + "lighting_mode": "Both", + "mask_blur": 75, + "mask_shape": "Directional gradient", + "outer_brightness": -10, + "outer_circle_radius": 0.8, + "outer_contrast": 0, + "outer_gamma": 0.91, + "outer_saturation": -5, + "outer_temperature": 15, + "outer_tint": -5, + "rim_amplification": 1.0 + }, + "Cool Blue Moonlight": { + "inner_brightness": -5, + "inner_circle_radius": 0.4, + "inner_contrast": 5, + "inner_gamma": 0.91, + "inner_saturation": -5, + "inner_temperature": -20, + "inner_tint": 0, + "light_color_b": 255, + "light_color_g": 150, + "light_color_r": 120, + "light_intensity": 0.35, + "light_position_x": 0.8, + "light_position_y": 0.2, + "lighting_mode": "Both", + "mask_blur": 60, + "outer_brightness": -20, + "outer_circle_radius": 0.7, + "outer_contrast": 0, + "outer_gamma": 0.83, + "outer_saturation": -10, + "outer_temperature": -30, + "outer_tint": 0, + "rim_amplification": 1.0 + }, + "Studio Key Light": { + "inner_brightness": 12, + "inner_circle_radius": 0.6, + "inner_contrast": 5, + "inner_gamma": 1.0, + "inner_saturation": 0, + "inner_temperature": 0, + "inner_tint": 0, + "light_position_x": 0.4, + "light_position_y": 0.3, + "mask_blur": 90, + "outer_brightness": -5, + "outer_circle_radius": 0.9, + "outer_contrast": 0, + "outer_gamma": 0.91, + "outer_saturation": -5, + "outer_temperature": 0, + "outer_tint": 0, + "rim_amplification": 1.0 + }, + "Rim Light (Behind)": { + "effect_strength": 1.5, + "inner_brightness": 0, + "inner_circle_radius": 0.3, + "inner_contrast": 8, + "inner_gamma": 1.0, + "inner_saturation": 12, + "inner_temperature": 0, + "inner_tint": 0, + "light_color_b": 255, + "light_color_g": 255, + "light_color_r": 255, + "light_intensity": 1.0, + "light_position_x": 0.5, + "light_position_y": 0.1, + "lighting_mode": "Both", + "mask_blur": 25, + "outer_brightness": 0, + "outer_circle_radius": 0.6, + "outer_contrast": 0, + "outer_gamma": 1.0, + "outer_saturation": 0, + "outer_temperature": 0, + "outer_tint": 0, + "rim_amplification": 2.5, + "shadow_length": 0.45, + "shadow_strength": 0.7, + "subject_interaction": "Light behind subject (rim)" + }, + "Spotlight": { + "effect_strength": 1.2, + "inner_brightness": 25, + "inner_circle_radius": 0.1, + "inner_contrast": 15, + "inner_gamma": 1.11, + "inner_saturation": -5, + "inner_temperature": 0, + "inner_tint": 0, + "light_position_x": 0.5, + "light_position_y": 0.4, + "mask_blur": 30, + "outer_brightness": -40, + "outer_circle_radius": 0.25, + "outer_contrast": 10, + "outer_gamma": 0.77, + "outer_saturation": -20, + "outer_temperature": 0, + "outer_tint": 0, + "rim_amplification": 1.0 + }, + "Negative Light (Darken)": { + "effect_strength": 1.0, + "inner_brightness": -20, + "inner_circle_radius": 0.4, + "inner_contrast": 5, + "inner_gamma": 0.91, + "inner_saturation": -5, + "inner_temperature": 0, + "inner_tint": 0, + "light_position_x": 0.5, + "light_position_y": 0.5, + "mask_blur": 60, + "outer_brightness": 0, + "outer_circle_radius": 0.7, + "outer_contrast": 0, + "outer_gamma": 1.0, + "outer_saturation": 0, + "outer_temperature": 0, + "outer_tint": 0, + "rim_amplification": 1.0 + } +}; + +/** Widgets a preset may name but that `preserve_positioning` hands back. */ +export const GEOMETRY_KEYS = [ + "inner_circle_radius", + "light2_inner_radius", + "light2_outer_radius", + "light2_position_x", + "light2_position_y", + "light3_inner_radius", + "light3_outer_radius", + "light3_position_x", + "light3_position_y", + "light_position_x", + "light_position_y", + "outer_circle_radius" +]; + +/** The widget a preset scales rather than replaces. */ +export const STRENGTH_KEY = "effect_strength"; diff --git a/web/relight_recreate.js b/web/relight_recreate.js new file mode 100644 index 0000000..44a4d17 --- /dev/null +++ b/web/relight_recreate.js @@ -0,0 +1,291 @@ +import { app } from "../../scripts/app.js"; + +/* + * A working "Fix node (recreate)" for ReLight. + * + * Why this file exists + * -------------------- + * "Fix node (recreate)" is not a ComfyUI feature. It is contributed by + * ComfyUI-Manager (js/node_fixer.js), and its implementation throws before it + * finishes: + * + * TypeError: t.findInputSlot is not a function + * at LGraphNode.connect + * at node_info_copy (node_fixer.js, reconnecting the inputs) + * + * Node ids are strings now. Manager calls + * `src_node.connect(origin_slot, dest.id, input_name)`, and `connect` only + * resolves its second argument to a node when that argument is a *number*. A + * string id sails past the lookup and `connect` then calls `findInputSlot` on + * it. + * + * The callback creates the replacement node first and removes the original + * last, so the exception leaves both on the canvas: the original still wired + * up, the replacement unconnected and sitting on top of it. That is the whole + * of the reported duplicate-node bug, and it is not specific to this pack - it + * happens to any node with a connected input, in any pack. Filed upstream as + * Comfy-Org/ComfyUI-Manager#3126. + * + * The fix is to stop relying on that code for our own nodes. This module + * installs a correct recreate and takes the broken entry out of the menu, for + * ReLight instances only. Nothing global is patched and no other pack's nodes + * are touched. Ported from comfyui-identity-forge's + * js/identity_forge_recreate.js. + * + * Three rules make a recreate correct, and each of them is a bug avoided: + * + * 1. Pass the node OBJECT to connect(), never its id. Ids are strings. + * 2. Restore widget values by NAME, never by index. Recreating a node is what + * you do *because* the schema changed, so an index-based copy writes every + * value into the wrong widget from the first added or removed input on - + * which for v4.0.0 is index 1. + * 3. Reconnect links by SLOT NAME, never by slot number, for the same reason. + * + * The whole thing is wrapped so that a failure leaves the graph exactly as it + * was rather than half-rebuilt. + */ + +const EXT_NAME = "ReLight.recreate"; +const MENU_LABEL = "Fix node (recreate)"; +const NODE_CLASS = "ReLight"; + +function warn(message, error) { + console.warn("[" + EXT_NAME + "] " + message, error || ""); +} + +function isReLight(node) { + const type = node && (node.comfyClass || (node.constructor && node.constructor.type)); + return type === NODE_CLASS; +} + +/** Resolve a link id to its link record across frontend versions. */ +function getLink(graph, linkId) { + if (linkId == null) return null; + try { + if (typeof graph.getLink === "function") return graph.getLink(linkId); + } catch (_) { + // fall through to the map/proxy form + } + try { + return graph.links[linkId] || null; + } catch (_) { + return null; + } +} + +/** + * Record every link touching *node*, keyed by slot name. + * + * Names, not numbers: the point of recreating a node is that its schema moved, + * so slot 2 before is not slot 2 after. + */ +export function snapshotLinks(node) { + const graph = node.graph; + const inputs = []; + const outputs = []; + + for (const input of node.inputs || []) { + const link = getLink(graph, input.link); + if (!link) continue; + const origin = graph.getNodeById(link.origin_id); + if (!origin) continue; + inputs.push({ name: input.name, origin, originSlot: link.origin_slot }); + } + + for (const output of node.outputs || []) { + for (const linkId of output.links || []) { + const link = getLink(graph, linkId); + if (!link) continue; + const target = graph.getNodeById(link.target_id); + if (!target) continue; + outputs.push({ name: output.name, target, targetSlot: link.target_slot }); + } + } + + return { inputs, outputs }; +} + +/** The selectable values of a combo widget, or null if it is not one. */ +function comboValues(widget) { + const values = widget && widget.options && widget.options.values; + if (typeof values === "function") { + try { + return values(widget) || null; + } catch (_) { + return null; + } + } + return Array.isArray(values) ? values : null; +} + +/** + * Copy widget values from *from* to *to*, matched by name. + * + * A value that no longer exists in a combo's options is dropped rather than + * forced, because writing an invalid value onto a widget produces a node that + * fails prompt validation with a message about a value the user never chose. + * The fresh node's default is the honest result, and the dropped names are + * returned so the caller can say what happened. + * + * The `hidden-` type prefix comes from relight_ui.js, which hides a widget by + * swapping its type; those still carry real values and must be copied. + */ +export function copyWidgetValues(from, to) { + const dropped = []; + const target = new Map(); + for (const widget of to.widgets || []) { + if (widget && widget.name) target.set(widget.name, widget); + } + + for (const widget of from.widgets || []) { + if (!widget || !widget.name) continue; + if (widget.type === "button" || widget.serialize === false) continue; + const match = target.get(widget.name); + if (!match) { + dropped.push(widget.name); + continue; + } + const values = comboValues(match); + if (values && !values.includes(widget.value)) { + dropped.push(widget.name); + continue; + } + match.value = widget.value; + } + return dropped; +} + +/** + * Replace *node* with a fresh instance of the same type, keeping its position, + * size, title, colours, widget values and every link. + */ +export function recreateNode(node) { + const graph = node.graph || app.graph; + const LiteGraph = window.LiteGraph; + if (!graph || !LiteGraph) return false; + + const type = node.comfyClass || node.type; + const fresh = LiteGraph.createNode(type); + if (!fresh) { + warn("could not create a replacement node of type " + type); + return false; + } + + const links = snapshotLinks(node); + + fresh.pos = [node.pos[0], node.pos[1]]; + if (node.title) fresh.title = node.title; + if (node.color) fresh.color = node.color; + if (node.bgcolor) fresh.bgcolor = node.bgcolor; + graph.add(fresh); + + const dropped = copyWidgetValues(node, fresh); + + // Size after adding, so the node's own layout has already run and we are + // widening rather than fighting it. Height is left to relight_ui.js, which + // fits the node to whatever the restored values leave visible. + if (node.size) { + fresh.setSize([Math.max(node.size[0], fresh.size[0]), fresh.size[1]]); + } + + // Remove the original before reconnecting. An input holds one link, so + // reconnecting first would fight the link that is still attached. + graph.remove(node); + + // A link whose slot the fresh node doesn't expose - most commonly a widget + // the user converted to an input socket, which a freshly created node + // always reverts to a plain widget. Silently dropping it would leave a + // graph that looks fixed but has quietly lost a connection; name it. + const unresolvedLinks = []; + for (const link of links.inputs) { + const slot = fresh.findInputSlot(link.name); + // The node object, never its id. Ids are strings, and connect() only + // resolves numbers. + if (slot >= 0) link.origin.connect(link.originSlot, fresh, slot); + else unresolvedLinks.push(link.name); + } + for (const link of links.outputs) { + const slot = fresh.findOutputSlot(link.name); + if (slot >= 0) fresh.connect(slot, link.target, link.targetSlot); + else unresolvedLinks.push(link.name); + } + + if (unresolvedLinks.length) { + warn( + "recreated " + type + ", but these connections could not be restored " + + "-- the fresh node no longer exposes a matching input/output slot " + + "(likely a widget that was converted to a socket, which a fresh node " + + "always reverts to a plain widget): " + unresolvedLinks.join(", ") + ); + } + + if (dropped.length) { + warn( + "recreated " + type + ", but these widgets no longer exist or no " + + "longer accept their saved value and were left at their default: " + + dropped.join(", ") + ); + } + + if (typeof graph.afterChange === "function") graph.afterChange(); + if (app.canvas) app.canvas.setDirty(true, true); + return true; +} + +/** + * Drop any other pack's recreate entry and add ours in its place. + * + * Same label, so the entry stays where the muscle memory expects it. + */ +export function replaceRecreateOption(node, options) { + for (let index = options.length - 1; index >= 0; index -= 1) { + const entry = options[index]; + if (entry && entry.content === MENU_LABEL) options.splice(index, 1); + } + options.push({ + content: MENU_LABEL, + callback: () => { + try { + recreateNode(node); + } catch (error) { + warn("recreate failed; the graph was left unchanged", error); + } + }, + }); +} + +app.registerExtension({ + name: EXT_NAME, + + async nodeCreated(node) { + try { + if (!isReLight(node)) return; + + // An own property on the instance, not another prototype wrapper. + // Every pack that contributes menu entries does it by wrapping the + // prototype, and the last wrapper installed runs its own additions + // last. An instance property shadows the whole prototype chain, so + // we see the finished option list no matter what order the + // extensions loaded in. + const inherited = node.getExtraMenuOptions; + node.getExtraMenuOptions = function (canvas, options) { + let result; + if (typeof inherited === "function") { + try { + result = inherited.call(this, canvas, options); + } catch (error) { + warn("an upstream menu handler failed", error); + } + } + try { + replaceRecreateOption(this, options); + } catch (error) { + warn("could not install the recreate menu entry", error); + } + return result; + }; + } catch (error) { + warn("menu setup failed", error); + } + }, +}); diff --git a/web/relight_ui.js b/web/relight_ui.js new file mode 100644 index 0000000..e6850b6 --- /dev/null +++ b/web/relight_ui.js @@ -0,0 +1,330 @@ +import { app } from "../../scripts/app.js"; +import { GEOMETRY_KEYS, PRESET_KEYS, PRESET_VALUES, STRENGTH_KEY } from "./relight_presets.js"; + +/* + * ReLight: show only the controls that are doing something. + * + * The node has 49 widgets and up to v3.1.2 painted every one of them, always. + * Light 2 and Light 3 were on screen with `num_light_sources` at 1; the whole + * grading block was on screen in colour mode, where it did nothing; a preset + * silently overrode a dozen values that stayed fully draggable. All three are + * the same defect - a control that looks live and is not. + * + * Two treatments, chosen deliberately: + * + * HIDE what is irrelevant. Light 2/3 with one light source, colour controls + * in a grading-only mode, shadow controls when nothing casts a shadow. These + * are whole blocks, and every one of them sits BELOW the control that governs + * it, so hiding them never moves anything under the pointer that just clicked. + * + * GREY OUT what a preset has taken over. `disabled = true` leaves the widget + * in place at its full height, so picking a preset does not reshuffle the + * node - and the preset's value stays readable, which matters, because seeing + * what "Spotlight" actually sets is how you learn to build your own. + * + * Three mechanics, all learned the hard way and all non-obvious: + * + * Hiding needs `widget.type` swapped AND `widget.hidden = true`. Older + * LiteGraph skips widget types it does not know; newer frontends honour + * `hidden` and ignore the type. Set one and not the other and the widget + * stays painted on half the frontends out there. + * + * Restore `computeSize` with `delete`, never by reassigning a saved copy. + * Most widgets define no `computeSize` of their own and inherit LiteGraph's, + * so the "saved" value is `undefined`; assigning it back leaves the zero-size + * stub in place and the widget never comes back. That is the single most + * likely cause of "hiding works but showing does not". + * + * A greyed widget cannot show its own value. The frontend's `_displayValue` + * getter returns "" for anything with `computedDisabled` set, so `disabled` + * on its own leaves an empty bar with a dim label and no number - the + * opposite of "you can still read what the preset set". The label does still + * paint, so the value goes there instead. It is deliberately the *preset's* + * value and not the widget's: the widget still holds whatever the user last + * set, which is precisely the number the preset is ignoring. + * + * Changes are detected on both paths - the widget's own callback for an + * immediate response, and a re-check on the draw loop, which is what catches a + * workflow load, an undo, or an API edit that never fires a callback. + */ + +const NODE_CLASS = "ReLight"; + +const MODE_CORRECTION = "Color Correction"; +const MODE_COLORED = "Colored Light"; +const SUBJECT_RIM = "Light behind subject (rim)"; + +const COLOUR_WIDGETS = ["light_color_r", "light_color_g", "light_color_b", "light_intensity"]; +const GRADING_WIDGETS = [ + "inner_brightness", "inner_contrast", "inner_saturation", + "inner_temperature", "inner_tint", "inner_gamma", + "outer_brightness", "outer_contrast", "outer_saturation", + "outer_temperature", "outer_tint", "outer_gamma", +]; +const RIM_WIDGETS = ["rim_amplification", "shadow_strength", "shadow_length"]; +const LIGHT_2 = [ + "light2_position_x", "light2_position_y", "light2_inner_radius", "light2_outer_radius", + "light2_color_r", "light2_color_g", "light2_color_b", "light2_intensity", +]; +const LIGHT_3 = [ + "light3_position_x", "light3_position_y", "light3_inner_radius", "light3_outer_radius", + "light3_color_r", "light3_color_g", "light3_color_b", "light3_intensity", +]; +const LIGHT_2_COLOUR = ["light2_color_r", "light2_color_g", "light2_color_b", "light2_intensity"]; +const LIGHT_3_COLOUR = ["light3_color_r", "light3_color_g", "light3_color_b", "light3_intensity"]; + +const GEOMETRY = new Set(GEOMETRY_KEYS); + +function warn(message, error) { + console.warn("[ReLight] " + message, error || ""); +} + +function isReLight(node) { + const type = node && (node.comfyClass || node.type); + return type === NODE_CLASS; +} + +function valuesOf(node) { + const values = {}; + for (const widget of node.widgets ?? []) { + if (widget?.name) values[widget.name] = widget.value; + } + return values; +} + +function hideWidget(widget) { + if (widget.hidden === true) return false; + widget.hidden = true; + widget.__relightType = widget.__relightType ?? widget.type; + widget.type = "hidden-" + widget.__relightType; + widget.computeSize = () => [0, -4]; + return true; +} + +function showWidget(widget) { + if (!widget.hidden) return false; + widget.hidden = false; + if (widget.__relightType !== undefined) widget.type = widget.__relightType; + // delete, not reassign: most widgets have no own computeSize, so a saved + // copy is `undefined` and assigning it back keeps the stub forever. + delete widget.computeSize; + return true; +} + +/** + * Which widgets should be hidden, given the node's current values. + * + * Pure and exported so it can be tested without a canvas. + */ +export function widgetsToHide(values) { + const hidden = new Set(); + const add = (names) => names.forEach((name) => hidden.add(name)); + + const mode = values.lighting_mode; + if (mode === MODE_CORRECTION) add(COLOUR_WIDGETS); + if (mode === MODE_COLORED) add(GRADING_WIDGETS); + + if (values.subject_interaction !== SUBJECT_RIM) add(RIM_WIDGETS); + + const lights = Number(values.num_light_sources) || 1; + if (lights < 2) add(LIGHT_2); + if (lights < 3) add(LIGHT_3); + + // Lights 2 and 3 have no grading of their own - they reuse Light 1's - so + // in a grading-only mode their colour controls are as inert as Light 1's. + if (mode === MODE_CORRECTION) { + if (lights >= 2) add(LIGHT_2_COLOUR); + if (lights >= 3) add(LIGHT_3_COLOUR); + } + + // The flag relight_debug.js maintains is never a user control. + hidden.add("debug_output_connected"); + return hidden; +} + +/** Separates a widget's name from the preset value in a greyed-out label. */ +const PRESET_LABEL_MARK = " → "; + +/** + * How a preset's value reads in a greyed widget's label. + * + * Numbers go through `String`, which drops JSON's trailing ".0" so an integer + * preset value reads as `50` rather than `50.0`. Anything else (a combo's + * string, a boolean) prints as itself. + */ +export function presetLabel(name, value) { + return `${name}${PRESET_LABEL_MARK}${String(value)}`; +} + +/** Is this label one ReLight wrote for a preset, rather than the user's own? */ +function isPresetLabel(widget) { + return typeof widget.label === "string" && widget.label.startsWith(widget.name + PRESET_LABEL_MARK); +} + +/** + * Grey a widget out, putting the preset's value where its own value would be. + * + * The original label is stashed rather than assumed absent: a future frontend + * that ships display names would otherwise lose them on the first preset. + */ +function disableWidget(widget, presetValue) { + const label = presetValue === undefined ? widget.name : presetLabel(widget.name, presetValue); + if (widget.disabled === true && widget.label === label) return false; + if (!("__relightLabel" in widget)) widget.__relightLabel = widget.label; + widget.disabled = true; + widget.label = label; + return true; +} + +function enableWidget(widget) { + const stale = isPresetLabel(widget); + if (!widget.disabled && !("__relightLabel" in widget) && !stale) return false; + widget.disabled = false; + if ("__relightLabel" in widget) { + widget.label = widget.__relightLabel; + delete widget.__relightLabel; + } else if (stale) { + // Self-heal: a preset label with no stash behind it means the widget + // arrived carrying one - a workflow saved mid-edit, an undo that + // restored the label but not our bookkeeping. Either way it is ours to + // clear, and leaving it would show a preset's value on a live control. + widget.label = undefined; + } + return true; +} + +/** + * Which widgets the selected preset has taken control of. + * + * `effect_strength` is excluded because a preset scales it rather than + * replacing it, so it stays live - that is the whole point of the exception in + * `_load_preset`. Geometry is excluded while `preserve_positioning` is on, for + * the same reason: the preset is not setting it. + */ +export function widgetsToDisable(values) { + const disabled = new Set(); + const keys = PRESET_KEYS[values.preset]; + if (!keys) return disabled; + for (const key of keys) { + if (key === STRENGTH_KEY) continue; + if (values.preserve_positioning && GEOMETRY.has(key)) continue; + disabled.add(key); + } + return disabled; +} + +/** + * Run `fn` after the current frame. + * + * `requestAnimationFrame` is a browser global and absent under `node --test`, + * where a bare call throws and takes the caller with it. + */ +function deferToNextFrame(fn) { + if (typeof requestAnimationFrame === "function") requestAnimationFrame(fn); + else setTimeout(fn, 0); +} + +/** + * Resize the node to exactly the widgets it is currently showing. + * + * Height is set, not grown. Growing only would leave the node its full + * 49-widget height with a large empty panel below the controls the moment + * anything is hidden, which is most of the time - and the node getting shorter + * when you drop to one light source is the visible payoff of the whole feature. + * Width is still only ever grown, because a widened node usually is deliberate. + */ +function fitNode(node) { + try { + const [minWidth, minHeight] = node.computeSize(); + node.setSize([Math.max(node.size[0], minWidth), minHeight]); + } catch (error) { + warn("could not resize the node after a visibility change", error); + } +} + +/** + * Apply both treatments to a node. Returns true if anything actually moved, so + * the caller can skip the resize and the redraw when nothing did. + * + * `resize: false` is for the draw loop. Resizing a node from inside its own + * draw leaves any DOM-backed widget positioned against geometry the in-progress + * frame already committed; the caller defers the resize to the next frame + * instead. + */ +export function applyVisibility(node, { resize = true } = {}) { + const values = valuesOf(node); + const hidden = widgetsToHide(values); + const disabled = widgetsToDisable(values); + let changed = false; + + const presetValues = PRESET_VALUES[values.preset] ?? {}; + + for (const widget of node.widgets ?? []) { + const name = widget?.name; + if (!name) continue; + changed = (hidden.has(name) ? hideWidget(widget) : showWidget(widget)) || changed; + changed = (disabled.has(name) + ? disableWidget(widget, presetValues[name]) + : enableWidget(widget)) || changed; + } + + if (changed && resize) fitNode(node); + return changed; +} + +app.registerExtension({ + name: "ReLight.ui", + + async nodeCreated(node) { + try { + if (!isReLight(node)) return; + + // Immediate path: the widget's own callback. Not dependable on its + // own - whether it fires, and whether `value` is current when it + // does, varies by frontend version and widget type - so it is the + // fast path, not the only one. + for (const widget of node.widgets ?? []) { + const inherited = widget.callback; + widget.callback = function (...args) { + let result; + if (typeof inherited === "function") { + try { + result = inherited.apply(this, args); + } catch (error) { + warn("an upstream widget callback failed", error); + } + } + try { + applyVisibility(node); + } catch (error) { + warn("could not update widget visibility", error); + } + return result; + }; + } + + // Safety net: re-check on the draw loop. This is what catches a + // workflow load, an undo, and any edit made through the API - none + // of which run a widget callback. + const inheritedDraw = node.onDrawForeground; + node.onDrawForeground = function (...args) { + try { + // Never resize from inside a draw - hence resize: false and + // the deferral. In a steady state nothing changed, so this + // costs a map lookup per widget per frame and nothing else. + if (applyVisibility(this, { resize: false })) { + deferToNextFrame(() => fitNode(this)); + } + } catch (error) { + warn("could not update widget visibility while drawing", error); + } + if (typeof inheritedDraw === "function") return inheritedDraw.apply(this, args); + return undefined; + }; + + applyVisibility(node); + } catch (error) { + warn("widget visibility setup failed", error); + } + }, +});