feat(batch-4): N-12 HALDIdentity + N-13 LUTExport ComfyUI nodes + workflow + tests (S-14 LUT round-trip)

- core/lut.py: identity_hald (ImageMagick HALD convention) + export_cube
  (Adobe Cube 1.0 format) — pure torch.arange + stdlib file I/O, zero Kornia.
- nodes/hald_identity_node.py: JHPixelProHALDIdentity COMBO {4,6,8,10,12}
  level, outputs (IMAGE, INT level pass-through). CATEGORY /color.
- nodes/lut_export_node.py: JHPixelProLUTExport, OUTPUT_NODE=True, writes
  to folder_paths.get_output_directory()/filename (.cube auto-append).
  Absolute path bypass resolve. Lazy folder_paths import so tests pass
  without a ComfyUI runtime available.
- workflows/S-14-lut-export.json: 3-node chain demo N-12 -> N-09 ToneCurve
  s_curve_mild -> N-13 Export s14_demo.cube (+ Note block documenting
  the color-only-chain constraint between N-12 and N-13).
- tests/test_lut.py: 8 AC (shape level=8 + level=12, black/white corners,
  round-trip identity parse spot-check, header format + 262144 body lines,
  shape mismatch ValueError, non-finite ValueError).
- README sec N-12 + N-13: 2 H2 sections numerical order sau sec N-11,
  pattern sec N-01..sec N-11 (image ref AFTER **Sample workflow:** link
  BEFORE ### Use cases H3).
- __init__.py register 13 class total (+2 new), CATEGORY groups comment
  updated: color = Lum + ColorMatcher + ToneCurve + HALDIdentity + LUTExport.
- core/__init__.py re-exports identity_hald + export_cube (consistency
  with existing 11 core function re-exports).
- Zero new dep (pyproject.toml + requirements.txt unchanged).
- pytest 214 passed / 10 CUDA-skip (206 baseline + 8 new, zero regression),
  ruff clean.

One-time sec 2b exception: single-agent Claude Code full-stack (core/ +
nodes/) cho trivial-math node (no Kornia, no bench-critical). Cowork
ref impl verbatim trong S-14 sec 3 -- pack precedent new pattern.

Refs: T-20260420-20, S-14
This commit is contained in:
Jet Huang
2026-04-20 08:09:26 +07:00
parent e65161ecce
commit 8adfd488ae
9 changed files with 606 additions and 1 deletions
+67
View File
@@ -516,6 +516,73 @@ The sample workflow chains `LoadImage → S-06 → ImageInvert (mock AI edit)
- **Round-trip bilinear softening.** Align (S-06) + edit + unwrap (S-11) goes through two bilinear resamples plus a feather blur, which softens the composite by ~10–34/255 in uint8. Good enough for a retouch chain, not near-lossless — do not stack multiple unwarp rounds.
- **Simple alpha-over composite only.** For multiply / screen / overlay blend modes, wire the `mask_used` output into a downstream `ImageBlend` node.
## N-12 HALD Identity
Generate an identity HALD image to feed into any color-grade chain and round-trip the grade into a portable Adobe Cube 1.0 (`.cube`) 3D LUT via `N-13 LUT Export`. The HALD image encodes a cube of `N = L²` color samples as a single `L³ × L³` RGB image (ImageMagick HALD convention); whatever color-only operators you wire between N-12 and N-13 get baked into the exported LUT.
**Inputs:**
| Name | Type | Default | Description |
|---|---|---|---|
| `level` | COMBO | `"8"` | HALD level L in `{4, 6, 8, 10, 12}`. Cube N = L² (L=8 → N=64, industry standard). Image side = L³ pixels: L=4 → 64×64, L=8 → 512×512, L=12 → 1728×1728. |
**Outputs:**
| Name | Type | Description |
|---|---|---|
| `image` | IMAGE | Identity HALD image `(1, L³, L³, 3)` float32 `[0, 1]`. Feed through your color-grade chain, then into `N-13 LUT Export`. |
| `level` | INT | Pass-through level L — wire into `N-13 LUT Export`'s `level` input to guarantee matching cube size. |
**Sample workflow:** [workflows/S-14-lut-export.json](workflows/S-14-lut-export.json)
![screenshot](workflows/S-14-lut-export-screenshot.png)
### Use cases
- **Bake a ComfyUI color grade into a portable LUT.** Replace `LoadImage` with `N-12 HALD Identity`, keep the rest of your color-only chain (Tone Curve, Color Matcher, Luminosity blends), and pipe the result into `N-13 LUT Export`.
- **Ship creative looks to non-ComfyUI tools.** DaVinci Resolve, Premiere, Final Cut, OBS, OCIO, hardware LUT boxes — anything that reads Adobe Cube 1.0 can load the exported `.cube`.
- **Iterate a look once, apply it many times.** Develop the grade inside ComfyUI, export the LUT, then apply it per-frame on a monitor box or in a color-grading suite without re-running the graph.
### Caveats
- **Color-only chain between N-12 and N-13.** Anything spatial (sharpening, denoise, detail masks, face pipeline, skin smoother, lens distortion) will NOT linearize into a 3D LUT — don't wire them between N-12 and N-13.
- **Level vs memory.** Level 12 produces a `1728 × 1728` image (~35.8 MB float32). Downstream chains may 2–3× that briefly; keep that in mind on CPU-only or low-VRAM runners.
## N-13 LUT Export (.cube)
Export a graded HALD image as an Adobe Cube 1.0 (`.cube`) 3D-LUT file. Pairs with `N-12 HALD Identity` upstream — the graded HALD (after your color-only chain) carries the cube's worth of color samples, and N-13 serializes them to a format readable by DaVinci Resolve 18+, Premiere Pro 2023+, OBS Studio 29+, OCIO 2.2+, LUTCalc, and most pro color tooling.
**Inputs:**
| Name | Type | Default | Description |
|---|---|---|---|
| `image` | IMAGE | — | Graded HALD image (BHWC float32 `[0, 1]`). First batch is taken. `H == W == L³` is validated. |
| `level` | INT | `8` | HALD level L — MUST match the level used by the upstream `N-12 HALD Identity`. Range `[2, 16]`. Mismatch raises `ValueError`. |
| `filename` | STRING | `"pack_lut.cube"` | Filename (relative to ComfyUI `output/`) or absolute path. `.cube` extension auto-appended if missing. Existing files are overwritten. |
| `title` | STRING | `"JHPixelPro LUT"` | Adobe Cube `TITLE` header metadata — shown by DaVinci / Premiere when browsing `.cube` files. |
**Outputs:**
| Name | Type | Description |
|---|---|---|
| `path` | STRING | Absolute resolved path of the written `.cube` file. Pipe into a downstream `ShowText` / notify node to display in the UI. |
**Sample workflow:** [workflows/S-14-lut-export.json](workflows/S-14-lut-export.json)
![screenshot](workflows/S-14-lut-export-screenshot.png)
### Use cases
- **Deliver a color-grade chain as a `.cube` file.** One export, usable in any OCIO / Resolve / Premiere workflow.
- **On-set monitor LUT.** Bake the look into a hardware LUT box or DIT monitor for live preview that matches the final grade.
- **OCIO pipeline integration.** Drop the `.cube` into an OCIO config as a `FileTransform` node for VFX studios.
### Caveats
- **Level must match upstream.** The `level` pin validates against image dims (`H == W == L³`). Always wire N-12's `level` output into N-13's `level` input to stay in sync.
- **RGB-only, no alpha, no shaper.** Input is assumed linear `[0, 1]` float; ACES / LogC / HLG users should bake the transform upstream of N-12. Alpha is not exported (3D LUT format limitation).
- **Parent directory must exist.** The wrapper does not silently `mkdir` — an `OSError` is raised if the parent directory is missing, to avoid writing LUTs into unexpected places.
## License
Apache-2.0 — see [LICENSE](./LICENSE).
+9 -1
View File
@@ -18,7 +18,7 @@ NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {}
# load the pack themselves via ``importlib.util.spec_from_file_location``.
# Category groups exposed to ComfyUI Add Node menu (under root "ComfyUI-JH-PixelPro/"):
# filters — pixel-domain filters (FS, ES)
# color — color-grade layer (Lum, ColorMatcher, ToneCurve)
# color — color-grade layer (Lum, ColorMatcher, ToneCurve, HALDIdentity, LUTExport)
# mask — mask creation / refinement (MR, HFDM)
# geometry — geometric transforms (Aligner, LensDistortion)
# face — face-pipeline domain (FaceDetect, UnwrapFace)
@@ -29,9 +29,11 @@ if __package__:
JHPixelProFaceDetect,
JHPixelProFacialAligner,
JHPixelProFrequencySeparation,
JHPixelProHALDIdentity,
JHPixelProHighFreqDetailMasker,
JHPixelProLensDistortion,
JHPixelProLuminosityMasking,
JHPixelProLUTExport,
JHPixelProSubPixelMaskRefiner,
JHPixelProToneCurve,
JHPixelProUnwrapFace,
@@ -70,6 +72,12 @@ if __package__:
NODE_CLASS_MAPPINGS["JHPixelProToneCurve"] = JHPixelProToneCurve
NODE_DISPLAY_NAME_MAPPINGS["JHPixelProToneCurve"] = "Tone Curve (RGB)"
NODE_CLASS_MAPPINGS["JHPixelProHALDIdentity"] = JHPixelProHALDIdentity
NODE_DISPLAY_NAME_MAPPINGS["JHPixelProHALDIdentity"] = "HALD Identity"
NODE_CLASS_MAPPINGS["JHPixelProLUTExport"] = JHPixelProLUTExport
NODE_DISPLAY_NAME_MAPPINGS["JHPixelProLUTExport"] = "LUT Export (.cube)"
# Web extensions (JS/CSS) — not used in Phase 1.
WEB_DIRECTORY = "./web"
+3
View File
@@ -7,6 +7,7 @@ from .facial_aligner import facial_align
from .frequency import frequency_separation
from .lens_distortion import lens_distortion
from .luminosity import luminosity_masks
from .lut import export_cube, identity_hald
from .mask_refiner import subpixel_mask_refine
from .smoother import edge_aware_smooth
from .tone_curve import tone_curve
@@ -24,4 +25,6 @@ __all__ = [
"facial_align",
"lens_distortion",
"unwrap_face",
"identity_hald",
"export_cube",
]
+126
View File
@@ -0,0 +1,126 @@
"""Identity HALD generator + Adobe Cube 1.0 (.cube) 3D-LUT exporter.
Pure ``torch.arange`` reshape + stdlib file I/O. No Kornia, no bench-critical
GPU-parallel primitive. ImageMagick HALD convention for the identity image,
blue-outer-loop ordering for the ``.cube`` body (DaVinci Resolve / Premiere /
OCIO compatible).
"""
from __future__ import annotations
import os
import torch
def identity_hald(
level: int,
*,
device: str | torch.device = "cpu",
dtype: torch.dtype = torch.float32,
) -> torch.Tensor:
"""Generate an identity HALD image for 3D LUT round-trip (ImageMagick convention).
Args:
level: HALD level L in [2, 16]. Cube grid N = L**2. Image side = L**3.
device: torch device for output. CPU default — HALD generation is cheap.
dtype: Output dtype. Pack downstream assumes float32 BHWC in [0, 1].
Returns:
Tensor shape ``(1, L**3, L**3, 3)`` in ``[0, 1]``. Pixel ``(y, x)`` encodes::
r = (x mod N) / (N - 1)
g = (y mod N) / (N - 1)
b = ((x // N) + (y // N) * L) / (N - 1)
with ``N = L**2``.
Raises:
ValueError: ``level`` not an int in ``[2, 16]``.
"""
if not isinstance(level, int) or level < 2 or level > 16:
raise ValueError(f"HALD level must be int in [2, 16], got {level!r}")
n = level * level
side = level ** 3 # n * level == level ** 3
x = torch.arange(side, device=device).view(1, side).expand(side, side)
y = torch.arange(side, device=device).view(side, 1).expand(side, side)
r = (x % n).to(dtype) / (n - 1)
g = (y % n).to(dtype) / (n - 1)
b = ((x // n) + (y // n) * level).to(dtype) / (n - 1)
hald = torch.stack([r, g, b], dim=-1) # (side, side, 3)
return hald.unsqueeze(0) # (1, side, side, 3)
def export_cube(
hald: torch.Tensor,
level: int,
path: str,
*,
title: str = "JHPixelPro LUT",
) -> str:
"""Export a graded HALD image as an Adobe Cube 1.0 (``.cube``) 3D-LUT file.
Args:
hald: Graded HALD tensor ``(B, H, W, 3)`` or ``(H, W, 3)`` float in ``[0, 1]``.
First batch taken. ``H == W == level**3`` required (must match the
identity HALD output for the same level).
level: HALD level L used when the identity HALD was generated. Cube
``N = L**2``. Must match ``hald`` spatial dims.
path: Output file path (absolute or relative). Parent directory must exist.
title: TITLE header string (Adobe Cube metadata).
Returns:
Absolute resolved path of the written file.
Raises:
ValueError: ``level`` out of ``[2, 16]``, ``hald`` shape mismatch, or
non-finite values in ``hald``.
OSError: Parent directory missing or write permission denied.
"""
if not isinstance(level, int) or level < 2 or level > 16:
raise ValueError(f"HALD level must be int in [2, 16], got {level!r}")
n = level * level
side = level ** 3
if hald.ndim == 4:
hald = hald[0]
if hald.shape != (side, side, 3):
raise ValueError(
f"hald shape must be ({side},{side},3) for level={level}, "
f"got {tuple(hald.shape)}"
)
if not torch.isfinite(hald).all():
raise ValueError(
"hald contains non-finite values (NaN/Inf) — clamp or filter upstream"
)
samples = hald.clamp(0.0, 1.0).detach().to("cpu", torch.float32).numpy()
resolved = os.path.abspath(path)
parent = os.path.dirname(resolved)
if parent and not os.path.isdir(parent):
raise OSError(f"Parent directory missing: {parent}")
lines = [
f'TITLE "{title}"',
f"LUT_3D_SIZE {n}",
"DOMAIN_MIN 0.0 0.0 0.0",
"DOMAIN_MAX 1.0 1.0 1.0",
"",
]
# Blue-outer / green-middle / red-innermost (Adobe Cube 1.0 standard).
for b_idx in range(n):
by = b_idx // level
bx = b_idx % level
for g_idx in range(n):
y = g_idx + by * n
for r_idx in range(n):
x = r_idx + bx * n
r, g, b = samples[y, x]
lines.append(f"{r:.6f} {g:.6f} {b:.6f}")
with open(resolved, "w", encoding="utf-8", newline="\n") as fh:
fh.write("\n".join(lines) + "\n")
return resolved
+4
View File
@@ -5,8 +5,10 @@ from .detail_masker_node import JHPixelProHighFreqDetailMasker
from .face_detect_node import JHPixelProFaceDetect
from .facial_aligner_node import JHPixelProFacialAligner
from .frequency_node import JHPixelProFrequencySeparation
from .hald_identity_node import JHPixelProHALDIdentity
from .lens_distortion_node import JHPixelProLensDistortion
from .luminosity_node import JHPixelProLuminosityMasking
from .lut_export_node import JHPixelProLUTExport
from .mask_refiner_node import JHPixelProSubPixelMaskRefiner
from .smoother_node import JHPixelProEdgeAwareSmoother
from .tone_curve_node import JHPixelProToneCurve
@@ -18,7 +20,9 @@ __all__ = [
"JHPixelProFaceDetect",
"JHPixelProFacialAligner",
"JHPixelProFrequencySeparation",
"JHPixelProHALDIdentity",
"JHPixelProHighFreqDetailMasker",
"JHPixelProLUTExport",
"JHPixelProLensDistortion",
"JHPixelProLuminosityMasking",
"JHPixelProSubPixelMaskRefiner",
+42
View File
@@ -0,0 +1,42 @@
"""ComfyUI wrapper for ``core.lut.identity_hald``."""
from __future__ import annotations
import torch
from ..core.lut import identity_hald
_LEVEL_CHOICES = ["4", "6", "8", "10", "12"]
class JHPixelProHALDIdentity:
"""Generate an identity HALD image to feed into a color-grade chain + LUT export."""
CATEGORY = "ComfyUI-JH-PixelPro/color"
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("image", "level")
FUNCTION = "generate"
@classmethod
def INPUT_TYPES(cls) -> dict: # noqa: N802 — ComfyUI node contract mandates UPPER_CASE.
return {
"required": {
"level": (
_LEVEL_CHOICES,
{
"default": "8",
"tooltip": (
"HALD level L. Cube N=L² (L=8→N=64 industry "
"standard). Image side = L³ pixels: L=4→64×64, "
"L=8→512×512, L=12→1728×1728. Higher L = finer "
"LUT but larger intermediate image."
),
},
),
},
}
def generate(self, level: str) -> tuple[torch.Tensor, int]:
level_int = int(level)
image = identity_hald(level_int)
return (image, level_int)
+89
View File
@@ -0,0 +1,89 @@
"""ComfyUI wrapper for ``core.lut.export_cube`` — writes Adobe Cube 1.0 (.cube)."""
from __future__ import annotations
import os
import torch
from ..core.lut import export_cube
class JHPixelProLUTExport:
"""Export a graded HALD image as a portable Adobe Cube 1.0 (``.cube``) 3D LUT."""
CATEGORY = "ComfyUI-JH-PixelPro/color"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("path",)
FUNCTION = "export"
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls) -> dict: # noqa: N802 — ComfyUI node contract mandates UPPER_CASE.
return {
"required": {
"image": ("IMAGE",),
"level": (
"INT",
{
"default": 8,
"min": 2,
"max": 16,
"step": 1,
"tooltip": (
"HALD level L — MUST match the level used by the "
"upstream HALD Identity node. Image H==W==L³ is "
"validated; mismatch raises ValueError."
),
},
),
"filename": (
"STRING",
{
"default": "pack_lut.cube",
"tooltip": (
"Filename (relative to ComfyUI output/) or "
"absolute path. '.cube' extension auto-appended "
"if missing. Existing files are overwritten."
),
},
),
"title": (
"STRING",
{
"default": "JHPixelPro LUT",
"tooltip": (
"Adobe Cube TITLE header metadata — shown by "
"DaVinci Resolve, Premiere and other LUT tools "
"when browsing cube files."
),
},
),
},
}
def export(
self,
image: torch.Tensor,
level: int,
filename: str,
title: str,
) -> tuple[str]:
resolved = self._resolve_output_path(filename)
written = export_cube(image, int(level), resolved, title=title)
return (written,)
@staticmethod
def _resolve_output_path(filename: str) -> str:
name = filename.strip()
if not name.lower().endswith(".cube"):
name = name + ".cube"
if os.path.isabs(name):
return name
try:
import folder_paths # ComfyUI runtime module (not installed in unit tests).
base = folder_paths.get_output_directory()
except ImportError:
base = os.path.abspath("output")
return os.path.join(base, name)
+124
View File
@@ -0,0 +1,124 @@
"""Functional tests for ``core.lut`` (identity HALD + Adobe Cube 1.0 exporter)."""
from __future__ import annotations
import math
import pytest
import torch
from core.lut import export_cube, identity_hald
EPS_CORNER = 1e-6
EPS_ROUNDTRIP = 1e-5
def _parse_cube_body(path: str) -> tuple[list[str], list[tuple[float, float, float]]]:
"""Split a .cube file into (header_lines, body_triplets).
Header = every line until the first line that parses as ``r g b`` floats.
Body = every non-blank line after that parses as three floats.
"""
with open(path, encoding="utf-8") as fh:
raw = fh.read().splitlines()
header: list[str] = []
body: list[tuple[float, float, float]] = []
in_body = False
for line in raw:
stripped = line.strip()
parts = stripped.split()
is_triplet = False
if len(parts) == 3:
try:
triplet = (float(parts[0]), float(parts[1]), float(parts[2]))
is_triplet = True
except ValueError:
is_triplet = False
if is_triplet:
in_body = True
body.append(triplet)
else:
if in_body:
# Blank lines inside body are tolerated (some writers add them).
if stripped:
header.append(line)
else:
header.append(line)
return header, body
def test_identity_hald_shape_level_8() -> None:
hald = identity_hald(8)
assert hald.shape == (1, 512, 512, 3)
assert hald.dtype == torch.float32
assert float(hald.min()) >= 0.0
assert float(hald.max()) <= 1.0
def test_identity_hald_black_corner_level_8() -> None:
hald = identity_hald(8)
corner = hald[0, 0, 0]
assert torch.allclose(corner, torch.tensor([0.0, 0.0, 0.0]), atol=EPS_CORNER)
def test_identity_hald_white_corner_level_8() -> None:
hald = identity_hald(8)
corner = hald[0, 511, 511]
assert torch.allclose(corner, torch.tensor([1.0, 1.0, 1.0]), atol=EPS_CORNER)
def test_roundtrip_identity_cube_body(tmp_path) -> None:
level = 8
n = level * level # 64
hald = identity_hald(level)
path = str(tmp_path / "identity.cube")
export_cube(hald, level, path, title="roundtrip")
_, body = _parse_cube_body(path)
assert len(body) == n ** 3 == 262144
# Spot-check 4 entries distributed across the cube — full 262144 compare is slow.
for idx in (0, 37, 64 * 64 - 1, 262143):
b_idx, rem = divmod(idx, n * n)
g_idx, r_idx = divmod(rem, n)
expected = (r_idx / (n - 1), g_idx / (n - 1), b_idx / (n - 1))
got = body[idx]
for e, g in zip(expected, got, strict=True):
assert math.isclose(g, e, abs_tol=EPS_ROUNDTRIP), (
f"idx={idx} expected={expected} got={got}"
)
def test_identity_hald_shape_level_12() -> None:
hald = identity_hald(12)
assert hald.shape == (1, 1728, 1728, 3)
corner = hald[0, 1727, 1727]
assert torch.allclose(corner, torch.tensor([1.0, 1.0, 1.0]), atol=EPS_CORNER)
def test_export_cube_header_and_body_count(tmp_path) -> None:
level = 8
hald = identity_hald(level) * 0.9 + 0.05 # arbitrary "graded" hald
path = str(tmp_path / "graded.cube")
export_cube(hald, level, path, title="My Title")
header, body = _parse_cube_body(path)
assert any('TITLE "My Title"' in line for line in header)
assert any("LUT_3D_SIZE 64" in line for line in header)
assert any("DOMAIN_MIN 0.0 0.0 0.0" in line for line in header)
assert any("DOMAIN_MAX 1.0 1.0 1.0" in line for line in header)
assert len(body) == 64 ** 3 == 262144
def test_export_cube_shape_mismatch_raises(tmp_path) -> None:
bad = torch.zeros((1, 256, 256, 3), dtype=torch.float32)
with pytest.raises(ValueError, match=r"hald shape must be \(512,512,3\)"):
export_cube(bad, 8, str(tmp_path / "nope.cube"))
def test_export_cube_non_finite_raises(tmp_path) -> None:
hald = identity_hald(8).clone()
hald[0, 0, 0, 0] = float("nan")
with pytest.raises(ValueError, match="non-finite values"):
export_cube(hald, 8, str(tmp_path / "nan.cube"))
+142
View File
@@ -0,0 +1,142 @@
{
"id": "s14-lut-export-20260420",
"revision": 0,
"last_node_id": 4,
"last_link_id": 3,
"nodes": [
{
"id": 1,
"type": "JHPixelProHALDIdentity",
"pos": [80, 200],
"size": [260, 120],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"slot_index": 0,
"links": [1]
},
{
"name": "level",
"type": "INT",
"slot_index": 1,
"links": [3]
}
],
"properties": {
"cnr_id": "ComfyUI-JH-PixelPro",
"ver": "0.6.0",
"Node name for S&R": "JHPixelProHALDIdentity"
},
"widgets_values": ["8"]
},
{
"id": 2,
"type": "JHPixelProToneCurve",
"pos": [380, 200],
"size": [345, 260],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "image_toned",
"type": "IMAGE",
"slot_index": 0,
"links": [2]
}
],
"properties": {
"cnr_id": "ComfyUI-JH-PixelPro",
"ver": "0.6.0",
"Node name for S&R": "JHPixelProToneCurve"
},
"widgets_values": [
"s_curve_mild",
"rgb_master",
"[[0.0, 0.0], [0.15, 0.10], [0.30, 0.25], [0.45, 0.43], [0.55, 0.57], [0.70, 0.75], [0.85, 0.90], [1.0, 1.0]]",
1.0
]
},
{
"id": 3,
"type": "JHPixelProLUTExport",
"pos": [760, 200],
"size": [320, 180],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 2
},
{
"name": "level",
"type": "INT",
"link": 3,
"widget": {
"name": "level"
}
}
],
"outputs": [
{
"name": "path",
"type": "STRING",
"slot_index": 0,
"links": null
}
],
"properties": {
"cnr_id": "ComfyUI-JH-PixelPro",
"ver": "0.6.0",
"Node name for S&R": "JHPixelProLUTExport"
},
"widgets_values": ["s14_demo.cube", "S-14 Demo"]
},
{
"id": 4,
"type": "Note",
"pos": [80, 360],
"size": [640, 220],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"ℹ️ S-14 LUT Export — round-trip color-grade into portable .cube\n\nChain: N-12 HALD Identity (level=8, 512×512) → N-09 Tone Curve (s_curve_mild) → N-13 LUT Export (.cube).\n\nN-12 generates an identity HALD image encoding a 64×64×64 color cube. Everything you wire between N-12 and N-13 gets *baked into* the exported LUT — any chain that is color-grade-only (Tone Curve, Color Matcher, Luminosity Masking blends, etc.) becomes a portable .cube you can load into DaVinci Resolve / Premiere / OBS / OCIO / hardware LUT boxes.\n\nN-13's `level` pin MUST match the level from N-12 (wired here via the INT link). The `filename` defaults to 'pack_lut.cube' under ComfyUI's output/ directory; absolute paths are honored verbatim. The '.cube' extension is auto-appended if you leave it off.\n\nDO NOT wire anything pixel-structure-aware (sharpening, denoise, detail masks, face pipeline, skin smoother, lens distortion) between N-12 and N-13 — those operators are spatial, not color-only, and will NOT linearize into a 3D LUT."
],
"color": "#243",
"bgcolor": "#365"
}
],
"links": [
[1, 1, 0, 2, 0, "IMAGE"],
[2, 2, 0, 3, 0, "IMAGE"],
[3, 1, 1, 3, 1, "INT"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.0,
"offset": [0, 0]
}
},
"version": 0.4
}