feat: big refactor and add ANSI support
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# ComfyUI-Mircify
|
||||
|
||||
A ComfyUI custom node for converting images into IRC art by processing them in pixel blocks and generating both image and text outputs with optimal color selection from a 99-color IRC palette.
|
||||
A ComfyUI custom node for converting images into IRC art by processing them in pixel blocks and generating image and text outputs with optimal color selection from a 99-color IRC palette. Now supports both IRC and ANSI terminal text outputs for maximum compatibility.
|
||||
|
||||
This node is used with [aibird](https://github.com/birdneststream/aibird) to convert text prompts to IRC art that is scrolled in the IRC client.
|
||||
This node is used with [aibird](https://github.com/birdneststream/aibird) to convert text prompts to IRC art that is scrolled in the IRC client, and can also output ANSI-formatted text for terminal display.
|
||||
|
||||
## Limitations
|
||||
|
||||
@@ -25,6 +25,25 @@ Pasting the results of the created `.txt` file into IRC, we should be able to se
|
||||
|
||||

|
||||
|
||||
## ANSI Terminal Output
|
||||
|
||||
The node now also generates ANSI-formatted text that can be displayed directly in terminals with 256-color support. The ANSI output uses:
|
||||
|
||||
- **256-color palette**: Maps IRC colors to the closest ANSI 256-color equivalents using RGB distance calculations
|
||||
- **Background colors with spaces**: Creates solid color blocks for consistent rendering
|
||||
- **Half-block support**: Uses the ▀ character with foreground/background colors for different top/bottom colors
|
||||
- **Automatic color reset**: Properly resets colors at the end of each line
|
||||
|
||||
To view ANSI output in a terminal:
|
||||
```bash
|
||||
cat output/irc_art/your_ansi_file.txt
|
||||
```
|
||||
|
||||
or display it with `less -R` to preserve colors:
|
||||
```bash
|
||||
less -R output/irc_art/your_ansi_file.txt
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Universal Image Support**: Works with any image dimensions, but remember the IRC 512 byte limit per line.
|
||||
@@ -35,8 +54,10 @@ Pasting the results of the created `.txt` file into IRC, we should be able to se
|
||||
- **Multiple Distance Methods**: Choose between weighted manhattan, manhattan, euclidean, and perceptual weighted distance calculations
|
||||
- **99-Color IRC Palette**: Maps colors to extended IRC color palette
|
||||
- **16-Color Compatibility Mode**: Option to use traditional 16-color IRC palette
|
||||
- **Dual Output**: Returns both processed image and mIRC-formatted text
|
||||
- **Optimized Text Output**: Compressed color codes and space-efficient formatting
|
||||
- **Dual Text Output**: Returns both IRC-formatted and ANSI-formatted text
|
||||
- **ANSI Terminal Support**: Generate ANSI escape sequences for terminal display with 256-color accuracy
|
||||
- **Intelligent Output Generation**: Only generates text outputs that are connected, optimizing performance
|
||||
- **Optimized Text Output**: Compressed color codes and space-efficient formatting for both IRC and ANSI
|
||||
- **Text File Export**: Save IRC codes directly to text files with timestamp support
|
||||
|
||||
## Installation
|
||||
@@ -73,6 +94,7 @@ Converts images into IRC art with both visual and text output.
|
||||
**Outputs:**
|
||||
- **processed_image**: Visual representation of the IRC art conversion
|
||||
- **irc_text**: mIRC-formatted text with color codes ready for IRC clients
|
||||
- **ansi_text**: ANSI-formatted text with 256-color escape sequences for terminal display
|
||||
|
||||
### IRC Text Saver
|
||||
**Category**: `text/output`
|
||||
@@ -91,7 +113,8 @@ Saves IRC-formatted text to files for easy copying.
|
||||
1. **Basic Workflow:**
|
||||
- Load an image
|
||||
- Connect it to "IRC Art Converter"
|
||||
- Connect the `irc_text` output to "IRC Text Saver"
|
||||
- Connect the `irc_text` output to "IRC Text Saver" (for IRC clients)
|
||||
- Optionally connect the `ansi_text` output to display in terminals
|
||||
- Run the workflow
|
||||
|
||||
2. **Distance Methods:**
|
||||
@@ -109,9 +132,10 @@ Saves IRC-formatted text to files for easy copying.
|
||||
- **16-color mode**: Traditional IRC colors for better compatibility
|
||||
|
||||
5. **Text Output:**
|
||||
- Uses optimized mIRC color text output
|
||||
- Ready to paste directly into IRC clients
|
||||
- Support for {TIMESTAMP} placeholder in filenames
|
||||
- **IRC Output**: Uses optimized mIRC color text output ready to paste directly into IRC clients
|
||||
- **ANSI Output**: Uses ANSI 256-color escape sequences for terminal display with accurate color mapping
|
||||
- **Performance Optimization**: Only generates text for outputs that are actually connected to other nodes
|
||||
- **Support**: {TIMESTAMP} placeholder in filenames for IRC Text Saver
|
||||
|
||||
## Color Processing
|
||||
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ name = "comfyui-mircify"
|
||||
version = "1.0.0"
|
||||
description = "ComfyUI node for converting images to IRC art blocks"
|
||||
authors = [
|
||||
{name = "ComfyUI-Mircify", email = "noreply@example.com"}
|
||||
{name = "ComfyUI-Mircify", email = "bird@birdnest.live"}
|
||||
]
|
||||
license = {text = "MIT"}
|
||||
readme = "README.md"
|
||||
@@ -21,4 +21,4 @@ dependencies = [
|
||||
keywords = ["comfyui", "irc", "art", "image-processing"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/example/ComfyUI-Mircify"
|
||||
Repository = "https://github.com/birdneststream/ComfyUI-Mircify"
|
||||
+503
-269
@@ -1,167 +1,179 @@
|
||||
"""
|
||||
IRC Art Converter Node for ComfyUI
|
||||
Converts images to IRC art with text output support for both IRC and ANSI terminals.
|
||||
"""
|
||||
|
||||
import torch
|
||||
import numpy as np
|
||||
from sklearn.cluster import KMeans
|
||||
from datetime import datetime
|
||||
import warnings
|
||||
import os
|
||||
from typing import Tuple, List, Optional, Union, Dict, Any
|
||||
|
||||
class IRCArtConverter:
|
||||
CATEGORY = "image/conversion"
|
||||
|
||||
# ============================================================================
|
||||
# CONSTANTS
|
||||
# ============================================================================
|
||||
|
||||
# 99-color IRC palette - matches Go IRCColorPalette exactly
|
||||
IRC_COLORS = [
|
||||
(255, 255, 255), (0, 0, 0), (0, 0, 127), (0, 147, 0), (255, 0, 0),
|
||||
(127, 0, 0), (156, 0, 156), (252, 127, 0), (255, 255, 0), (0, 252, 0),
|
||||
(0, 147, 147), (0, 255, 255), (0, 0, 252), (255, 0, 255), (127, 127, 127),
|
||||
(210, 210, 210), (71, 0, 0), (71, 33, 0), (71, 71, 0), (50, 71, 0),
|
||||
(0, 71, 0), (0, 71, 44), (0, 71, 71), (0, 39, 71), (0, 0, 71),
|
||||
(46, 0, 71), (71, 0, 71), (71, 0, 42), (116, 0, 0), (116, 58, 0),
|
||||
(116, 116, 0), (81, 116, 0), (0, 116, 0), (0, 116, 73), (0, 116, 116),
|
||||
(0, 64, 116), (0, 0, 116), (75, 0, 116), (116, 0, 116), (116, 0, 69),
|
||||
(181, 0, 0), (181, 99, 0), (181, 181, 0), (125, 181, 0), (0, 181, 0),
|
||||
(0, 181, 113), (0, 181, 181), (0, 99, 181), (0, 0, 181), (117, 0, 181),
|
||||
(181, 0, 181), (181, 0, 107), (255, 0, 0), (255, 140, 0), (255, 255, 0),
|
||||
(178, 255, 0), (0, 255, 0), (0, 255, 160), (0, 255, 255), (0, 140, 255),
|
||||
(0, 0, 255), (165, 0, 255), (255, 0, 255), (255, 0, 152), (255, 89, 89),
|
||||
(255, 180, 89), (255, 255, 113), (207, 255, 96), (111, 255, 111), (101, 255, 201),
|
||||
(109, 255, 255), (89, 180, 255), (89, 89, 255), (196, 89, 255), (255, 102, 255),
|
||||
(255, 89, 188), (255, 156, 156), (255, 211, 156), (255, 255, 156), (226, 255, 156),
|
||||
(156, 255, 156), (156, 255, 219), (156, 255, 255), (156, 211, 255), (156, 156, 255),
|
||||
(220, 156, 255), (255, 156, 255), (255, 148, 211), (0, 0, 0), (19, 19, 19),
|
||||
(40, 40, 40), (54, 54, 54), (77, 77, 77), (101, 101, 101), (129, 129, 129),
|
||||
(159, 159, 159), (188, 188, 188), (226, 226, 226), (255, 255, 255)
|
||||
]
|
||||
|
||||
# ANSI standard colors (0-15)
|
||||
ANSI_STANDARD_COLORS = [
|
||||
(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
|
||||
(0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
|
||||
(128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
|
||||
(0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255)
|
||||
]
|
||||
|
||||
# RGB values for 6x6x6 cube mapping
|
||||
RGB_CUBE_VALUES = [0, 95, 135, 175, 215, 255]
|
||||
|
||||
# Distance method weights
|
||||
DISTANCE_WEIGHTS = {
|
||||
'weighted_manhattan': np.array([0.7, 1.0, 0.5]), # Human visual sensitivity
|
||||
'perceptual_weighted': np.array([0.299, 0.587, 0.114]) # Luminance weights
|
||||
}
|
||||
|
||||
# Block processing defaults
|
||||
DEFAULT_BLOCK_WIDTH = 8
|
||||
DEFAULT_BLOCK_HEIGHT = 15
|
||||
HALF_BLOCK_DIVISOR = 2
|
||||
MIN_COLOR_VARIANCE = 0.01
|
||||
MAX_KMEANS_CLUSTERS = 3
|
||||
KMEANS_N_INIT = 10
|
||||
|
||||
# Character constants
|
||||
HALF_BLOCK_CHAR = "▀"
|
||||
SPACE_CHAR = " "
|
||||
ANSI_RESET = "\033[0m"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# COLOR UTILITIES
|
||||
# ============================================================================
|
||||
|
||||
class ColorUtils:
|
||||
"""Utility class for color-related operations."""
|
||||
|
||||
# 99-color IRC palette (class constant) - matches Go IRCColorPalette exactly
|
||||
IRC_COLORS = [
|
||||
(255, 255, 255), (0, 0, 0), (0, 0, 127), (0, 147, 0), (255, 0, 0),
|
||||
(127, 0, 0), (156, 0, 156), (252, 127, 0), (255, 255, 0), (0, 252, 0),
|
||||
(0, 147, 147), (0, 255, 255), (0, 0, 252), (255, 0, 255), (127, 127, 127),
|
||||
(210, 210, 210), (71, 0, 0), (71, 33, 0), (71, 71, 0), (50, 71, 0),
|
||||
(0, 71, 0), (0, 71, 44), (0, 71, 71), (0, 39, 71), (0, 0, 71),
|
||||
(46, 0, 71), (71, 0, 71), (71, 0, 42), (116, 0, 0), (116, 58, 0),
|
||||
(116, 116, 0), (81, 116, 0), (0, 116, 0), (0, 116, 73), (0, 116, 116),
|
||||
(0, 64, 116), (0, 0, 116), (75, 0, 116), (116, 0, 116), (116, 0, 69),
|
||||
(181, 0, 0), (181, 99, 0), (181, 181, 0), (125, 181, 0), (0, 181, 0),
|
||||
(0, 181, 113), (0, 181, 181), (0, 99, 181), (0, 0, 181), (117, 0, 181),
|
||||
(181, 0, 181), (181, 0, 107), (255, 0, 0), (255, 140, 0), (255, 255, 0),
|
||||
(178, 255, 0), (0, 255, 0), (0, 255, 160), (0, 255, 255), (0, 140, 255),
|
||||
(0, 0, 255), (165, 0, 255), (255, 0, 255), (255, 0, 152), (255, 89, 89),
|
||||
(255, 180, 89), (255, 255, 113), (207, 255, 96), (111, 255, 111), (101, 255, 201),
|
||||
(109, 255, 255), (89, 180, 255), (89, 89, 255), (196, 89, 255), (255, 102, 255),
|
||||
(255, 89, 188), (255, 156, 156), (255, 211, 156), (255, 255, 156), (226, 255, 156),
|
||||
(156, 255, 156), (156, 255, 219), (156, 255, 255), (156, 211, 255), (156, 156, 255),
|
||||
(220, 156, 255), (255, 156, 255), (255, 148, 211), (0, 0, 0), (19, 19, 19),
|
||||
(40, 40, 40), (54, 54, 54), (77, 77, 77), (101, 101, 101), (129, 129, 129),
|
||||
(159, 159, 159), (188, 188, 188), (226, 226, 226), (255, 255, 255)
|
||||
]
|
||||
@staticmethod
|
||||
def get_ansi_rgb(color_num: int) -> Tuple[int, int, int]:
|
||||
"""Get RGB values for ANSI 256 color number."""
|
||||
if color_num <= 7:
|
||||
return ANSI_STANDARD_COLORS[color_num]
|
||||
elif color_num <= 15:
|
||||
return ANSI_STANDARD_COLORS[color_num]
|
||||
elif color_num <= 231:
|
||||
# 6x6x6 RGB cube
|
||||
idx = color_num - 16
|
||||
r = idx // 36
|
||||
g = (idx % 36) // 6
|
||||
b = idx % 6
|
||||
return (RGB_CUBE_VALUES[r], RGB_CUBE_VALUES[g], RGB_CUBE_VALUES[b])
|
||||
else:
|
||||
# Grayscale 232-255
|
||||
gray = 8 + (color_num - 232) * 10
|
||||
return (gray, gray, gray)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"block_width": ("INT", {"default": 8, "min": 1, "max": 32}),
|
||||
"block_height": ("INT", {"default": 15, "min": 1, "max": 32}),
|
||||
"half_block_mode": ("BOOLEAN", {"default": False}),
|
||||
"use_16_colors": ("BOOLEAN", {"default": False}),
|
||||
"distance_method": (["weighted_manhattan", "manhattan", "euclidean", "perceptual_weighted"], {"default": "weighted_manhattan"}),
|
||||
}
|
||||
}
|
||||
@staticmethod
|
||||
def find_closest_ansi_color(target_rgb: Tuple[int, int, int]) -> int:
|
||||
"""Find the closest ANSI 256 color to the target RGB."""
|
||||
min_distance = float('inf')
|
||||
closest_color = 0
|
||||
|
||||
for i in range(256):
|
||||
ansi_rgb = ColorUtils.get_ansi_rgb(i)
|
||||
# Euclidean distance
|
||||
distance = sum((a - b) ** 2 for a, b in zip(target_rgb, ansi_rgb)) ** 0.5
|
||||
if distance < min_distance:
|
||||
min_distance = distance
|
||||
closest_color = i
|
||||
|
||||
return closest_color
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("processed_image", "irc_text")
|
||||
FUNCTION = "convert_to_irc_art"
|
||||
@staticmethod
|
||||
def generate_irc_to_ansi_mapping() -> List[int]:
|
||||
"""Generate IRC to ANSI color mapping based on RGB distance."""
|
||||
return [ColorUtils.find_closest_ansi_color(irc_rgb) for irc_rgb in IRC_COLORS]
|
||||
|
||||
|
||||
class DistanceCalculator:
|
||||
"""Handles various color distance calculation methods."""
|
||||
|
||||
def calculate_color_distance(self, detected_color, target_colors, method):
|
||||
"""Calculate color distance using various methods."""
|
||||
@staticmethod
|
||||
def calculate(detected_color: np.ndarray, target_colors: np.ndarray,
|
||||
method: str) -> np.ndarray:
|
||||
"""Calculate color distance using the specified method."""
|
||||
|
||||
if method == "manhattan":
|
||||
# Standard Manhattan distance (L1 norm)
|
||||
return np.sum(np.abs(detected_color - target_colors), axis=1)
|
||||
|
||||
elif method == "weighted_manhattan":
|
||||
# Weighted Manhattan distance for better perceptual matching
|
||||
# Weights based on human visual sensitivity: [R, G, B]
|
||||
weights = np.array([0.7, 1.0, 0.5])
|
||||
weights = DISTANCE_WEIGHTS['weighted_manhattan']
|
||||
diff = np.abs(detected_color - target_colors)
|
||||
return np.sum(diff * weights, axis=1)
|
||||
|
||||
elif method == "euclidean":
|
||||
# Standard Euclidean distance (L2 norm)
|
||||
return np.sqrt(np.sum((detected_color - target_colors) ** 2, axis=1))
|
||||
|
||||
elif method == "perceptual_weighted":
|
||||
# Perceptual weighted Euclidean distance
|
||||
# Based on CIE recommendations for RGB weights
|
||||
weights = np.array([0.299, 0.587, 0.114]) # Luminance weights
|
||||
weights = DISTANCE_WEIGHTS['perceptual_weighted']
|
||||
diff = (detected_color - target_colors) ** 2
|
||||
return np.sqrt(np.sum(diff * weights, axis=1))
|
||||
|
||||
else:
|
||||
# Fallback to weighted manhattan
|
||||
return self.calculate_color_distance(detected_color, target_colors, "weighted_manhattan")
|
||||
# Default fallback to weighted_manhattan
|
||||
return DistanceCalculator.calculate(detected_color, target_colors, "weighted_manhattan")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TEXT FORMATTERS
|
||||
# ============================================================================
|
||||
|
||||
class TextFormatter:
|
||||
"""Base class for text formatting operations."""
|
||||
|
||||
def apply_color_transfer(self, image, use_16_colors=False, distance_method="weighted_manhattan"):
|
||||
"""Apply color transfer using KMeans clustering and configurable distance matching."""
|
||||
# Convert image to numpy array
|
||||
img_array = (image * 255.0).cpu().numpy().astype(np.uint8)
|
||||
img_flat = img_array.reshape((-1, 3))
|
||||
|
||||
# Select color palette based on mode
|
||||
target_colors = self.IRC_COLORS[:16] if use_16_colors else self.IRC_COLORS
|
||||
|
||||
# KMeans clustering
|
||||
clustering_model = KMeans(n_clusters=len(target_colors), n_init="auto", random_state=42)
|
||||
clustering_model.fit(img_flat)
|
||||
|
||||
detected_colors = clustering_model.cluster_centers_.astype(int)
|
||||
|
||||
# Match detected colors to IRC palette using configured distance method
|
||||
target_colors_array = np.array(target_colors)
|
||||
closest_colors = []
|
||||
|
||||
for color in detected_colors:
|
||||
distances = self.calculate_color_distance(color, target_colors_array, distance_method)
|
||||
closest_colors.append(target_colors_array[np.argmin(distances)])
|
||||
|
||||
closest_colors = np.array(closest_colors)
|
||||
|
||||
# Map each pixel to its closest IRC color
|
||||
processed_image = closest_colors[clustering_model.labels_].reshape(img_array.shape)
|
||||
|
||||
# Convert back to torch tensor
|
||||
return torch.from_numpy(processed_image.astype(np.float32) / 255.0)
|
||||
|
||||
def get_block_color(self, block):
|
||||
"""Extract dominant color from a block using k-means clustering."""
|
||||
# Use k-means to find dominant color
|
||||
block_flat = block.reshape(-1, 3).cpu().numpy()
|
||||
if len(block_flat) < 2:
|
||||
return tuple(block_flat[0] * 255)
|
||||
|
||||
# Check for color variance
|
||||
color_std = np.std(block_flat, axis=0)
|
||||
if np.max(color_std) < 0.01: # Very uniform color
|
||||
return tuple(np.mean(block_flat, axis=0) * 255)
|
||||
|
||||
# Find unique colors to determine appropriate cluster count
|
||||
unique_colors = np.unique(block_flat, axis=0)
|
||||
n_clusters = min(3, len(unique_colors))
|
||||
|
||||
if n_clusters == 1:
|
||||
return tuple(unique_colors[0] * 255)
|
||||
|
||||
# Use k-means with appropriate cluster count
|
||||
try:
|
||||
import warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
|
||||
kmeans.fit(block_flat)
|
||||
@staticmethod
|
||||
def count_consecutive(items: List, index: int) -> int:
|
||||
"""Count consecutive identical entries starting from index."""
|
||||
if index >= len(items):
|
||||
return 0
|
||||
|
||||
# Find the cluster with most points
|
||||
labels = kmeans.labels_
|
||||
unique, counts = np.unique(labels, return_counts=True)
|
||||
dominant_cluster = unique[np.argmax(counts)]
|
||||
dominant_color = kmeans.cluster_centers_[dominant_cluster]
|
||||
|
||||
return tuple(dominant_color * 255)
|
||||
except:
|
||||
# Fallback to average if k-means fails
|
||||
return tuple(block.mean(dim=(0, 1)).cpu().numpy() * 255)
|
||||
|
||||
def find_irc_color_index(self, block_color, use_16_colors, distance_method="weighted_manhattan"):
|
||||
"""Find the IRC color index for a given block color."""
|
||||
# Get representative color for this block
|
||||
block_color_rounded = tuple(np.round(block_color).astype(int))
|
||||
count = 1
|
||||
current = items[index]
|
||||
|
||||
# Select palette based on mode
|
||||
palette = self.IRC_COLORS[:16] if use_16_colors else self.IRC_COLORS
|
||||
while index + count < len(items) and items[index + count] == current:
|
||||
count += 1
|
||||
|
||||
# Try exact match first (since colors should already be in IRC palette)
|
||||
try:
|
||||
return palette.index(block_color_rounded)
|
||||
except ValueError:
|
||||
# Fallback to distance matching if exact match fails
|
||||
target_colors_array = np.array(palette)
|
||||
distances = self.calculate_color_distance(np.array(block_color), target_colors_array, distance_method)
|
||||
return np.argmin(distances)
|
||||
return count
|
||||
|
||||
|
||||
class IRCFormatter(TextFormatter):
|
||||
"""Handles IRC-specific text formatting."""
|
||||
|
||||
def _format_color_code(self, fg_color, bg_color=None):
|
||||
@staticmethod
|
||||
def format_color_code(fg_color: int, bg_color: Optional[int] = None) -> str:
|
||||
"""Format IRC color code with proper padding."""
|
||||
if bg_color is None:
|
||||
bg_color = fg_color
|
||||
@@ -170,202 +182,424 @@ class IRCArtConverter:
|
||||
bg_fmt = f"{bg_color:02d}" if bg_color >= 10 else str(bg_color)
|
||||
return f"\x03{fg_fmt},{bg_fmt}"
|
||||
|
||||
def _count_consecutive(self, colors, index, is_tuple_check=False):
|
||||
"""Count consecutive identical entries starting from index."""
|
||||
count = 1
|
||||
current = colors[index]
|
||||
|
||||
while index + count < len(colors):
|
||||
next_entry = colors[index + count]
|
||||
# Check type consistency and value equality
|
||||
if (is_tuple_check and not isinstance(next_entry, tuple)) or next_entry != current:
|
||||
break
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def format_irc_line(self, line_colors):
|
||||
@staticmethod
|
||||
def format_line(line_colors: List[Union[int, Tuple[int, int]]]) -> str:
|
||||
"""Format a line of IRC color codes with compression."""
|
||||
if not line_colors:
|
||||
return ""
|
||||
|
||||
line_text = ""
|
||||
result = []
|
||||
i = 0
|
||||
|
||||
while i < len(line_colors):
|
||||
color_entry = line_colors[i]
|
||||
|
||||
if isinstance(color_entry, tuple):
|
||||
# Half-block mode: (top_color, bottom_color)
|
||||
# Half-block mode
|
||||
top_color, bottom_color = color_entry
|
||||
|
||||
if top_color == bottom_color:
|
||||
# Same color - compress consecutive identical blocks
|
||||
count = self._count_consecutive(line_colors, i, is_tuple_check=True)
|
||||
line_text += self._format_color_code(top_color) + (" " * count)
|
||||
# Compress consecutive identical blocks
|
||||
count = 1
|
||||
while (i + count < len(line_colors) and
|
||||
isinstance(line_colors[i + count], tuple) and
|
||||
line_colors[i + count] == color_entry):
|
||||
count += 1
|
||||
|
||||
result.append(IRCFormatter.format_color_code(top_color) + (SPACE_CHAR * count))
|
||||
i += count
|
||||
else:
|
||||
# Different colors - use half block character
|
||||
line_text += self._format_color_code(top_color, bottom_color) + "▀"
|
||||
result.append(IRCFormatter.format_color_code(top_color, bottom_color) + HALF_BLOCK_CHAR)
|
||||
i += 1
|
||||
else:
|
||||
# Full-block mode: single color
|
||||
count = self._count_consecutive(line_colors, i)
|
||||
line_text += self._format_color_code(color_entry) + (" " * count)
|
||||
# Full-block mode
|
||||
count = IRCFormatter.count_consecutive(line_colors, i)
|
||||
result.append(IRCFormatter.format_color_code(color_entry) + (SPACE_CHAR * count))
|
||||
i += count
|
||||
|
||||
return line_text
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
class ANSIFormatter(TextFormatter):
|
||||
"""Handles ANSI terminal text formatting."""
|
||||
|
||||
def process_block(self, color_transferred_img, x, y, block_width, block_height,
|
||||
actual_block_height, width, height, use_16_colors, distance_method):
|
||||
"""Process a single block and return its color index."""
|
||||
# Calculate block boundaries
|
||||
def __init__(self, irc_to_ansi_mapping: List[int]):
|
||||
self.mapping = irc_to_ansi_mapping
|
||||
|
||||
def get_ansi_color(self, irc_idx: int) -> int:
|
||||
"""Get ANSI color for IRC color index with bounds checking."""
|
||||
if 0 <= irc_idx < len(self.mapping):
|
||||
return self.mapping[irc_idx]
|
||||
return self.mapping[0] # Default to first color
|
||||
|
||||
def format_background(self, ansi_color: int, count: int = 1) -> str:
|
||||
"""Format ANSI background color with spaces."""
|
||||
return f"\033[48;5;{ansi_color}m" + (SPACE_CHAR * count)
|
||||
|
||||
def format_half_block(self, top_ansi: int, bottom_ansi: int) -> str:
|
||||
"""Format ANSI half-block with foreground and background colors."""
|
||||
return f"\033[38;5;{top_ansi};48;5;{bottom_ansi}m{HALF_BLOCK_CHAR}"
|
||||
|
||||
def format_line(self, line_colors: List[Union[int, Tuple[int, int]]]) -> str:
|
||||
"""Format a line of ANSI color codes with compression."""
|
||||
if not line_colors:
|
||||
return ""
|
||||
|
||||
result = []
|
||||
i = 0
|
||||
|
||||
while i < len(line_colors):
|
||||
color_entry = line_colors[i]
|
||||
|
||||
if isinstance(color_entry, tuple):
|
||||
# Half-block mode
|
||||
top_color, bottom_color = color_entry
|
||||
|
||||
if top_color == bottom_color:
|
||||
# Same color - compress
|
||||
count = 1
|
||||
while (i + count < len(line_colors) and
|
||||
isinstance(line_colors[i + count], tuple) and
|
||||
line_colors[i + count] == color_entry):
|
||||
count += 1
|
||||
|
||||
ansi_color = self.get_ansi_color(top_color)
|
||||
result.append(self.format_background(ansi_color, count))
|
||||
i += count
|
||||
else:
|
||||
# Different colors - half block
|
||||
top_ansi = self.get_ansi_color(top_color)
|
||||
bottom_ansi = self.get_ansi_color(bottom_color)
|
||||
result.append(self.format_half_block(top_ansi, bottom_ansi))
|
||||
i += 1
|
||||
else:
|
||||
# Full-block mode
|
||||
count = ANSIFormatter.count_consecutive(line_colors, i)
|
||||
ansi_color = self.get_ansi_color(color_entry)
|
||||
result.append(self.format_background(ansi_color, count))
|
||||
i += count
|
||||
|
||||
result.append(ANSI_RESET)
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# IMAGE PROCESSING
|
||||
# ============================================================================
|
||||
|
||||
class BlockProcessor:
|
||||
"""Handles block-based image processing operations."""
|
||||
|
||||
@staticmethod
|
||||
def get_dominant_color(block: torch.Tensor) -> Tuple[int, int, int]:
|
||||
"""Extract dominant color from a block using k-means clustering."""
|
||||
block_flat = block.reshape(-1, 3).cpu().numpy()
|
||||
|
||||
# Handle edge cases
|
||||
if len(block_flat) < 2:
|
||||
return tuple((block_flat[0] * 255).astype(int))
|
||||
|
||||
# Check for uniform color
|
||||
color_std = np.std(block_flat, axis=0)
|
||||
if np.max(color_std) < MIN_COLOR_VARIANCE:
|
||||
return tuple((np.mean(block_flat, axis=0) * 255).astype(int))
|
||||
|
||||
# Determine cluster count
|
||||
unique_colors = np.unique(block_flat, axis=0)
|
||||
n_clusters = min(MAX_KMEANS_CLUSTERS, len(unique_colors))
|
||||
|
||||
if n_clusters == 1:
|
||||
return tuple((unique_colors[0] * 255).astype(int))
|
||||
|
||||
# Apply k-means clustering
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=KMEANS_N_INIT)
|
||||
kmeans.fit(block_flat)
|
||||
|
||||
# Find dominant cluster
|
||||
unique, counts = np.unique(kmeans.labels_, return_counts=True)
|
||||
dominant_cluster = unique[np.argmax(counts)]
|
||||
dominant_color = kmeans.cluster_centers_[dominant_cluster]
|
||||
|
||||
return tuple((dominant_color * 255).astype(int))
|
||||
except Exception:
|
||||
# Fallback to average
|
||||
return tuple((block.mean(dim=(0, 1)).cpu().numpy() * 255).astype(int))
|
||||
|
||||
@staticmethod
|
||||
def extract_block(image: torch.Tensor, x: int, y: int,
|
||||
block_width: int, actual_block_height: float,
|
||||
img_width: int, img_height: int) -> torch.Tensor:
|
||||
"""Extract a block from the image with proper boundaries."""
|
||||
start_x = x * block_width
|
||||
end_x = min(start_x + block_width, width)
|
||||
end_x = min(start_x + block_width, img_width)
|
||||
start_y = round(y * actual_block_height)
|
||||
end_y = round(min((y + 1) * actual_block_height, height))
|
||||
end_y = round(min((y + 1) * actual_block_height, img_height))
|
||||
|
||||
# Extract block
|
||||
block = color_transferred_img[start_y:end_y, start_x:end_x, :]
|
||||
return image[start_y:end_y, start_x:end_x, :]
|
||||
|
||||
|
||||
class ColorTransfer:
|
||||
"""Handles color transfer operations to map images to IRC palette."""
|
||||
|
||||
def __init__(self, distance_calculator: DistanceCalculator):
|
||||
self.distance_calc = distance_calculator
|
||||
|
||||
def apply(self, image: torch.Tensor, use_16_colors: bool = False,
|
||||
distance_method: str = "weighted_manhattan") -> torch.Tensor:
|
||||
"""Apply color transfer using KMeans clustering and distance matching."""
|
||||
# Convert to numpy
|
||||
img_array = (image * 255.0).cpu().numpy().astype(np.uint8)
|
||||
img_flat = img_array.reshape((-1, 3))
|
||||
|
||||
# Select palette
|
||||
target_colors = IRC_COLORS[:16] if use_16_colors else IRC_COLORS
|
||||
|
||||
# Apply KMeans
|
||||
clustering_model = KMeans(n_clusters=len(target_colors), n_init="auto", random_state=42)
|
||||
clustering_model.fit(img_flat)
|
||||
|
||||
detected_colors = clustering_model.cluster_centers_.astype(int)
|
||||
|
||||
# Match to IRC palette
|
||||
target_colors_array = np.array(target_colors)
|
||||
closest_colors = []
|
||||
|
||||
for color in detected_colors:
|
||||
distances = self.distance_calc.calculate(color, target_colors_array, distance_method)
|
||||
closest_colors.append(target_colors_array[np.argmin(distances)])
|
||||
|
||||
closest_colors = np.array(closest_colors)
|
||||
|
||||
# Map pixels
|
||||
processed_image = closest_colors[clustering_model.labels_].reshape(img_array.shape)
|
||||
|
||||
return torch.from_numpy(processed_image.astype(np.float32) / 255.0)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MAIN NODE CLASSES
|
||||
# ============================================================================
|
||||
|
||||
class IRCArtConverter:
|
||||
"""Main node for converting images to IRC art."""
|
||||
|
||||
CATEGORY = "image/conversion"
|
||||
|
||||
def __init__(self):
|
||||
self.distance_calc = DistanceCalculator()
|
||||
self.color_transfer = ColorTransfer(self.distance_calc)
|
||||
self.block_processor = BlockProcessor()
|
||||
self._irc_to_ansi_mapping = None
|
||||
self._ansi_formatter = None
|
||||
self._irc_formatter = IRCFormatter()
|
||||
|
||||
@property
|
||||
def ansi_formatter(self) -> ANSIFormatter:
|
||||
"""Lazy initialization of ANSI formatter."""
|
||||
if self._ansi_formatter is None:
|
||||
if self._irc_to_ansi_mapping is None:
|
||||
self._irc_to_ansi_mapping = ColorUtils.generate_irc_to_ansi_mapping()
|
||||
self._ansi_formatter = ANSIFormatter(self._irc_to_ansi_mapping)
|
||||
return self._ansi_formatter
|
||||
|
||||
def _is_output_connected(self, prompt: Optional[Dict], node_id: Optional[str], output_index: int) -> bool:
|
||||
"""Check if a specific output is connected to another node."""
|
||||
if not prompt or not node_id:
|
||||
return True # Default to generating output if we can't determine connection status
|
||||
|
||||
try:
|
||||
# Check all nodes in the prompt to see if any use this node's output
|
||||
for node_data in prompt.values():
|
||||
if isinstance(node_data, dict) and "inputs" in node_data:
|
||||
inputs = node_data["inputs"]
|
||||
for input_value in inputs.values():
|
||||
# ComfyUI represents connections as [node_id, output_index]
|
||||
if (isinstance(input_value, list) and
|
||||
len(input_value) == 2 and
|
||||
input_value[0] == node_id and
|
||||
input_value[1] == output_index):
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
# If there's any error in parsing, default to generating the output
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"block_width": ("INT", {"default": DEFAULT_BLOCK_WIDTH, "min": 1, "max": 32}),
|
||||
"block_height": ("INT", {"default": DEFAULT_BLOCK_HEIGHT, "min": 1, "max": 32}),
|
||||
"half_block_mode": ("BOOLEAN", {"default": False}),
|
||||
"use_16_colors": ("BOOLEAN", {"default": False}),
|
||||
"distance_method": (["weighted_manhattan", "manhattan", "euclidean", "perceptual_weighted"],
|
||||
{"default": "perceptual_weighted"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING", "STRING")
|
||||
RETURN_NAMES = ("processed_image", "irc_text", "ansi_text")
|
||||
FUNCTION = "convert_to_irc_art"
|
||||
|
||||
def find_irc_color_index(self, color: Tuple[int, int, int], use_16_colors: bool,
|
||||
distance_method: str) -> int:
|
||||
"""Find the IRC color index for a given color."""
|
||||
# Select palette
|
||||
palette = IRC_COLORS[:16] if use_16_colors else IRC_COLORS
|
||||
|
||||
# Try exact match first
|
||||
color_rounded = tuple(np.round(color).astype(int))
|
||||
try:
|
||||
return palette.index(color_rounded)
|
||||
except ValueError:
|
||||
# Fallback to distance matching
|
||||
target_colors_array = np.array(palette)
|
||||
distances = self.distance_calc.calculate(np.array(color), target_colors_array, distance_method)
|
||||
return int(np.argmin(distances))
|
||||
|
||||
def process_block(self, image: torch.Tensor, x: int, y: int, params: Dict[str, Any]) -> Tuple[int, int, int]:
|
||||
"""Process a single block and return its color index and boundaries."""
|
||||
block = self.block_processor.extract_block(
|
||||
image, x, y, params['block_width'], params['actual_block_height'],
|
||||
params['width'], params['height']
|
||||
)
|
||||
|
||||
# Calculate boundaries for output
|
||||
start_y = round(y * params['actual_block_height'])
|
||||
end_y = round(min((y + 1) * params['actual_block_height'], params['height']))
|
||||
|
||||
# Get IRC color index
|
||||
if block.numel() == 0:
|
||||
return 0, start_y, end_y
|
||||
else:
|
||||
block_color = self.get_block_color(block)
|
||||
return self.find_irc_color_index(block_color, use_16_colors, distance_method), start_y, end_y
|
||||
|
||||
block_color = self.block_processor.get_dominant_color(block)
|
||||
color_idx = self.find_irc_color_index(
|
||||
block_color, params['use_16_colors'], params['distance_method']
|
||||
)
|
||||
|
||||
return color_idx, start_y, end_y
|
||||
|
||||
def _apply_color_to_output(self, output, color_idx, start_y, end_y, start_x, end_x):
|
||||
def apply_color_to_output(self, output: torch.Tensor, color_idx: int,
|
||||
start_y: int, end_y: int, start_x: int, end_x: int) -> None:
|
||||
"""Apply IRC color to output image region."""
|
||||
irc_color = self.IRC_COLORS[color_idx]
|
||||
irc_color_tensor = torch.tensor(irc_color, dtype=torch.float32) / 255.0
|
||||
output[start_y:end_y, start_x:end_x, :] = irc_color_tensor
|
||||
irc_color = torch.tensor(IRC_COLORS[color_idx], dtype=torch.float32) / 255.0
|
||||
output[start_y:end_y, start_x:end_x, :] = irc_color
|
||||
|
||||
def _process_half_block_row(self, color_transferred_img, y, blocks_x, blocks_y, output, params):
|
||||
"""Process a row of half-blocks."""
|
||||
def process_row(self, image: torch.Tensor, y: int, output: torch.Tensor,
|
||||
params: Dict[str, Any], half_block: bool = False) -> List[Union[int, Tuple[int, int]]]:
|
||||
"""Process a row of blocks."""
|
||||
line_colors = []
|
||||
block_width, block_height, actual_block_height = params['block_width'], params['block_height'], params['actual_block_height']
|
||||
width, height = params['width'], params['height']
|
||||
use_16_colors, distance_method = params['use_16_colors'], params['distance_method']
|
||||
blocks_x = params['blocks_x']
|
||||
|
||||
for x in range(blocks_x):
|
||||
# Process first half-block
|
||||
color_idx, start_y, end_y = self.process_block(
|
||||
color_transferred_img, x, y, block_width, block_height,
|
||||
actual_block_height, width, height, use_16_colors, distance_method
|
||||
)
|
||||
# Process first block
|
||||
color_idx1, start_y1, end_y1 = self.process_block(image, x, y, params)
|
||||
|
||||
# Apply color to image
|
||||
x_start = x * block_width
|
||||
x_end = min((x + 1) * block_width, width)
|
||||
self._apply_color_to_output(output, color_idx, start_y, end_y, x_start, x_end)
|
||||
# Apply to output
|
||||
x_start = x * params['block_width']
|
||||
x_end = min((x + 1) * params['block_width'], params['width'])
|
||||
self.apply_color_to_output(output, color_idx1, start_y1, end_y1, x_start, x_end)
|
||||
|
||||
# Process second half-block if it exists
|
||||
if y + 1 < blocks_y:
|
||||
color_idx2, start_y2, end_y2 = self.process_block(
|
||||
color_transferred_img, x, y + 1, block_width, block_height,
|
||||
actual_block_height, width, height, use_16_colors, distance_method
|
||||
)
|
||||
|
||||
# Apply color to second block
|
||||
self._apply_color_to_output(output, color_idx2, start_y2, end_y2, x_start, x_end)
|
||||
|
||||
# Store both colors as tuple (top, bottom)
|
||||
line_colors.append((color_idx, color_idx2))
|
||||
if half_block:
|
||||
# Process second half-block if exists
|
||||
if y + 1 < params['blocks_y']:
|
||||
color_idx2, start_y2, end_y2 = self.process_block(image, x, y + 1, params)
|
||||
self.apply_color_to_output(output, color_idx2, start_y2, end_y2, x_start, x_end)
|
||||
line_colors.append((color_idx1, color_idx2))
|
||||
else:
|
||||
line_colors.append((color_idx1, color_idx1))
|
||||
else:
|
||||
# Last row, only one half-block - use same color for both
|
||||
line_colors.append((color_idx, color_idx))
|
||||
line_colors.append(color_idx1)
|
||||
|
||||
return line_colors
|
||||
|
||||
def _process_full_block_row(self, color_transferred_img, y, blocks_x, output, params):
|
||||
"""Process a row of full blocks."""
|
||||
line_colors = []
|
||||
block_width, block_height, actual_block_height = params['block_width'], params['block_height'], params['actual_block_height']
|
||||
width, height = params['width'], params['height']
|
||||
use_16_colors, distance_method = params['use_16_colors'], params['distance_method']
|
||||
def convert_to_irc_art(self, image: torch.Tensor, block_width: int, block_height: int,
|
||||
half_block_mode: bool, use_16_colors: bool, distance_method: str,
|
||||
prompt=None, extra_pnginfo=None, my_unique_id=None) -> Tuple[torch.Tensor, str, str]:
|
||||
"""Main conversion function."""
|
||||
# Check which outputs are connected
|
||||
irc_connected = self._is_output_connected(prompt, my_unique_id, 1) # irc_text is output index 1
|
||||
ansi_connected = self._is_output_connected(prompt, my_unique_id, 2) # ansi_text is output index 2
|
||||
|
||||
for x in range(blocks_x):
|
||||
color_idx, start_y, end_y = self.process_block(
|
||||
color_transferred_img, x, y, block_width, block_height,
|
||||
actual_block_height, width, height, use_16_colors, distance_method
|
||||
)
|
||||
|
||||
# Apply color to image and store for text
|
||||
x_start = x * block_width
|
||||
x_end = min((x + 1) * block_width, width)
|
||||
self._apply_color_to_output(output, color_idx, start_y, end_y, x_start, x_end)
|
||||
line_colors.append(color_idx)
|
||||
|
||||
return line_colors
|
||||
|
||||
def convert_to_irc_art(self, image, block_width, block_height, half_block_mode, use_16_colors, distance_method):
|
||||
# Input validation
|
||||
# Validate input
|
||||
batch_size, height, width, channels = image.shape
|
||||
|
||||
if batch_size != 1:
|
||||
raise ValueError(f"Expected batch size 1, got {batch_size}")
|
||||
|
||||
# Get the first (and only) image from batch
|
||||
img = image[0] # Shape: (height, width, 3)
|
||||
img = image[0]
|
||||
|
||||
# Apply color transfer to map image to IRC palette
|
||||
color_transferred_img = self.apply_color_transfer(img, use_16_colors, distance_method)
|
||||
# Apply color transfer
|
||||
color_transferred_img = self.color_transfer.apply(img, use_16_colors, distance_method)
|
||||
|
||||
# Calculate number of blocks
|
||||
# Calculate block dimensions
|
||||
blocks_x = width // block_width
|
||||
|
||||
if half_block_mode:
|
||||
blocks_y = (height * 2) // block_height
|
||||
actual_block_height = block_height / 2
|
||||
actual_block_height = block_height / HALF_BLOCK_DIVISOR
|
||||
else:
|
||||
blocks_y = height // block_height
|
||||
actual_block_height = block_height
|
||||
actual_block_height = float(block_height)
|
||||
|
||||
# Create output image and text lines
|
||||
output = torch.zeros_like(img)
|
||||
text_lines = []
|
||||
|
||||
# Bundle parameters for cleaner function calls
|
||||
# Prepare parameters
|
||||
params = {
|
||||
'block_width': block_width,
|
||||
'block_height': block_height,
|
||||
'actual_block_height': actual_block_height,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'blocks_x': blocks_x,
|
||||
'blocks_y': blocks_y,
|
||||
'use_16_colors': use_16_colors,
|
||||
'distance_method': distance_method
|
||||
}
|
||||
|
||||
# Process blocks
|
||||
# Process image
|
||||
output = torch.zeros_like(img)
|
||||
irc_lines = []
|
||||
ansi_lines = []
|
||||
|
||||
if half_block_mode:
|
||||
# Half-block mode: process in pairs
|
||||
# Process in pairs for half-block mode
|
||||
for y in range(0, blocks_y, 2):
|
||||
line_colors = self._process_half_block_row(color_transferred_img, y, blocks_x, blocks_y, output, params)
|
||||
text_lines.append(self.format_irc_line(line_colors))
|
||||
line_colors = self.process_row(color_transferred_img, y, output, params, half_block=True)
|
||||
if irc_connected:
|
||||
irc_lines.append(self._irc_formatter.format_line(line_colors))
|
||||
if ansi_connected:
|
||||
ansi_lines.append(self.ansi_formatter.format_line(line_colors))
|
||||
else:
|
||||
# Full block mode
|
||||
# Process full blocks
|
||||
for y in range(blocks_y):
|
||||
line_colors = self._process_full_block_row(color_transferred_img, y, blocks_x, output, params)
|
||||
text_lines.append(self.format_irc_line(line_colors))
|
||||
line_colors = self.process_row(color_transferred_img, y, output, params, half_block=False)
|
||||
if irc_connected:
|
||||
irc_lines.append(self._irc_formatter.format_line(line_colors))
|
||||
if ansi_connected:
|
||||
ansi_lines.append(self.ansi_formatter.format_line(line_colors))
|
||||
|
||||
# Join all lines with newlines
|
||||
irc_text = "\n".join(text_lines)
|
||||
# Format output
|
||||
irc_text = "\n".join(irc_lines) if irc_connected else ""
|
||||
ansi_text = "\n".join(ansi_lines) if ansi_connected else ""
|
||||
|
||||
# Return as batch of 1
|
||||
return (output.unsqueeze(0), irc_text)
|
||||
return (output.unsqueeze(0), irc_text, ansi_text)
|
||||
|
||||
|
||||
class IRCTextSaver:
|
||||
"""Node to save IRC formatted text to a file"""
|
||||
"""Node to save IRC formatted text to a file."""
|
||||
|
||||
CATEGORY = "text/output"
|
||||
OUTPUT_DIR = "output/irc_art"
|
||||
DEFAULT_FILENAME = "irc_art_{TIMESTAMP}.txt"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"irc_text": ("STRING", {"forceInput": True}),
|
||||
"filename": ("STRING", {"default": "irc_art_{TIMESTAMP}.txt"}),
|
||||
"filename": ("STRING", {"default": cls.DEFAULT_FILENAME}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,44 +608,44 @@ class IRCTextSaver:
|
||||
FUNCTION = "save_irc_text"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def save_irc_text(self, irc_text, filename):
|
||||
"""Save IRC text to a file"""
|
||||
import os
|
||||
|
||||
# Generate current timestamp
|
||||
def save_irc_text(self, irc_text: str, filename: str) -> Dict[str, Any]:
|
||||
"""Save IRC text to a file."""
|
||||
# Generate timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
# Replace {TIMESTAMP} placeholder in filename if present
|
||||
# Process filename
|
||||
if "{TIMESTAMP}" in filename:
|
||||
filename = filename.replace("{TIMESTAMP}", timestamp)
|
||||
|
||||
# Ensure filename has .txt extension
|
||||
if not filename.endswith('.txt'):
|
||||
filename += '.txt'
|
||||
|
||||
# Create output directory if it doesn't exist
|
||||
output_dir = "output/irc_art"
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
# Create output directory
|
||||
os.makedirs(self.OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
# Create full file path
|
||||
file_path = os.path.join(output_dir, filename)
|
||||
# Save file
|
||||
file_path = os.path.join(self.OUTPUT_DIR, filename)
|
||||
|
||||
# Write the IRC text to file
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(irc_text)
|
||||
|
||||
abs_path = os.path.abspath(file_path)
|
||||
success_msg = f"✅ IRC text saved to: {abs_path}"
|
||||
success_msg = f"IRC text saved to: {abs_path}"
|
||||
print(success_msg)
|
||||
|
||||
return {"ui": {"text": [success_msg]}, "result": (abs_path,)}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ Error saving file: {str(e)}"
|
||||
error_msg = f"Error saving file: {str(e)}"
|
||||
print(error_msg)
|
||||
return {"ui": {"text": [error_msg]}, "result": (error_msg,)}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NODE REGISTRATION
|
||||
# ============================================================================
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"IRC Art Converter": IRCArtConverter,
|
||||
"IRC Text Saver": IRCTextSaver,
|
||||
|
||||
Reference in New Issue
Block a user