Add files via upload

This commit is contained in:
HeX
2026-07-06 16:15:07 -03:00
committed by GitHub
commit c33dac0c7f
10 changed files with 1057 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
# ComfyUI — Pixel Snapper
Turn any image into clean, grid-aligned **pixel art** inside ComfyUI, using the
compiled [SpriteFusion Pixel Snapper](https://github.com/Hugo-Dz/spritefusion-pixel-snapper)
engine — plus color controls, classic video-game palettes, a pixel-perfect resizer,
and an interactive preview with a magnifier.
![Workflow preview](docs/workflow.png)
> _Example workflow: Load Image → Pixel Snapper (Engine) → Pixel Art Preview →
> Pixel Art Resizer → Preview Image, with a Video Game Palette node._
---
## ✨ Features
- **Real snapping engine** — detects the native pixel grid and downscales via
majority-color voting (not a naive resize).
- **Color controls** — saturation / contrast / brightness applied *before* the snap.
- **21 video-game palettes** — Game Boy, NES, PICO-8, C64, ZX Spectrum, DawnBringer,
and more, in a dedicated node.
- **Aspect-ratio fix** — corrects the engine's occasional "flattening" so output
keeps the source proportions.
- **Pixel Art Resizer** — scale up crisp (nearest) or shrink down to pixel-art
resolution.
- **Pixel Art Preview** — crisp nearest-neighbor rendering with an interactive
**magnifier lens** that follows your cursor.
---
## 🧩 Nodes
All nodes live under **Add Node → image → pixel_art**.
### Pixel Snapper (Engine)
Runs the compiled engine, then layers optional color and palette processing around it.
| Input | Description |
|-------|-------------|
| `image` | Input image |
| `colors_k` | Number of output colors (ignored when a palette is connected) |
| `saturation` / `contrast` / `brightness` | Color tweaks applied **before** the snap (they influence the engine's k-means) |
| `fix_aspect` | Corrects the engine's aspect-ratio flattening (trims the over-represented axis, nearest). On by default |
| `keep_output_size` | Upscales the result back to the original size (nearest) |
| `palette` *(optional)* | A fixed palette from the **Video Game Palette** node |
| `palette_dither` *(optional)* | Floyd–Steinberg dithering during palette remap |
### Video Game Palette
Dropdown of classic palettes → outputs a `GAME_PALETTE`. Connect it to the Pixel
Snapper's `palette` input. When connected, the engine's `k` becomes the palette size
and the output is remapped to the **exact** palette colors.
### Pixel Art Resizer
Resizes pixel art without blurring.
| Input | Description |
|-------|-------------|
| `mode` | `scale_factor` (multiply) or `target_size` (width/height) |
| `scale_factor` | Factor in `scale_factor` mode (e.g. `4.0` = 4×) |
| `width` / `height` | Target size in `target_size` mode |
| `interpolation` | `nearest` (crisp upscale), or `box`/`lanczos`/`bicubic`/`bilinear`/`hamming` (downscale) |
| `keep_aspect` | Preserve aspect ratio in `target_size` mode |
> **Tip:** use `nearest` to enlarge pixel art; use `box` to shrink a large image
> down to a pixel-art resolution.
### Pixel Art Preview *(output node)*
Displays the image with crisp (nearest-neighbor) rendering and an interactive
magnifier — hover the image to zoom in on individual pixels.
| Input | Description |
|-------|-------------|
| `magnifier` | Toggle the lens on/off |
| `zoom` | Magnification (2–32×) |
| `lens_size` | Lens diameter in pixels |
---
## ⚙️ How it works
1. **Grid detection** — the engine analyzes intensity peaks to estimate the pixel
step size on each axis, then an edge-following "walker" places grid cuts.
2. **Downscale** — each grid cell is collapsed to its most frequent color
(majority voting), producing a small, crisp pixel-art image.
3. **Color quantization** — colors are reduced to `k` via k-means (or to a fixed
palette when one is connected).
4. **Wrapper layer (this repo)** — the ComfyUI nodes handle image ↔ tensor
conversion, pre-snap color adjustments, the `fix_aspect` correction, palette
remapping, resizing, and the crisp magnifier preview (a small front-end widget
in `web/pixelart_preview.js`).
The engine runs as a bundled `.exe` invoked per image:
```
spritefusion-pixel-snapper.exe <input.png> <output.png> <k>
```
---
## 📦 Installation
1. Copy the `comfyui_pixel_snapper` folder into `ComfyUI/custom_nodes/`.
The `spritefusion-pixel-snapper.exe` binary is **included**, so it works
standalone on any Windows machine — no path setup required.
2. *(Optional)* To use your own build of the engine, simply replace
`spritefusion-pixel-snapper.exe` in the node folder with your binary (same name).
3. Restart ComfyUI, then hard-refresh the browser (**Ctrl+F5**) so the preview
widget's JavaScript loads.
Dependencies (`torch`, `numpy`, `Pillow`) ship with ComfyUI.
> ⚠️ **Windows only** — the engine is shipped as a `.exe`. On Linux/macOS you would
> need to build the engine from source (see credits).
### 🔒 Binary safety
The bundled `spritefusion-pixel-snapper.exe` is the compiled
[open-source engine](https://github.com/Hugo-Dz/spritefusion-pixel-snapper). You can
verify it yourself on VirusTotal:
**[VirusTotal scan report](https://www.virustotal.com/gui/file-analysis/MGE0Zjk1MjU5M2Y3ZGNlNDRlZWEwNDQ4OTk5YjZiZjA6MTc4MzM2NDE0Ng==)**
Prefer full trust? Build the engine from source (see credits) and replace the
`spritefusion-pixel-snapper.exe` in the node folder with your own binary.
---
## 🎨 Included palettes (21)
Game Boy (DMG / Pocket) · NES (Full 54) · PICO-8 · CGA (Palette 1 / EGA 16) ·
Sweetie 16 · DawnBringer 16 · DawnBringer 32 · Commodore 64 · ZX Spectrum ·
Endesga 32 · Resurrect 64 · SLSO8 · Oil 6 · 1-Bit Monitor Glow · 2-Bit Demichrome ·
Ice Cream GB · Kirokaze Gameboy · Rustic GB · Mist GB
---
## 🙏 Credits
- **Pixel-snapping engine** — [SpriteFusion Pixel Snapper](https://github.com/Hugo-Dz/spritefusion-pixel-snapper)
by **Hugo Duprez**. All credit for the core snapping algorithm and the compiled
binary goes to the original author; please refer to the upstream repository for
its license and terms.
- **ComfyUI custom node & integration** — implemented by **[HexaDucket](https://github.com/HexaDucket)**.
The nodes, palettes, aspect-ratio fix, resizer, and the magnifier preview widget
in this repository are my own work wrapping the engine for ComfyUI.
---
<!-- Optional: add a LICENSE file. Keep it compatible with the upstream engine's license. -->
+5
View File
@@ -0,0 +1,5 @@
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+328
View File
@@ -0,0 +1,328 @@
{
"id": "c6594601-b6ca-49d6-ad7f-6b60bb1906f5",
"revision": 0,
"last_node_id": 6,
"last_link_id": 5,
"nodes": [
{
"id": 1,
"type": "VideoGamePalette",
"pos": [
1487.7681467795892,
-1346.4899488120564
],
"size": [
270,
58
],
"flags": {},
"order": 0,
"mode": 4,
"inputs": [],
"outputs": [
{
"name": "palette",
"type": "GAME_PALETTE",
"links": [
5
]
}
],
"properties": {
"Node name for S&R": "VideoGamePalette",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.7"
}
},
"widgets_values": [
"NES (completa 54)"
]
},
{
"id": 2,
"type": "PixelArtPreview",
"pos": [
1773.8122799418038,
-1606.7501614765035
],
"size": [
210,
273
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PixelArtPreview",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.7"
}
},
"widgets_values": [
true,
4,
140,
""
]
},
{
"id": 3,
"type": "PixelArtResizer",
"pos": [
1995.6162003659078,
-1604.379113069304
],
"size": [
270,
178
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 2
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
3
]
}
],
"properties": {
"Node name for S&R": "PixelArtResizer",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.7"
}
},
"widgets_values": [
"scale_factor",
4,
128,
128,
"nearest",
true
]
},
{
"id": 4,
"type": "LoadImage",
"pos": [
1193.949206631912,
-1604.9259539466343
],
"size": [
282.83333587646484,
314
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
4
]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.27.0",
"Node name for S&R": "LoadImage",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.7"
}
},
"widgets_values": [
"pasted/image (226).png",
"image"
]
},
{
"id": 5,
"type": "PreviewImage",
"pos": [
2277.9584226206034,
-1602.6605931724057
],
"size": [
355.697905475281,
400.93793210196236
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 3
}
],
"outputs": [
{
"name": "images",
"type": "IMAGE",
"links": null
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.27.0",
"Node name for S&R": "PreviewImage",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.7"
}
},
"widgets_values": []
},
{
"id": 6,
"type": "PixelSnapper",
"pos": [
1487.9154660931863,
-1606.385292004229
],
"size": [
270,
246
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 4
},
{
"name": "palette",
"shape": 7,
"type": "GAME_PALETTE",
"link": 5
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
1,
2
]
}
],
"properties": {
"Node name for S&R": "PixelSnapper",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.7"
}
},
"widgets_values": [
256,
1,
1,
1,
true,
"D:\\ComfyUI\\custom_nodes\\comfyui_pixel_snapper\\spritefusion-pixel-snapper.exe",
false
]
}
],
"links": [
[
1,
6,
0,
2,
0,
"IMAGE"
],
[
2,
6,
0,
3,
0,
"IMAGE"
],
[
3,
3,
0,
5,
0,
"IMAGE"
],
[
4,
4,
0,
6,
0,
"IMAGE"
],
[
5,
1,
0,
6,
1,
"GAME_PALETTE"
]
],
"groups": [],
"config": {},
"extra": {
"ue_links": [],
"links_added_by_ue": [],
"ds": {
"scale": 1.228459735736727,
"offset": [
-1093.949206631912,
1706.7501614765035
]
},
"frontendVersion": "1.45.20",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 942 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

+305
View File
@@ -0,0 +1,305 @@
import os
import subprocess
import tempfile
import numpy as np
import torch
from PIL import Image, ImageEnhance
from .palettes import PALETTE_NAMES, get_palette_rgb
# The compiled engine lives next to this file, inside the custom node folder, so it
# works standalone on any Windows machine. To use your own build, just replace this
# .exe in the node folder.
_HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_EXE = os.path.join(_HERE, "spritefusion-pixel-snapper.exe")
def tensor_to_pil(img_tensor):
arr = (img_tensor.cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
return Image.fromarray(arr, mode="RGB")
def pil_to_tensor(img):
arr = np.array(img.convert("RGB")).astype(np.float32) / 255.0
return torch.from_numpy(arr)
def remap_to_palette(img, rgb_palette, dither):
"""Reduce the image to the exact colors of a fixed palette."""
pal_img = Image.new("P", (1, 1))
flat = []
for r, g, b in rgb_palette:
flat.extend([r, g, b])
flat.extend([0, 0, 0] * (256 - len(rgb_palette)))
pal_img.putpalette(flat)
dither_mode = Image.FLOYDSTEINBERG if dither else Image.NONE
return img.convert("RGB").quantize(
palette=pal_img, colors=len(rgb_palette), dither=dither_mode
).convert("RGB")
def correct_aspect(img, src_size):
"""Fix the 'flattening' from the engine: the edge-walker can emit a slightly
different number of cells on X vs Y, distorting the aspect ratio. We trim the
over-represented axis (nearest) so the output aspect matches the source."""
ow, oh = img.size
sw, sh = src_size
if min(ow, oh, sw, sh) <= 0:
return img
src_aspect = sw / sh
out_aspect = ow / oh
if abs(out_aspect - src_aspect) < 1e-3:
return img
if out_aspect > src_aspect:
new_w, new_h = max(1, round(oh * src_aspect)), oh # too wide -> trim width
else:
new_w, new_h = ow, max(1, round(ow / src_aspect)) # too tall -> trim height
if (new_w, new_h) == (ow, oh):
return img
return img.resize((new_w, new_h), Image.NEAREST)
def run_snapper(exe_path, pil_in, k):
"""Save the image, run the spritefusion-pixel-snapper binary and read the result."""
if not (isinstance(exe_path, str) and os.path.isfile(exe_path)):
raise FileNotFoundError(
f"Pixel Snapper engine not found. Place 'spritefusion-pixel-snapper.exe' "
f"in the node folder: {_HERE}"
)
tmpdir = tempfile.mkdtemp(prefix="pixelsnap_")
in_path = os.path.join(tmpdir, "in.png")
out_path = os.path.join(tmpdir, "out.png")
pil_in.save(in_path)
result = subprocess.run(
[exe_path, in_path, out_path, str(int(k))],
capture_output=True, text=True,
)
if result.returncode != 0 or not os.path.isfile(out_path):
raise RuntimeError(
f"Pixel Snapper failed (code {result.returncode}).\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
out_img = Image.open(out_path).convert("RGB").copy()
return out_img
class PixelSnapperNode:
"""Calls the compiled engine 'spritefusion-pixel-snapper.exe' which detects the
native pixel art grid and reduces the image to k colors. Supports color
adjustments (pre) and remapping to a fixed video game palette (post)."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"colors_k": ("INT", {"default": 16, "min": 2, "max": 256, "step": 1}),
"saturation": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.05}),
"contrast": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.05}),
"brightness": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.05}),
"fix_aspect": ("BOOLEAN", {"default": True}),
"keep_output_size": ("BOOLEAN", {"default": False}),
},
"optional": {
"palette": ("GAME_PALETTE",),
"palette_dither": ("BOOLEAN", {"default": False}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "run"
CATEGORY = "image/pixel_art"
def run(self, image, colors_k, saturation, contrast, brightness,
fix_aspect, keep_output_size, palette=None, palette_dither=False):
# If a palette is connected, the k passed to the engine is the palette size.
k = len(palette) if palette is not None else colors_k
out_batch = []
target_size = None
for i in range(image.shape[0]):
img = tensor_to_pil(image[i])
orig_size = img.size
# Color adjustments BEFORE the snap (they affect the engine's k-means).
if brightness != 1.0:
img = ImageEnhance.Brightness(img).enhance(brightness)
if contrast != 1.0:
img = ImageEnhance.Contrast(img).enhance(contrast)
if saturation != 1.0:
img = ImageEnhance.Color(img).enhance(saturation)
result = run_snapper(DEFAULT_EXE, img, k)
# Fix the engine's aspect-ratio flattening (edge-walker cell mismatch).
if fix_aspect:
result = correct_aspect(result, orig_size)
# Remap to a fixed palette AFTER the snap (exact video game colors).
if palette is not None:
result = remap_to_palette(result, palette, palette_dither)
if keep_output_size:
result = result.resize(orig_size, Image.NEAREST)
# Ensure consistent size across the batch (torch.stack requires it).
if target_size is None:
target_size = result.size
elif result.size != target_size:
result = result.resize(target_size, Image.NEAREST)
out_batch.append(pil_to_tensor(result))
return (torch.stack(out_batch),)
class VideoGamePaletteNode:
"""Provides a classic video game color palette for the Pixel Snapper."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"palette_name": (PALETTE_NAMES, {"default": PALETTE_NAMES[0]}),
},
}
RETURN_TYPES = ("GAME_PALETTE",)
RETURN_NAMES = ("palette",)
FUNCTION = "run"
CATEGORY = "image/pixel_art"
def run(self, palette_name):
return (get_palette_rgb(palette_name),)
RESAMPLE = {
"nearest": Image.NEAREST,
"box": Image.BOX,
"bilinear": Image.BILINEAR,
"hamming": Image.HAMMING,
"bicubic": Image.BICUBIC,
"lanczos": Image.LANCZOS,
}
class PixelArtResizerNode:
"""Resizes pixel art. To scale up without blurring use 'nearest'; to shrink a
large image down to pixel art resolution use 'box' or 'nearest'."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mode": (["scale_factor", "target_size"], {"default": "scale_factor"}),
"scale_factor": ("FLOAT", {"default": 2.0, "min": 0.05, "max": 64.0, "step": 0.05}),
"width": ("INT", {"default": 128, "min": 1, "max": 8192, "step": 1}),
"height": ("INT", {"default": 128, "min": 1, "max": 8192, "step": 1}),
"interpolation": (list(RESAMPLE.keys()), {"default": "nearest"}),
"keep_aspect": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "run"
CATEGORY = "image/pixel_art"
def run(self, image, mode, scale_factor, width, height, interpolation, keep_aspect):
filt = RESAMPLE[interpolation]
out_batch = []
target = None
for i in range(image.shape[0]):
img = tensor_to_pil(image[i])
w, h = img.size
if mode == "scale_factor":
nw = max(1, round(w * scale_factor))
nh = max(1, round(h * scale_factor))
else:
if keep_aspect:
ratio = min(width / w, height / h)
nw = max(1, round(w * ratio))
nh = max(1, round(h * ratio))
else:
nw, nh = int(width), int(height)
result = img.resize((nw, nh), filt)
# Batch needs a consistent size for torch.stack.
if target is None:
target = result.size
elif result.size != target:
result = result.resize(target, filt)
out_batch.append(pil_to_tensor(result))
return (torch.stack(out_batch),)
class PixelArtPreviewNode:
"""Pixel art preview with crisp rendering (nearest-neighbor) and an interactive
magnifier. Hover over the image to zoom in. Output node."""
def __init__(self):
self._prefix = "pixelart_preview"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"magnifier": ("BOOLEAN", {"default": True}),
"zoom": ("INT", {"default": 6, "min": 2, "max": 32, "step": 1}),
"lens_size": ("INT", {"default": 140, "min": 40, "max": 400, "step": 10}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "preview"
OUTPUT_NODE = True
CATEGORY = "image/pixel_art"
def preview(self, images, magnifier, zoom, lens_size, prompt=None, extra_pnginfo=None):
import folder_paths
output_dir = folder_paths.get_temp_directory()
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
self._prefix, output_dir, images[0].shape[1], images[0].shape[0]
)
results = []
for image in images:
arr = (255.0 * image.cpu().numpy()).clip(0, 255).astype(np.uint8)
img = Image.fromarray(arr)
file = f"{filename}_{counter:05}_.png"
img.save(os.path.join(full_output_folder, file), compress_level=1)
results.append({"filename": file, "subfolder": subfolder, "type": "temp"})
counter += 1
return {
"ui": {
"images": results,
"magnifier": [magnifier],
"zoom": [zoom],
"lens_size": [lens_size],
}
}
NODE_CLASS_MAPPINGS = {
"PixelSnapper": PixelSnapperNode,
"VideoGamePalette": VideoGamePaletteNode,
"PixelArtResizer": PixelArtResizerNode,
"PixelArtPreview": PixelArtPreviewNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PixelSnapper": "Pixel Snapper (Engine)",
"VideoGamePalette": "Video Game Palette",
"PixelArtResizer": "Pixel Art Resizer",
"PixelArtPreview": "Pixel Art Preview",
}
+104
View File
@@ -0,0 +1,104 @@
"""Built-in video game color palettes (hex), used by the VideoGamePalette node."""
PALETTES = {
"Game Boy (DMG Green)": [
"0f380f", "306230", "8bac0f", "9bbc0f",
],
"Game Boy Pocket (Gray)": [
"000000", "555555", "aaaaaa", "ffffff",
],
"NES (Full 54)": [
"7c7c7c", "0000fc", "0000bc", "4428bc", "940084", "a80020", "a81000", "881400",
"503000", "007800", "006800", "005800", "004058", "000000", "000000", "000000",
"bcbcbc", "0078f8", "0058f8", "6844fc", "d800cc", "e40058", "f83800", "e45c10",
"ac7c00", "00b800", "00a800", "00a844", "008888", "000000", "000000", "000000",
"f8f8f8", "3cbcfc", "6888fc", "9878f8", "f878f8", "f85898", "f87858", "fca044",
"f8b800", "b8f818", "58d854", "58f898", "00e8d8", "787878", "000000", "000000",
"fcfcfc", "a4e4fc", "b8b8f8", "d8b8f8", "f8b8f8", "f8a4c0", "f0d0b0", "fce0a8",
"f8d878", "d8f878", "b8f8b8", "b8f8d8", "00fcfc", "f8d8f8", "000000", "000000",
],
"PICO-8": [
"000000", "1D2B53", "7E2553", "008751", "AB5236", "5F574F", "C2C3C7", "FFF1E8",
"FF004D", "FFA300", "FFEC27", "00E436", "29ADFF", "83769C", "FF77A8", "FFCCAA",
],
"CGA (Palette 1 High)": [
"000000", "55ffff", "ff55ff", "ffffff",
],
"CGA / EGA 16": [
"000000", "0000aa", "00aa00", "00aaaa", "aa0000", "aa00aa", "aa5500", "aaaaaa",
"555555", "5555ff", "55ff55", "55ffff", "ff5555", "ff55ff", "ffff55", "ffffff",
],
"Sweetie 16": [
"1a1c2c", "5d275d", "b13e53", "ef7d57", "ffcd75", "a7f070", "38b764", "257179",
"29366f", "3b5dc9", "41a6f6", "73eff7", "f4f4f4", "94b0c2", "566c86", "333c57",
],
"DawnBringer 16": [
"140c1c", "442434", "30346d", "4e4a4e", "854c30", "346524", "d04648", "757161",
"597dce", "d27d2c", "8595a1", "6daa2c", "d2aa99", "6dc2ca", "dad45e", "deeed6",
],
"DawnBringer 32": [
"000000", "222034", "45283c", "663931", "8f563b", "df7126", "d9a066", "eec39a",
"fbf236", "99e550", "6abe30", "37946e", "4b692f", "524b24", "323c39", "3f3f74",
"306082", "5b6ee1", "639bff", "5fcde4", "cbdbfc", "ffffff", "9badb7", "847e87",
"696a6a", "595652", "76428a", "ac3232", "d95763", "d77bba", "8f974a", "8a6f30",
],
"Commodore 64": [
"000000", "ffffff", "880000", "aaffee", "cc44cc", "00cc55", "0000aa", "eeee77",
"dd8855", "664400", "ff7777", "333333", "777777", "aaff66", "0088ff", "bbbbbb",
],
"ZX Spectrum": [
"000000", "0000d7", "d70000", "d700d7", "00d700", "00d7d7", "d7d700", "d7d7d7",
"0000ff", "ff0000", "ff00ff", "00ff00", "00ffff", "ffff00", "ffffff",
],
"Endesga 32": [
"be4a2f", "d77643", "ead4aa", "e4a672", "b86f50", "733e39", "3e2731", "a22633",
"e43b44", "f77622", "feae34", "fee761", "63c74d", "3e8948", "265c42", "193c3e",
"124e89", "0099db", "2ce8f5", "ffffff", "c0cbdc", "8b9bb4", "5a6988", "3a4466",
"262b44", "181425", "ff0044", "68386c", "b55088", "f6757a", "e8b796", "c28569",
],
"Resurrect 64": [
"2e222f", "3e3546", "625565", "966c6c", "ab947a", "694f62", "7f708a", "9babb2",
"c7dcd0", "ffffff", "6e2727", "b33831", "ea4f36", "f57d4a", "ae2334", "e83b3b",
"fb6b1d", "f79617", "f9c22b", "7a3045", "9e4539", "cd683d", "e6904e", "fbb954",
"4c3e24", "676633", "a2a947", "d5e04b", "fbff86", "165a4c", "239063", "1ebc73",
"91db69", "cddf6c", "313638", "374e4a", "547e64", "92a984", "b2ba90", "0b5e65",
"0b8a8f", "0eaf9b", "30e1b9", "8ff8e2", "323353", "484a77", "4d65b4", "4d9be6",
"8fd3ff", "45293f", "6b3e75", "905ea9", "a884f3", "eaaded", "753c54", "a24b6f",
"cf657f", "ed8099", "831c5d", "c32454", "f04f78", "f68181", "fca790", "fdcbb0",
],
"SLSO8": [
"0d2b45", "203c56", "544e68", "8d697a", "d08159", "ffaa5e", "ffd4a3", "ffecd6",
],
"Oil 6": [
"fbf5ef", "f2d3ab", "c69fa5", "8b6d9c", "494d7e", "272744",
],
"1-Bit Monitor Glow": [
"222323", "f0f6f0",
],
"2-Bit Demichrome": [
"211e20", "555568", "a0a08b", "e9efec",
],
"Ice Cream GB": [
"7c3f58", "eb6b6f", "f9a875", "fff6d3",
],
"Kirokaze Gameboy": [
"332c50", "46878f", "94e344", "e2f3e4",
],
"Rustic GB": [
"2c2137", "764462", "a96868", "edb4a1",
],
"Mist GB": [
"2d1b00", "1e606e", "5ab9a8", "c4f0c2",
],
}
PALETTE_NAMES = list(PALETTES.keys())
def hex_to_rgb(h):
h = h.lstrip("#")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def get_palette_rgb(name):
return [hex_to_rgb(h) for h in PALETTES[name]]
Binary file not shown.
+166
View File
@@ -0,0 +1,166 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
function viewURL(img) {
const p = new URLSearchParams();
p.set("filename", img.filename);
p.set("type", img.type || "temp");
p.set("subfolder", img.subfolder || "");
p.set("t", "" + (+new Date()));
return api.apiURL(`/view?${p.toString()}`);
}
app.registerExtension({
name: "PixelArt.PreviewMagnifier",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "PixelArtPreview") return;
// Remove the blurry preview the core draws; we use our own crisp widget.
nodeType.prototype.onDrawBackground = function () {};
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
const self = this;
const container = document.createElement("div");
Object.assign(container.style, {
position: "relative",
width: "100%",
height: "100%",
minHeight: "120px",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#111",
borderRadius: "6px",
cursor: "crosshair",
});
const img = document.createElement("img");
Object.assign(img.style, {
maxWidth: "100%",
maxHeight: "100%",
imageRendering: "pixelated",
display: "none",
});
container.appendChild(img);
const hint = document.createElement("div");
hint.textContent = "no image — run the workflow";
Object.assign(hint.style, {
color: "#666",
fontSize: "11px",
fontFamily: "monospace",
pointerEvents: "none",
});
container.appendChild(hint);
const lens = document.createElement("div");
Object.assign(lens.style, {
position: "absolute",
boxSizing: "border-box",
border: "2px solid #fff",
borderRadius: "50%",
boxShadow: "0 0 0 1px #000, 0 2px 10px rgba(0,0,0,.7)",
pointerEvents: "none",
display: "none",
backgroundRepeat: "no-repeat",
backgroundOrigin: "border-box",
backgroundClip: "border-box",
imageRendering: "pixelated",
zIndex: "10",
});
container.appendChild(lens);
const getW = (name, def) => {
const w = self.widgets && self.widgets.find((x) => x.name === name);
return w ? w.value : def;
};
const onMove = (e) => {
if (img.style.display === "none" || !getW("magnifier", true)) {
lens.style.display = "none";
return;
}
const rect = img.getBoundingClientRect(); // screen space (graph zoom applied)
const iw = img.offsetWidth || rect.width; // local (untransformed) px
const ih = img.offsetHeight || rect.height;
// Screen->local scale from the ComfyUI graph transform.
const scale = rect.width / iw || 1;
// Mouse position over the image, in the image's LOCAL pixels.
const xLocal = (e.clientX - rect.left) / scale;
const yLocal = (e.clientY - rect.top) / scale;
if (xLocal < 0 || yLocal < 0 || xLocal > iw || yLocal > ih) {
lens.style.display = "none";
return;
}
const zoom = getW("zoom", 6);
const lensSize = getW("lens_size", 140);
const crect = container.getBoundingClientRect();
// Mouse position inside the container, also in LOCAL pixels.
const cxLocal = (e.clientX - crect.left) / scale;
const cyLocal = (e.clientY - crect.top) / scale;
lens.style.display = "block";
lens.style.width = lensSize + "px";
lens.style.height = lensSize + "px";
// Center the lens exactly on the cursor.
lens.style.left = (cxLocal - lensSize / 2) + "px";
lens.style.top = (cyLocal - lensSize / 2) + "px";
lens.style.backgroundImage = `url("${img.src}")`;
// Background is the image blown up in LOCAL px; put the pixel under
// the cursor at the lens center.
lens.style.backgroundSize = `${iw * zoom}px ${ih * zoom}px`;
lens.style.backgroundPosition =
`${lensSize / 2 - xLocal * zoom}px ${lensSize / 2 - yLocal * zoom}px`;
};
container.addEventListener("mousemove", onMove);
container.addEventListener("mouseleave", () => {
lens.style.display = "none";
});
img.onload = () => {
img.style.display = "block";
hint.style.display = "none";
self._paAspect = (img.naturalHeight && img.naturalWidth)
? img.naturalHeight / img.naturalWidth
: 0.75;
// Resize the node once to fit the image (no feedback loop).
const sz = self.computeSize();
self.setSize([self.size[0], sz[1]]);
app.graph.setDirtyCanvas(true, true);
};
this._paImg = img;
this._paAspect = 0.75;
const widget = this.addDOMWidget("pixelart_preview", "preview", container, {
serialize: false,
hideOnZoom: false,
});
// Height depends ONLY on width + image aspect ratio, never on the node
// height -> avoids the infinite vertical growth feedback loop.
widget.computeSize = function (width) {
const w = width || (self.size ? self.size[0] : 280);
let h = Math.round(w * (self._paAspect || 0.75));
h = Math.max(120, Math.min(h, 1024));
return [width, h];
};
return r;
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted && onExecuted.apply(this, arguments);
const imgs = message && message.images;
if (imgs && imgs.length && this._paImg) {
this._paImg.src = viewURL(imgs[imgs.length - 1]);
}
};
},
});