commit c33dac0c7fca13f99323a67f7327676c08ef7577 Author: HeX Date: Mon Jul 6 16:15:07 2026 -0300 Add files via upload diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d8929a --- /dev/null +++ b/README.md @@ -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 +``` + +--- + +## 📦 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. + +--- + + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..ba51731 --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/docs/Workflow.json b/docs/Workflow.json new file mode 100644 index 0000000..68e4694 --- /dev/null +++ b/docs/Workflow.json @@ -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 +} \ No newline at end of file diff --git a/docs/preview_no_workflow.png b/docs/preview_no_workflow.png new file mode 100644 index 0000000..01fc565 Binary files /dev/null and b/docs/preview_no_workflow.png differ diff --git a/docs/test_img.png b/docs/test_img.png new file mode 100644 index 0000000..9f88935 Binary files /dev/null and b/docs/test_img.png differ diff --git a/docs/workflow.png b/docs/workflow.png new file mode 100644 index 0000000..01fc565 Binary files /dev/null and b/docs/workflow.png differ diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..376e9f2 --- /dev/null +++ b/nodes.py @@ -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", +} diff --git a/palettes.py b/palettes.py new file mode 100644 index 0000000..6362c17 --- /dev/null +++ b/palettes.py @@ -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]] diff --git a/spritefusion-pixel-snapper.exe b/spritefusion-pixel-snapper.exe new file mode 100644 index 0000000..924a1b2 Binary files /dev/null and b/spritefusion-pixel-snapper.exe differ diff --git a/web/pixelart_preview.js b/web/pixelart_preview.js new file mode 100644 index 0000000..1e35e08 --- /dev/null +++ b/web/pixelart_preview.js @@ -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]); + } + }; + }, +});