KEMBAR78
Bot Py | PDF | Rgb Color Model | Imaging
0% found this document useful (0 votes)
3 views35 pages

Bot Py

The document outlines a Python script for a Discord bot that processes images, specifically focusing on removing green backgrounds and detecting shoes in images. It includes features such as managing process instances, handling image uploads from Discord channels, and applying various image processing techniques using the PIL library. Key functionalities include green removal, shoe detection, and image tiling, along with logging and configuration settings for the bot's operation.
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
3 views35 pages

Bot Py

The document outlines a Python script for a Discord bot that processes images, specifically focusing on removing green backgrounds and detecting shoes in images. It includes features such as managing process instances, handling image uploads from Discord channels, and applying various image processing techniques using the PIL library. Key functionalities include green removal, shoe detection, and image tiling, along with logging and configuration settings for the bot's operation.
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
You are on page 1/ 35

# bot.

py
# Actualizado: fix para que pants no usen detección de zapatos por defecto,
# mejora de detect_shoes_mask, tile_texture offset ajustado (texturas de pants
bajadas 25%),
# eliminación verde aplicada en todos los pasos, preservación de la mayoría del
código original.

import os
import sys
import signal
import discord
from discord.ext import commands
from PIL import Image, ImageChops, ImageFilter, ImageOps, ImageEnhance
import aiohttp, asyncio, io, random, logging, traceback
from collections import Counter
from datetime import datetime, timezone
import typing

# ---------------- PID LOCK ----------------


PIDFILE = os.path.join(os.getcwd(), ".shirtbot.pid")
def _is_process_running(pid: int) -> bool:
try:
os.kill(pid, 0)
except OSError:
return False
else:
return True

if os.path.exists(PIDFILE):
try:
with open(PIDFILE, "r") as f:
old_pid = int(f.read().strip())
if _is_process_running(old_pid):
print(f"Ya hay una instancia corriendo con PID {old_pid}. Aborto para
evitar duplicados.")
sys.exit(1)
else:
try:
os.remove(PIDFILE)
except Exception:
pass
except Exception:
try:
os.remove(PIDFILE)
except Exception:
pass

try:
with open(PIDFILE, "w") as f:
f.write(str(os.getpid()))
except Exception as e:
print("No pude crear pidfile:", e)

def _cleanup_and_exit(*_):
try:
if os.path.exists(PIDFILE):
os.remove(PIDFILE)
except Exception:
pass
sys.exit(0)

signal.signal(signal.SIGINT, _cleanup_and_exit)
signal.signal(signal.SIGTERM, _cleanup_and_exit)
# ------------------------------------------

# ========== CONFIG ==========


TOKEN = "MTQwNjA1NjM3MjM2OTQ5NDE1OA.GLri0f.b3iKc4sFLIKiZ_xKCYGMQ2ky4bMkCHBcFZoJ2M"
# <-- PON AQUÍ TU TOKEN
CHANNEL_BASES_SHIRT = 1405697239917002833
CHANNEL_BASES_PANTS = 1405697310154817637
CHANNEL_TEXTURES = 1405698290347151441
CHANNEL_TEXTURES_B = 1407990992593879050
CHANNEL_TEXTURES_REALISTIC = 1410661616944677004 # 'Tela'
TRASH_CHANNEL = 1407367161239965757
OVERLAY_CHANNEL = 1405697378358526144 # overlay obligatorio

HISTORY_LIMIT = 300 # aumentar pool para reducir repeticiones


MASK_TOLERANCE = 25
IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp")

# Reference images (optional)


REFERENCE_GREEN_PATH = "reference_remove_green.png"
REFERENCE_ZAPAT_PATH = "reference_zapat_red.png"

# GREEN REMOVAL params


GREEN_MIN = 100
GREEN_TOL = 40

# No cooldowns
CD_SHIRT = 0.50
CD_PANTS = 0.5
CD_CONJUNTO = 0.5

# Name tags (editable list)


NAME_TAGS = ["hola", "adios", "bye"]

# Color-button -> channel mapping


COLOR_TO_CHANNEL = {
"yellow": 1411530251539976274,
"blue": 1411530290660118589,
"green": 1411530340043980943,
"red": 1411530375322275954,
"white": 1411530455286808696,
"black": 1411530490921357342,
"pink": 1411530988336713829,
"cyan": 1411531023342243921,
"purple": 1411531124521566270,
"grey": 1411622198518743091,
"orange": 1411622239073341510,
"brown": 1411622286737277049,
}

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %


(message)s")
log = logging.getLogger("shirtbot")

intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
intents.messages = True
intents.members = True

bot = commands.Bot(command_prefix="!", intents=intents)

# runtime storages
user_last_result_msg: dict[int, discord.Message] = {}
TEST_MODE: dict[int, bool] = {}

# ---------------- Helpers (HTTP / files) ----------------


async def get_channel_safe(channel_id: int):
ch = bot.get_channel(channel_id)
if ch is None:
try:
ch = await bot.fetch_channel(channel_id)
except Exception as e:
log.warning(f"fetch_channel fallo {channel_id}: {e}")
return None
return ch

async def list_image_attachments_in_channel(channel_id: int, limit: int =


HISTORY_LIMIT) -> list[tuple[str, str]]:
ch = await get_channel_safe(channel_id)
if ch is None:
return []
found = []
try:
async for msg in ch.history(limit=limit):
for att in msg.attachments:
fn = (att.filename or "").lower()
if any(fn.endswith(ext) for ext in IMAGE_EXTS):
found.append((att.url, att.filename or "attachment"))
except Exception as e:
log.warning(f"Error leyendo historial canal {channel_id}: {e}")
random.shuffle(found)
return found

async def fetch_random_attachment_bytes_nonrepeat(channel_id: int, exclude_urls:


set[str] | None = None, limit: int = HISTORY_LIMIT) -> tuple[bytes | None, str |
None]:
exclude_urls = exclude_urls or set()
pool = await list_image_attachments_in_channel(channel_id, limit=limit)
if not pool:
return None, None
for url, fn in pool:
if url not in exclude_urls:
b = await download_image_bytes_once(url)
if b:
return b, url
for url, fn in pool:
b = await download_image_bytes_once(url)
if b:
return b, url
return None, None

async def download_image_bytes_once(url: str) -> bytes | None:


if not url:
return None
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status == 200:
return await resp.read()
else:
log.warning(f"Descarga {url} devolvió status {resp.status}")
except Exception as e:
log.warning(f"Error descargando {url}: {e}")
return None

async def get_random_overlay_bytes() -> bytes | None:


pair = await fetch_random_attachment_bytes_nonrepeat(OVERLAY_CHANNEL,
limit=HISTORY_LIMIT)
if not pair:
return None
b = pair[0]
try:
cleaned = remove_green_from_bytes(b)
return cleaned
except Exception:
return b

# ---------------- Reference mask loaders ----------------


def _load_reference_mask(path: str) -> tuple[Image.Image | None, tuple[int, int] |
None]:
if not path or not os.path.exists(path):
return None, None
try:
ref = Image.open(path).convert("RGBA")
w, h = ref.size
if os.path.basename(path).lower().startswith("reference_remove_green") or
"green" in path.lower():
mask = Image.new("L", (w, h), 0)
pixels = list(ref.getdata())
mask_pixels = []
for (r, g, b, a) in pixels:
if g > r + GREEN_TOL and g > b + GREEN_TOL and g > GREEN_MIN:
mask_pixels.append(255)
else:
mask_pixels.append(0)
mask.putdata(mask_pixels)
mask = mask.filter(ImageFilter.GaussianBlur(1))
return mask, (w, h)
else:
mask = Image.new("L", (w, h), 0)
pixels = list(ref.getdata())
mask_pixels = []
for (r, g, b, a) in pixels:
if r > g + 60 and r > b + 60 and r > 120:
mask_pixels.append(255)
else:
mask_pixels.append(0)
mask.putdata(mask_pixels)
mask = mask.filter(ImageFilter.GaussianBlur(1))
return mask, (w, h)
except Exception as e:
log.warning(f"Error cargando referencia {path}: {e}")
return None, None

_ref_green_mask, _ref_green_size = _load_reference_mask(REFERENCE_GREEN_PATH)


_ref_zapat_mask, _ref_zapat_size = _load_reference_mask(REFERENCE_ZAPAT_PATH)

# ---------------- GREEN REMOVAL UTILITIES ----------------


def remove_green_from_pil_image(img: Image.Image, green_min: int = GREEN_MIN, tol:
int = GREEN_TOL) -> Image.Image:
img = img.convert("RGBA")
pixels = list(img.getdata())
new_pixels = []
for (r, g, b, a) in pixels:
if g > r + tol and g > b + tol and g > green_min:
new_pixels.append((r, g, b, 0))
else:
new_pixels.append((r, g, b, a))
img.putdata(new_pixels)
return img

def remove_green_from_bytes(b: bytes, green_min: int = GREEN_MIN, tol: int =


GREEN_TOL) -> bytes:
try:
base_img = Image.open(io.BytesIO(b)).convert("RGBA")
except Exception:
return b

if _ref_green_mask is not None and _ref_green_size is not None:


try:
ref_mask = _ref_green_mask
ref_size = _ref_green_size
if base_img.size != ref_size:
resized = base_img.resize(ref_size,
resample=Image.Resampling.LANCZOS)
base_pixels = list(resized.getdata())
mask_pixels = list(ref_mask.getdata())
new_pixels = []
for (r, g, bl, a), m in zip(base_pixels, mask_pixels):
if m >= 250:
new_pixels.append((r, g, bl, 0))
else:
new_pixels.append((r, g, bl, a))
resized.putdata(new_pixels)
final = resized.resize(base_img.size,
resample=Image.Resampling.LANCZOS)
out = io.BytesIO()
final.save(out, "PNG")
out.seek(0)
return out.getvalue()
else:
base_pixels = list(base_img.getdata())
mask_pixels = list(ref_mask.getdata())
new_pixels = []
for (r, g, bl, a), m in zip(base_pixels, mask_pixels):
if m >= 250:
new_pixels.append((r, g, bl, 0))
else:
new_pixels.append((r, g, bl, a))
base_img.putdata(new_pixels)
out = io.BytesIO()
base_img.save(out, "PNG")
out.seek(0)
return out.getvalue()
except Exception as e:
log.warning(f"Error aplicando referencia green: {e}; fallback a
umbral")

try:
cleaned = remove_green_from_pil_image(base_img, green_min=green_min,
tol=tol)
out = io.BytesIO()
cleaned.save(out, "PNG")
out.seek(0)
return out.getvalue()
except Exception:
return b

# ---------------- Image utilities (PIL) ----------------


def detect_mask_from_base(base_img: Image.Image, tol: int = MASK_TOLERANCE):
base = base_img.convert("RGBA")
w, h = base.size
alpha = base.split()[3]
min_a, max_a = alpha.getextrema()
if not (min_a == 255 and max_a == 255):
mask = alpha.convert("L").point(lambda p: 255 if p > 0 else 0)
mask =
mask.filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.MinFilter(1))
opaque_mask = alpha.point(lambda p: 255 if p >= 250 else 0).convert("L")
translucent_mask = alpha.point(lambda p: 255 if 0 < p < 250 else
0).convert("L")
opaque_mask =
opaque_mask.filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.MinFilter(1))
translucent_mask =
translucent_mask.filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.MinFilter(1))
translucent_mask = translucent_mask.filter(ImageFilter.GaussianBlur(1))
return mask.convert("L"), opaque_mask, translucent_mask
corners = [
base.getpixel((0, 0))[:3],
base.getpixel((w - 1, 0))[:3],
base.getpixel((0, h - 1))[:3],
base.getpixel((w - 1, h - 1))[:3],
]
try:
most_common = Counter(corners).most_common(1)[0][0]
except Exception:
most_common = corners[0]
solid = Image.new("RGB", base.size, most_common)
diff = ImageChops.difference(base.convert("RGB"), solid)
gray = diff.convert("L")
full_mask = gray.point(lambda p: 255 if p > tol else 0)
full_mask =
full_mask.filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.MinFilter(1))
full_mask = full_mask.filter(ImageFilter.GaussianBlur(1))
return full_mask.convert("L"), full_mask.convert("L"), Image.new("L",
base.size, 0)
def tile_texture_to_size(tex: Image.Image, target_w: int, target_h: int, offset_y:
int = 0) -> Image.Image:
"""
Tile texture to fill target. offset_y: number of pixels to shift texture
DOWNwards.
"""
try:
tex_resized = tex.resize((target_w, int(tex.height * (target_w / max(1,
tex.width)))), resample=Image.Resampling.LANCZOS)
except Exception:
tex_resized = tex.resize((target_w, target_w),
resample=Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (target_w, target_h), (0, 0, 0, 0))
y = offset_y # start with positive offset to push texture DOWN
while y < target_h:
canvas.paste(tex_resized, (0, y), tex_resized)
y += tex_resized.height
# When offset_y > 0 we might have top empty area; fill backward
y_back = offset_y - tex_resized.height
while y_back > -tex_resized.height:
canvas.paste(tex_resized, (0, y_back), tex_resized)
y_back -= tex_resized.height
return canvas

# ---------------- Improved shoe detection ----------------


def detect_shoes_mask(base_img: Image.Image,
bottom_fraction: float = 0.30,
dark_threshold: int = 100,
alpha_threshold: int = 12,
expand_px: int = 8) -> Image.Image:
"""
More conservative shoe detection:
- scans bottom_fraction of image
- finds dark connected areas with alpha
- limits to smaller regions relative to width/height
"""
base = base_img.convert("RGBA")
w, h = base.size
# adapt bottom fraction based on image height (if very short, reduce)
bottom_fraction = min(max(bottom_fraction, 0.08), 0.45)
y0 = int(h * (1.0 - bottom_fraction))
if y0 < 0:
y0 = 0
bottom = base.crop((0, y0, w, h))
alpha = bottom.split()[3]
alpha_mask = alpha.point(lambda p: 255 if p > alpha_threshold else
0).convert("L")
gray_bottom = ImageOps.grayscale(bottom)
dark_mask = gray_bottom.point(lambda p: 255 if p < dark_threshold else
0).convert("L")
candidate = ImageChops.multiply(alpha_mask, dark_mask)
# If candidate too big (covers almost whole bottom), make stricter
bbox = candidate.getbbox()
if bbox:
area = (bbox[2]-bbox[0]) * (bbox[3]-bbox[1])
total = w * (h - y0)
if area > total * 0.75:
# tighten thresholds: lower dark_threshold and smaller region
dark_mask = gray_bottom.point(lambda p: 255 if p < (dark_threshold -
20) else 0).convert("L")
candidate = ImageChops.multiply(alpha_mask, dark_mask)
if candidate.getbbox() is None:
candidate = alpha_mask.copy()
full_mask = Image.new("L", (w, h), 0)
full_mask.paste(candidate, (0, y0))
try:
steps = max(1, expand_px // 2)
for _ in range(steps):
full_mask = full_mask.filter(ImageFilter.MaxFilter(3))
full_mask = full_mask.filter(ImageFilter.GaussianBlur(max(1, expand_px /
3)))
except Exception:
try:
full_mask = full_mask.filter(ImageFilter.GaussianBlur(2))
except Exception:
pass
final = full_mask.point(lambda p: 255 if p > 60 else 0).convert("L")
# enforce a size limit: if mask area > 40% of full image, shrink by erode
pixels = list(final.getdata())
area = sum(1 for v in pixels if v > 0)
if area > (w*h)*0.40:
# erode a bit
for _ in range(2):
final = final.filter(ImageFilter.MinFilter(3))
return final

# ---------------- ZAPAT mask generator ----------------


def generate_zapat_mask_for_base(base_img: Image.Image) -> Image.Image | None:
global _ref_zapat_mask, _ref_zapat_size
if _ref_zapat_mask is not None and _ref_zapat_size is not None:
try:
ref = _ref_zapat_mask
ref_size = _ref_zapat_size
if base_img.size != ref_size:
resized_ref = ref.resize(base_img.size,
resample=Image.Resampling.LANCZOS)
else:
resized_ref = ref
return resized_ref.convert("L")
except Exception:
pass
# fallback: detect red-ish areas
try:
base = base_img.convert("RGBA")
rgb = base.convert("RGB")
pixels = list(rgb.getdata())
mask = Image.new("L", base.size, 0)
mpix = []
for (r, g, b) in pixels:
if r > g + 60 and r > b + 60 and r > 120:
mpix.append(255)
else:
mpix.append(0)
mask.putdata(mpix)
mask = mask.filter(ImageFilter.GaussianBlur(1))
mask = mask.point(lambda p: 255 if p > 50 else 0).convert("L")
return mask
except Exception:
return None

# ---------------- Core texture application ----------------


def apply_texture_preserving_translucency_with_offset(base_img: Image.Image,
texture_img: Image.Image,
offset_y: int = 0,
zapat_mask: Image.Image |
None = None,
exclude_shoes: bool = False,
shoes_mask_override:
Image.Image | None = None):
"""
Apply texture tiled to base_img. offset_y shifts the tiled texture DOWN by
offset_y pixels.
exclude_shoes: if True, will detect shoes and exclude them from texture
application (used only when user requests).
zapat_mask: a mask to exclude (from Zapat button).
"""
base = base_img.convert("RGBA")
tex = texture_img.convert("RGBA")
bw, bh = base.size
tex_tiled = tile_texture_to_size(tex, bw, bh, offset_y=offset_y)
full_mask, opaque_mask, translucent_mask = detect_mask_from_base(base)

# shoes exclusion: only if explicitly requested


if exclude_shoes:
try:
shoes_mask = shoes_mask_override or detect_shoes_mask(base,
bottom_fraction=0.30, dark_threshold=100, alpha_threshold=12, expand_px=10)
if shoes_mask:
zero_img = Image.new("L", base.size, 0)
new_opaque = Image.composite(opaque_mask, zero_img,
ImageChops.invert(shoes_mask))
new_translucent = Image.composite(translucent_mask, zero_img,
ImageChops.invert(shoes_mask))
opaque_mask = new_opaque
translucent_mask = new_translucent
except Exception as e:
log.warning(f"Falló la detección de zapatos (exclude_shoes): {e}")

# zapat exclusion (explicit region)


if zapat_mask is not None:
try:
zero_img2 = Image.new("L", base.size, 0)
new_opaque2 = Image.composite(opaque_mask, zero_img2,
ImageChops.invert(zapat_mask))
new_translucent2 = Image.composite(translucent_mask, zero_img2,
ImageChops.invert(zapat_mask))
opaque_mask = new_opaque2
translucent_mask = new_translucent2
except Exception as e:
log.warning(f"Falló aplicar zapat_mask: {e}")

base_rgb = base.convert("RGB")
tex_rgb = tex_tiled.convert("RGB")
try:
multiplied = ImageChops.multiply(base_rgb, tex_rgb)
except Exception:
multiplied = ImageChops.blend(base_rgb, tex_rgb, alpha=0.6)

multiplied_rgba = multiplied.convert("RGBA")
multiplied_rgba.putalpha(Image.new("L", base.size, 255))
result = Image.new("RGBA", base.size, (0, 0, 0, 0))
result = Image.composite(multiplied_rgba, result, opaque_mask)
if translucent_mask.getbbox() is not None:
blended = Image.composite(multiplied_rgba, base.convert("RGBA"),
translucent_mask)
result = Image.composite(blended, result, translucent_mask)
combined_mask = ImageChops.lighter(opaque_mask, translucent_mask)
inverse = ImageChops.invert(combined_mask)
result = Image.composite(base.convert("RGBA"), result, inverse)
return result

def overlay_on_image_sync(base_img: Image.Image, overlay_img: Image.Image) ->


Image.Image:
base = base_img.convert("RGBA")
ov = overlay_img.convert("RGBA")
bw, bh = base.size
try:
ov_resized = ov.resize((bw, bh), resample=Image.Resampling.LANCZOS)
except Exception:
ov_resized = ov
out = base.copy()
out.paste(ov_resized, (0, 0), ov_resized)
return out

async def overlay_bytes_on_bytes(base_bytes: bytes) -> bytes:


try:
base_img = Image.open(io.BytesIO(base_bytes)).convert("RGBA")
except Exception:
return base_bytes
overlay_pair = await fetch_random_attachment_bytes_nonrepeat(OVERLAY_CHANNEL,
limit=HISTORY_LIMIT)
overlay_b = overlay_pair[0] if overlay_pair else None
if not overlay_b:
out = io.BytesIO()
base_img.save(out, "PNG")
out.seek(0)
final = remove_green_from_bytes(out.getvalue())
return final
try:
cleaned_overlay_bytes = remove_green_from_bytes(overlay_b)
overlay_img = Image.open(io.BytesIO(cleaned_overlay_bytes)).convert("RGBA")
composed = overlay_on_image_sync(base_img, overlay_img)
out = io.BytesIO()
composed.save(out, "PNG")
out.seek(0)
final_bytes = remove_green_from_bytes(out.getvalue())
return final_bytes
except Exception:
out = io.BytesIO()
base_img.save(out, "PNG")
out.seek(0)
final = remove_green_from_bytes(out.getvalue())
return final

# ---------------- TRASH upload helper ----------------


async def upload_originals_to_trash_simple(author: discord.User, originals:
list[tuple[str, bytes]]):
note = f"[{datetime.now(timezone.utc).isoformat()}] Backup orig (interactive)
por {author}"
trash_ch = await get_channel_safe(TRASH_CHANNEL)
files = []
for name, b in originals:
try:
files.append(discord.File(io.BytesIO(b), filename=name))
except Exception:
pass
if trash_ch:
try:
await trash_ch.send(content=note, files=files)
return
except Exception:
log.warning("No pude enviar originales al TRASH, intentaré enviar a
DM.")
try:
dm = author.dm_channel or await author.create_dm()
await dm.send(content=note, files=files)
except Exception:
log.warning("No pude enviar originales a TRASH ni a DM.")

# ---------------- send-to-channel helpers ----------------


async def delete_last_result_message(user_id: int):
msg = user_last_result_msg.get(user_id)
if msg:
try:
await msg.delete()
except Exception:
pass
user_last_result_msg.pop(user_id, None)

async def send_to_channel_and_store(channel: discord.TextChannel, author:


discord.User, content: str, files_bytes: list[tuple[str, bytes]], view:
discord.ui.View | None):
try:
await delete_last_result_message(author.id)
except Exception:
pass
files = []
for fn, b in files_bytes:
try:
files.append(discord.File(io.BytesIO(b), filename=fn))
except Exception:
pass
try:
sent = await channel.send(content=content, files=files, view=view)
user_last_result_msg[author.id] = sent
return sent
except Exception as e:
log.warning(f"Error enviando canal {channel} : {e}")
try:
sent = await channel.send(content=content, files=files)
user_last_result_msg[author.id] = sent
return sent
except Exception as e2:
log.error(f"Fallback channel send failed: {e2}")
return None

# ---------------- helper: extension detection ----------------


def detect_extension_from_bytes(b: bytes) -> str:
try:
img = Image.open(io.BytesIO(b))
fmt = img.format or "PNG"
return f".{fmt.lower()}"
except Exception:
return ".png"

# ---------------- UI: Interactive view & callbacks ----------------


class ImageEditView(discord.ui.View):
def __init__(self, author_id: int, base_bytes_list: list[bytes], base_ext: str,
origin_channel_id: int,
initial_texture_bytes: bytes | None = None, has_shirt: bool =
True, has_pants: bool = True):
super().__init__(timeout=None)
self.author_id = author_id
self.base_bytes_list = base_bytes_list
self.base_ext = base_ext
self.origin_channel_id = origin_channel_id
self.name_tag = random.choice(NAME_TAGS) if NAME_TAGS else "Name"

# state
self.texture_bytes: bytes | None = initial_texture_bytes
self.texture_url: str | None = None
self.seen_textures: set[str] = set()
self.base_seen_urls: list[set[str]] = [set() for _ in base_bytes_list]
self.using_realistic_channel = False
self.goth = False
self.reset_mode = False
self.shades_mode = False

# zapat (exclusion)
self.zapat_active = False
self.zapat_masks_per_base: list[Image.Image | None] = [None for _ in
base_bytes_list]

# dynamic visibility flags


self.has_shirt = has_shirt
self.has_pants = has_pants

for item in list(self.children):


if isinstance(item, discord.ui.Button):
if item.label == "Shirt" and not has_shirt:
item.disabled = True
if item.label == "Pants" and not has_pants:
item.disabled = True

def get_block_text(self):
bot_name = (bot.user.name if bot.user and getattr(bot.user, "name", None)
else "botuser")
bot_name_short = f"{bot_name} bot"
ext = self.base_ext or ".png"
return (
"{\n"
"Name Tag:\n"
f"{self.name_tag}.\n\n"
"Extensión:\n"
f"{ext}\n\n"
"Versión:\n"
f"{bot_name_short} \n\n"
"Developer:\n"
"Dizzy\n"
"}"
)

async def build_image_bytes_from_state(self) -> list[bytes] | None:


results: list[bytes] = []
for idx, base_bytes in enumerate(self.base_bytes_list):
try:
base_bytes = remove_green_from_bytes(base_bytes)
base_img = Image.open(io.BytesIO(base_bytes)).convert("RGBA")
except Exception:
return None

if self.shades_mode:
try:
alpha = base_img.split()[3]
gray = ImageOps.grayscale(base_img)
base_img = gray.convert("RGBA")
base_img.putalpha(alpha)
except Exception:
pass

zapat_mask_for_this = None
if self.zapat_active and idx < len(self.zapat_masks_per_base):
if self.zapat_masks_per_base[idx] is None:
try:
gen = generate_zapat_mask_for_base(base_img)
self.zapat_masks_per_base[idx] = gen
except Exception:
self.zapat_masks_per_base[idx] = None
zapat_mask_for_this = self.zapat_masks_per_base[idx]

base_for_texture = base_img
if self.texture_bytes:
try:
alpha = base_img.split()[3]
gray = ImageOps.grayscale(base_img).convert("RGBA")
gray.putalpha(alpha)
base_for_texture = gray
except Exception:
base_for_texture = base_img

composed = None
if self.texture_bytes:
try:
tex_img =
Image.open(io.BytesIO(self.texture_bytes)).convert("RGBA")
except Exception:
tex_img = None
if tex_img:
# for pants: offset down 25%
offset = 0
exclude_shoes_flag = False
if len(self.base_bytes_list) > 1 and idx == 1:
offset = int(base_for_texture.size[1] * 0.25) # shift DOWN
25%
# default: do NOT exclude shoes; only exclude if
zapat_active true or explicitly asked
exclude_shoes_flag = False
else:
offset = 0
exclude_shoes_flag = False
composed = await
asyncio.to_thread(apply_texture_preserving_translucency_with_offset,
base_for_texture, tex_img,
offset, zapat_mask=zapat_mask_for_this, exclude_shoes=exclude_shoes_flag)
else:
full_mask, _, _ = detect_mask_from_base(base_for_texture)
tmp = base_for_texture.convert("RGB").convert("RGBA")
tmp.putalpha(full_mask)
composed = tmp
else:
full_mask, _, _ = detect_mask_from_base(base_img)
tmp = base_img.convert("RGB").convert("RGBA")
tmp.putalpha(full_mask)
composed = tmp

if composed is None:
return None

if self.goth:
try:
# goth: adjust brightness/contrast by factor
rgb = composed.convert("RGB")
enhancer = ImageEnhance.Color(rgb)
rgb_en = enhancer.enhance(1.0)
contrast = ImageEnhance.Contrast(rgb_en)
rgb_en = contrast.enhance(1.15)
bright = ImageEnhance.Brightness(rgb_en)
rgb_en = bright.enhance(1.05)
rgb_final = rgb_en.convert("RGBA")
alpha = composed.split()[3]
rgb_final.putalpha(alpha)
composed = rgb_final
except Exception:
pass

overlay_bytes = await get_random_overlay_bytes()


if overlay_bytes:
try:
overlay_img =
Image.open(io.BytesIO(overlay_bytes)).convert("RGBA")
composed = overlay_on_image_sync(composed, overlay_img)
except Exception:
pass

try:
out = io.BytesIO()
composed.save(out, "PNG")
out.seek(0)
final_clean = remove_green_from_bytes(out.getvalue())
results.append(final_clean)
except Exception:
return None
return results

# Colors -> palette


@discord.ui.button(label="Colors", style=discord.ButtonStyle.primary, row=0)
async def colors_menu(self, interaction: discord.Interaction, button:
discord.ui.Button):
if interaction.user.id != self.author_id:
try:
await interaction.response.send_message("Solo el autor puede usar
estos botones.", ephemeral=True)
except Exception:
pass
return
palette = discord.ui.View(timeout=None)
palette_msg_holder = {"msg": None}

def make_color_cb(channel_id: int):


async def cb(inter: discord.Interaction):
if inter.user.id != self.author_id:
try:
await inter.response.send_message("Solo el autor puede usar
estos botones.", ephemeral=True)
except Exception:
pass
return
try:
await inter.response.defer()
except Exception:
pass
tex_b, tex_url = await
fetch_random_attachment_bytes_nonrepeat(channel_id,
exclude_urls=self.seen_textures, limit=HISTORY_LIMIT)
if not tex_b:
try:
await inter.followup.send("No pude obtener una textura
nueva.", ephemeral=True)
except Exception:
pass
pm = palette_msg_holder.get("msg")
if pm:
try:
await pm.delete()
except Exception:
pass
return
if tex_url:
self.seen_textures.add(tex_url)
self.texture_bytes = tex_b
self.texture_url = tex_url
self.using_realistic_channel = (channel_id ==
CHANNEL_TEXTURES_REALISTIC)
channel = inter.channel
loading = None
try:
loading = await channel.send(f"{inter.user.mention}
Cargando...")
except Exception:
loading = None
try:
await delete_last_result_message(inter.user.id)
except Exception:
pass
originals = []
for i, b in enumerate(self.base_bytes_list):
originals.append((f"orig_base_{i+1}.png", b))
if self.texture_bytes:
originals.append(("orig_texture.png", self.texture_bytes))
await upload_originals_to_trash_simple(inter.user, originals)
new_list = await self.build_image_bytes_from_state()
pm = palette_msg_holder.get("msg")
if pm:
try:
await pm.delete()
except Exception:
pass
if loading:
try:
await loading.delete()
except Exception:
pass
if not new_list:
try:
await channel.send(f"{inter.user.mention} Error procesando
la imagen.")
except Exception:
pass
return
files = []
for idx, nb in enumerate(new_list):
fn = f"result{self.base_ext}" if len(new_list) == 1 else
f"result_{idx+1}{self.base_ext}"
files.append((fn, nb))
new_view = ImageEditView(self.author_id, self.base_bytes_list,
self.base_ext, self.origin_channel_id, initial_texture_bytes=self.texture_bytes,
has_shirt=self.has_shirt, has_pants=self.has_pants)
new_view.texture_bytes = self.texture_bytes
new_view.texture_url = self.texture_url
new_view.seen_textures = self.seen_textures
new_view.base_seen_urls = self.base_seen_urls
new_view.using_realistic_channel = self.using_realistic_channel
new_view.goth = self.goth
new_view.shades_mode = self.shades_mode
new_view.zapat_active = self.zapat_active
new_view.zapat_masks_per_base = self.zapat_masks_per_base
new_view.name_tag = self.name_tag
try:
await inter.message.delete()
except Exception:
pass
await send_to_channel_and_store(channel, inter.user,
new_view.get_block_text(), files, new_view)
return cb

for cname, ch_id in COLOR_TO_CHANNEL.items():


btn = discord.ui.Button(label=cname.capitalize(),
style=discord.ButtonStyle.secondary)
btn.callback = make_color_cb(ch_id)
palette.add_item(btn)

back = discord.ui.Button(label="Volver", style=discord.ButtonStyle.danger)


async def back_cb(inter: discord.Interaction):
if inter.user.id != self.author_id:
try:
await inter.response.send_message("Solo el autor puede usar
estos botones.", ephemeral=True)
except Exception:
pass
return
pm = palette_msg_holder.get("msg")
if pm:
try:
await pm.delete()
except Exception:
pass
try:
await inter.message.delete()
except Exception:
pass
back.callback = back_cb
palette.add_item(back)

try:
palette_msg = await
interaction.channel.send(f"{interaction.user.mention} Elige un color:",
view=palette)
palette_msg_holder["msg"] = palette_msg
try:
await interaction.response.defer()
except Exception:
pass
except Exception:
try:
await interaction.response.send_message("No pude mostrar la
paleta.", ephemeral=True)
except Exception:
pass

# Shades button
@discord.ui.button(label="Shades", style=discord.ButtonStyle.primary, row=0)
async def shades_button(self, interaction: discord.Interaction, button:
discord.ui.Button):
if interaction.user.id != self.author_id:
try:
await interaction.response.send_message("Solo el autor puede usar
estos botones.", ephemeral=True)
except Exception:
pass
return
self.shades_mode = not self.shades_mode
try:
await interaction.response.defer()
except Exception:
pass
channel = interaction.channel
loading = None
try:
msg_text = "Activando Shades..." if self.shades_mode else "Desactivando
Shades..."
loading = await channel.send(f"{interaction.user.mention} {msg_text}")
except Exception:
loading = None
try:
await delete_last_result_message(interaction.user.id)
except Exception:
pass
originals = []
for i, b in enumerate(self.base_bytes_list):
originals.append((f"orig_base_{i+1}.png", b))
if self.texture_bytes:
originals.append(("orig_texture.png", self.texture_bytes))
await upload_originals_to_trash_simple(interaction.user, originals)
new_list = await self.build_image_bytes_from_state()
if loading:
try:
await loading.delete()
except Exception:
pass
if not new_list:
try:
await channel.send(f"{interaction.user.mention} Error aplicando
Shades.")
except Exception:
pass
return
files = []
for idx, nb in enumerate(new_list):
fn = f"result{self.base_ext}" if len(new_list) == 1 else
f"result_{idx+1}{self.base_ext}"
files.append((fn, nb))
new_view = ImageEditView(self.author_id, self.base_bytes_list,
self.base_ext, self.origin_channel_id, initial_texture_bytes=self.texture_bytes,
has_shirt=self.has_shirt, has_pants=self.has_pants)
new_view.texture_bytes = self.texture_bytes
new_view.texture_url = self.texture_url
new_view.seen_textures = self.seen_textures
new_view.base_seen_urls = self.base_seen_urls
new_view.using_realistic_channel = self.using_realistic_channel
new_view.goth = self.goth
new_view.shades_mode = self.shades_mode
new_view.zapat_active = self.zapat_active
new_view.zapat_masks_per_base = self.zapat_masks_per_base
new_view.name_tag = self.name_tag
try:
await interaction.message.delete()
except Exception:
pass
await send_to_channel_and_store(channel, interaction.user,
new_view.get_block_text(), files, new_view)

# Randomize
@discord.ui.button(label="Randomize", style=discord.ButtonStyle.primary, row=0)
async def randomize_button(self, interaction: discord.Interaction, button:
discord.ui.Button):
if interaction.user.id != self.author_id:
try:
await interaction.response.send_message("Solo el autor puede usar
estos botones.", ephemeral=True)
except Exception:
pass
return
try:
await interaction.response.defer()
except Exception:
pass

channel = interaction.channel
loading = None
try:
loading = await channel.send(f"{interaction.user.mention} Randomizando
base(s) y textura...")
except Exception:
loading = None

new_bases = []
for idx, chid in enumerate((CHANNEL_BASES_SHIRT, CHANNEL_BASES_PANTS)
[:len(self.base_bytes_list)]):
b, url = await fetch_random_attachment_bytes_nonrepeat(chid,
exclude_urls=self.base_seen_urls[idx], limit=HISTORY_LIMIT)
if b:
b = remove_green_from_bytes(b)
new_bases.append((idx, b, url))
else:
new_bases.append((idx, self.base_bytes_list[idx], None))

tex_b, tex_url = await


fetch_random_attachment_bytes_nonrepeat(CHANNEL_TEXTURES,
exclude_urls=self.seen_textures, limit=HISTORY_LIMIT)
if not tex_b:
tex_b, tex_url = await
fetch_random_attachment_bytes_nonrepeat(CHANNEL_TEXTURES_REALISTIC,
exclude_urls=self.seen_textures, limit=HISTORY_LIMIT)
if not tex_b:
tex_b, tex_url = await
fetch_random_attachment_bytes_nonrepeat(CHANNEL_TEXTURES_B,
exclude_urls=self.seen_textures, limit=HISTORY_LIMIT)

for idx, b, url in new_bases:


if b:
self.base_bytes_list[idx] = b
if url:
self.base_seen_urls[idx].add(url)
if tex_b:
self.texture_bytes = tex_b
if tex_url:
self.seen_textures.add(tex_url)

try:
await delete_last_result_message(interaction.user.id)
except Exception:
pass
originals = []
for i, b in enumerate(self.base_bytes_list):
originals.append((f"orig_base_{i+1}.png", b))
if self.texture_bytes:
originals.append(("orig_texture.png", self.texture_bytes))
await upload_originals_to_trash_simple(interaction.user, originals)

new_list = await self.build_image_bytes_from_state()


if loading:
try:
await loading.delete()
except Exception:
pass
if not new_list:
try:
await channel.send(f"{interaction.user.mention} Error al
randomizar.")
except Exception:
pass
return
files = []
for idx, nb in enumerate(new_list):
fn = f"result{self.base_ext}" if len(new_list) == 1 else
f"result_{idx+1}{self.base_ext}"
files.append((fn, nb))
new_view = ImageEditView(self.author_id, self.base_bytes_list,
self.base_ext, self.origin_channel_id, initial_texture_bytes=self.texture_bytes,
has_shirt=self.has_shirt, has_pants=self.has_pants)
new_view.texture_bytes = self.texture_bytes
new_view.texture_url = self.texture_url
new_view.seen_textures = self.seen_textures
new_view.base_seen_urls = self.base_seen_urls
new_view.using_realistic_channel = self.using_realistic_channel
new_view.goth = self.goth
new_view.shades_mode = self.shades_mode
new_view.zapat_active = self.zapat_active
new_view.zapat_masks_per_base = self.zapat_masks_per_base
new_view.name_tag = self.name_tag
try:
await interaction.message.delete()
except Exception:
pass
await send_to_channel_and_store(channel, interaction.user,
new_view.get_block_text(), files, new_view)

# Shirt
@discord.ui.button(label="Shirt", style=discord.ButtonStyle.primary, row=1)
async def shirt_button(self, interaction: discord.Interaction, button:
discord.ui.Button):
if interaction.user.id != self.author_id:
try:
await interaction.response.send_message("Solo el autor puede usar
estos botones.", ephemeral=True)
except Exception:
pass
return
if not self.has_shirt:
try:
await interaction.response.send_message("No hay base de shirt en
este mensaje.", ephemeral=True)
except Exception:
pass
return
try:
await interaction.response.defer()
except Exception:
pass
b, url = await fetch_random_attachment_bytes_nonrepeat(CHANNEL_BASES_SHIRT,
exclude_urls=self.base_seen_urls[0], limit=HISTORY_LIMIT)
if not b:
try:
await interaction.followup.send("No encontré una nueva base de
shirt.", ephemeral=True)
except Exception:
pass
return
if url:
self.base_seen_urls[0].add(url)
b = remove_green_from_bytes(b)
self.base_bytes_list[0] = b
channel = interaction.channel
loading = None
try:
loading = await channel.send(f"{interaction.user.mention} Cargando
nueva shirt...")
except Exception:
loading = None
try:
await delete_last_result_message(interaction.user.id)
except Exception:
pass
originals = []
for i, bb in enumerate(self.base_bytes_list):
originals.append((f"orig_base_{i+1}.png", bb))
if self.texture_bytes:
originals.append(("orig_texture.png", self.texture_bytes))
await upload_originals_to_trash_simple(interaction.user, originals)
new_list = await self.build_image_bytes_from_state()
if loading:
try:
await loading.delete()
except Exception:
pass
if not new_list:
return
files = []
for idx, nb in enumerate(new_list):
fn = f"result{self.base_ext}" if len(new_list) == 1 else
f"result_{idx+1}{self.base_ext}"
files.append((fn, nb))
new_view = ImageEditView(self.author_id, self.base_bytes_list,
self.base_ext, self.origin_channel_id, initial_texture_bytes=self.texture_bytes,
has_shirt=self.has_shirt, has_pants=self.has_pants)
new_view.texture_bytes = self.texture_bytes
new_view.texture_url = self.texture_url
new_view.seen_textures = self.seen_textures
new_view.base_seen_urls = self.base_seen_urls
new_view.using_realistic_channel = self.using_realistic_channel
new_view.goth = self.goth
new_view.shades_mode = self.shades_mode
new_view.zapat_active = self.zapat_active
new_view.zapat_masks_per_base = self.zapat_masks_per_base
new_view.name_tag = self.name_tag
try:
await interaction.message.delete()
except Exception:
pass
await send_to_channel_and_store(channel, interaction.user,
new_view.get_block_text(), files, new_view)

# Pants
@discord.ui.button(label="Pants", style=discord.ButtonStyle.primary, row=1)
async def pants_button(self, interaction: discord.Interaction, button:
discord.ui.Button):
if interaction.user.id != self.author_id:
try:
await interaction.response.send_message("Solo el autor puede usar
estos botones.", ephemeral=True)
except Exception:
pass
return
if not self.has_pants:
try:
await interaction.response.send_message("No hay base de pants en
este mensaje.", ephemeral=True)
except Exception:
pass
return
try:
await interaction.response.defer()
except Exception:
pass
if len(self.base_bytes_list) < 2:
try:
await interaction.followup.send("No hay base de pants en este
mensaje.", ephemeral=True)
except Exception:
pass
return
b, url = await fetch_random_attachment_bytes_nonrepeat(CHANNEL_BASES_PANTS,
exclude_urls=self.base_seen_urls[1], limit=HISTORY_LIMIT)
if not b:
try:
await interaction.followup.send("No encontré una nueva base de
pants.", ephemeral=True)
except Exception:
pass
return
if url:
self.base_seen_urls[1].add(url)
b = remove_green_from_bytes(b)
self.base_bytes_list[1] = b
channel = interaction.channel
loading = None
try:
loading = await channel.send(f"{interaction.user.mention} Cargando
nueva pants...")
except Exception:
loading = None
try:
await delete_last_result_message(interaction.user.id)
except Exception:
pass
originals = []
for i, bb in enumerate(self.base_bytes_list):
originals.append((f"orig_base_{i+1}.png", bb))
if self.texture_bytes:
originals.append(("orig_texture.png", self.texture_bytes))
await upload_originals_to_trash_simple(interaction.user, originals)
new_list = await self.build_image_bytes_from_state()
if loading:
try:
await loading.delete()
except Exception:
pass
if not new_list:
return
files = []
for idx, nb in enumerate(new_list):
fn = f"result{self.base_ext}" if len(new_list) == 1 else
f"result_{idx+1}{self.base_ext}"
files.append((fn, nb))
new_view = ImageEditView(self.author_id, self.base_bytes_list,
self.base_ext, self.origin_channel_id, initial_texture_bytes=self.texture_bytes,
has_shirt=self.has_shirt, has_pants=self.has_pants)
new_view.texture_bytes = self.texture_bytes
new_view.texture_url = self.texture_url
new_view.seen_textures = self.seen_textures
new_view.base_seen_urls = self.base_seen_urls
new_view.using_realistic_channel = self.using_realistic_channel
new_view.goth = self.goth
new_view.shades_mode = self.shades_mode
new_view.zapat_active = self.zapat_active
new_view.zapat_masks_per_base = self.zapat_masks_per_base
new_view.name_tag = self.name_tag
try:
await interaction.message.delete()
except Exception:
pass
await send_to_channel_and_store(channel, interaction.user,
new_view.get_block_text(), files, new_view)

# Tela (realistic)
@discord.ui.button(label="Tela", style=discord.ButtonStyle.primary, row=2)
async def tela_button(self, interaction: discord.Interaction, button:
discord.ui.Button):
if interaction.user.id != self.author_id:
try:
await interaction.response.send_message("Solo el autor puede usar
estos botones.", ephemeral=True)
except Exception:
pass
return
try:
await interaction.response.defer()
except Exception:
pass
tex_b, tex_url = await
fetch_random_attachment_bytes_nonrepeat(CHANNEL_TEXTURES_REALISTIC,
exclude_urls=self.seen_textures, limit=HISTORY_LIMIT)
if not tex_b:
try:
await interaction.followup.send("No pude obtener una textura
Tela.", ephemeral=True)
except Exception:
pass
return
if tex_url:
self.seen_textures.add(tex_url)
self.texture_bytes = tex_b
self.texture_url = tex_url
self.using_realistic_channel = True
channel = interaction.channel
loading = None
try:
loading = await channel.send(f"{interaction.user.mention} Cargando
Tela...")
except Exception:
loading = None
try:
await delete_last_result_message(interaction.user.id)
except Exception:
pass
originals = []
for i, b in enumerate(self.base_bytes_list):
originals.append((f"orig_base_{i+1}.png", b))
if self.texture_bytes:
originals.append(("orig_texture.png", self.texture_bytes))
await upload_originals_to_trash_simple(interaction.user, originals)
new_list = await self.build_image_bytes_from_state()
if loading:
try:
await loading.delete()
except Exception:
pass
if not new_list:
return
files = []
for idx, nb in enumerate(new_list):
fn = f"result{self.base_ext}" if len(new_list) == 1 else
f"result_{idx+1}{self.base_ext}"
files.append((fn, nb))
new_view = ImageEditView(self.author_id, self.base_bytes_list,
self.base_ext, self.origin_channel_id, initial_texture_bytes=self.texture_bytes,
has_shirt=self.has_shirt, has_pants=self.has_pants)
new_view.texture_bytes = self.texture_bytes
new_view.texture_url = self.texture_url
new_view.seen_textures = self.seen_textures
new_view.base_seen_urls = self.base_seen_urls
new_view.using_realistic_channel = self.using_realistic_channel
new_view.goth = self.goth
new_view.shades_mode = self.shades_mode
new_view.zapat_active = self.zapat_active
new_view.zapat_masks_per_base = self.zapat_masks_per_base
new_view.name_tag = self.name_tag
try:
await interaction.message.delete()
except Exception:
pass
await send_to_channel_and_store(channel, interaction.user,
new_view.get_block_text(), files, new_view)

# Goth
@discord.ui.button(label="Goth", style=discord.ButtonStyle.secondary, row=2)
async def goth_button(self, interaction: discord.Interaction, button:
discord.ui.Button):
if interaction.user.id != self.author_id:
try:
await interaction.response.send_message("Solo el autor puede usar
estos botones.", ephemeral=True)
except Exception:
pass
return
try:
await interaction.response.defer()
except Exception:
pass
self.goth = not self.goth
channel = interaction.channel
loading = None
try:
loading = await channel.send(f"{interaction.user.mention} Aplicando
Goth...")
except Exception:
loading = None
try:
await delete_last_result_message(interaction.user.id)
except Exception:
pass
originals = []
for i, b in enumerate(self.base_bytes_list):
originals.append((f"orig_base_{i+1}.png", b))
if self.texture_bytes:
originals.append(("orig_texture.png", self.texture_bytes))
await upload_originals_to_trash_simple(interaction.user, originals)
new_list = await self.build_image_bytes_from_state()
if loading:
try:
await loading.delete()
except Exception:
pass
if not new_list:
return
files = []
for idx, nb in enumerate(new_list):
fn = f"result{self.base_ext}" if len(new_list) == 1 else
f"result_{idx+1}{self.base_ext}"
files.append((fn, nb))
new_view = ImageEditView(self.author_id, self.base_bytes_list,
self.base_ext, self.origin_channel_id, initial_texture_bytes=self.texture_bytes,
has_shirt=self.has_shirt, has_pants=self.has_pants)
new_view.texture_bytes = self.texture_bytes
new_view.texture_url = self.texture_url
new_view.seen_textures = self.seen_textures
new_view.base_seen_urls = self.base_seen_urls
new_view.using_realistic_channel = self.using_realistic_channel
new_view.goth = self.goth
new_view.shades_mode = self.shades_mode
new_view.zapat_active = self.zapat_active
new_view.zapat_masks_per_base = self.zapat_masks_per_base
new_view.name_tag = self.name_tag
try:
await interaction.message.delete()
except Exception:
pass
await send_to_channel_and_store(channel, interaction.user,
new_view.get_block_text(), files, new_view)

# Reset
@discord.ui.button(label="Reset", style=discord.ButtonStyle.secondary, row=3)
async def reset_button(self, interaction: discord.Interaction, button:
discord.ui.Button):
if interaction.user.id != self.author_id:
try:
await interaction.response.send_message("Solo el autor puede usar
estos buttons.", ephemeral=True)
except Exception:
pass
return
try:
await interaction.response.defer()
except Exception:
pass
channel = interaction.channel
loading = None
try:
loading = await channel.send(f"{interaction.user.mention} Reseteando...
(ADVERTENCIA: no se recomienda subir imágenes base sin textura).")
except Exception:
loading = None
self.texture_bytes = None
self.texture_url = None
self.seen_textures.clear()
self.goth = False
self.shades_mode = False
self.reset_mode = True
self.zapat_active = False
self.zapat_masks_per_base = [None for _ in self.base_bytes_list]
try:
await delete_last_result_message(interaction.user.id)
except Exception:
pass
originals = []
for i, b in enumerate(self.base_bytes_list):
originals.append((f"orig_base_{i+1}.png", b))
await upload_originals_to_trash_simple(interaction.user, originals)
new_list = []
for b in self.base_bytes_list:
nb = await overlay_bytes_on_bytes(b)
new_list.append(nb)
if loading:
try:
await loading.delete()
except Exception:
pass
content = self.get_block_text() + "\n\n**ADVERTENCIA:** Estas son las
imágenes base sin textura. No se recomienda subirlas ya que pueden tener
consecuencias."
files = []
for idx, nb in enumerate(new_list):
fn = f"base{self.base_ext}" if len(new_list) == 1 else f"base_{idx+1}
{self.base_ext}"
files.append((fn, nb))
new_view = ImageEditView(self.author_id, self.base_bytes_list,
self.base_ext, self.origin_channel_id, initial_texture_bytes=None,
has_shirt=self.has_shirt, has_pants=self.has_pants)
new_view.name_tag = self.name_tag
try:
await interaction.message.delete()
except Exception:
pass
await send_to_channel_and_store(channel, interaction.user, content, files,
new_view)

# base-original
@discord.ui.button(label="base-original", style=discord.ButtonStyle.danger,
row=3)
async def base_original_button(self, interaction: discord.Interaction, button:
discord.ui.Button):
if interaction.user.id != self.author_id:
try:
await interaction.response.send_message("Solo el autor puede usar
estos buttons.", ephemeral=True)
except Exception:
pass
return
try:
await interaction.response.defer()
except Exception:
pass
channel = interaction.channel
try:
await delete_last_result_message(interaction.user.id)
except Exception:
pass
originals = []
for i, b in enumerate(self.base_bytes_list):
originals.append((f"orig_base_{i+1}.png", b))
await upload_originals_to_trash_simple(interaction.user, originals)
files = []
for idx, b in enumerate(self.base_bytes_list):
clean = remove_green_from_bytes(b)
fn = f"base{self.base_ext}" if len(self.base_bytes_list) == 1 else
f"base_{idx+1}{self.base_ext}"
files.append((fn, clean))
content = self.get_block_text() + "\n\n**ADVERTENCIA:** Estas son las
imágenes base sin textura. No se recomienda subirlas ya que pueden tener
consecuencias."
new_view = ImageEditView(self.author_id, self.base_bytes_list,
self.base_ext, self.origin_channel_id, initial_texture_bytes=self.texture_bytes,
has_shirt=self.has_shirt, has_pants=self.has_pants)
new_view.name_tag = self.name_tag
try:
await interaction.message.delete()
except Exception:
pass
await send_to_channel_and_store(channel, interaction.user, content, files,
new_view)

# Zapat / Unzapat
@discord.ui.button(label="Zapat", style=discord.ButtonStyle.secondary, row=2)
async def zapat_button(self, interaction: discord.Interaction, button:
discord.ui.Button):
if interaction.user.id != self.author_id:
try:
await interaction.response.send_message("Solo el autor puede usar
estos buttons.", ephemeral=True)
except Exception:
pass
return
if not self.has_pants or len(self.base_bytes_list) < 2:
try:
await interaction.response.send_message("Zapat solo aplica a
mensajes con pants.", ephemeral=True)
except Exception:
pass
return
try:
await interaction.response.defer()
except Exception:
pass
# toggle
self.zapat_active = not self.zapat_active
if self.zapat_active:
try:
base_b = self.base_bytes_list[1]
base_img = Image.open(io.BytesIO(base_b)).convert("RGBA")
mask = generate_zapat_mask_for_base(base_img)
self.zapat_masks_per_base[1] = mask
except Exception:
self.zapat_masks_per_base[1] = None
else:
self.zapat_masks_per_base[1] = None

channel = interaction.channel
loading = None
try:
loading = await channel.send(f"{interaction.user.mention} {'Aplicando
Zapat...' if self.zapat_active else 'Reaplicando textura completa...'}")
except Exception:
loading = None
try:
await delete_last_result_message(interaction.user.id)
except Exception:
pass
originals = []
for i, b in enumerate(self.base_bytes_list):
originals.append((f"orig_base_{i+1}.png", b))
if self.texture_bytes:
originals.append(("orig_texture.png", self.texture_bytes))
await upload_originals_to_trash_simple(interaction.user, originals)
new_list = await self.build_image_bytes_from_state()
if loading:
try:
await loading.delete()
except Exception:
pass
if not new_list:
try:
await channel.send(f"{interaction.user.mention} Error aplicando
Zapat.")
except Exception:
pass
return
files = []
for idx, nb in enumerate(new_list):
fn = f"result{self.base_ext}" if len(new_list) == 1 else
f"result_{idx+1}{self.base_ext}"
files.append((fn, nb))
new_view = ImageEditView(self.author_id, self.base_bytes_list,
self.base_ext, self.origin_channel_id, initial_texture_bytes=self.texture_bytes,
has_shirt=self.has_shirt, has_pants=self.has_pants)
new_view.texture_bytes = self.texture_bytes
new_view.texture_url = self.texture_url
new_view.seen_textures = self.seen_textures
new_view.base_seen_urls = self.base_seen_urls
new_view.using_realistic_channel = self.using_realistic_channel
new_view.goth = self.goth
new_view.shades_mode = self.shades_mode
new_view.zapat_active = self.zapat_active
new_view.zapat_masks_per_base = self.zapat_masks_per_base
new_view.name_tag = self.name_tag
for it in new_view.children:
if isinstance(it, discord.ui.Button) and it.label in ("Zapat",
"Unzapat"):
it.label = "Unzapat" if new_view.zapat_active else "Zapat"
try:
await interaction.message.delete()
except Exception:
pass
await send_to_channel_and_store(channel, interaction.user,
new_view.get_block_text(), files, new_view)

# Test: color shoes neon green and upload mask to TRASH


@discord.ui.button(label="Test", style=discord.ButtonStyle.success, row=3)
async def test_button(self, interaction: discord.Interaction, button:
discord.ui.Button):
if interaction.user.id != self.author_id:
try:
await interaction.response.send_message("Solo el autor puede usar
estos buttons.", ephemeral=True)
except Exception:
pass
return
if not self.has_pants or len(self.base_bytes_list) < 2:
try:
await interaction.response.send_message("Test solo aplica a
mensajes con pants.", ephemeral=True)
except Exception:
pass
return
try:
await interaction.response.defer()
except Exception:
pass
channel = interaction.channel
loading = None
try:
loading = await channel.send(f"{interaction.user.mention} Ejecutando
test de zapatos...")
except Exception:
loading = None
try:
base_b = self.base_bytes_list[1]
base_img = Image.open(io.BytesIO(base_b)).convert("RGBA")
shoes_mask = detect_shoes_mask(base_img, bottom_fraction=0.30,
dark_threshold=100, alpha_threshold=12, expand_px=10)
# create neon green overlay where shoes_mask==255 on top of current
composed pants
base_for_texture = base_img
if self.texture_bytes:
alpha = base_img.split()[3] if "A" in base_img.getbands() else None
gray = ImageOps.grayscale(base_img).convert("RGBA")
if alpha:
gray.putalpha(alpha)
base_for_texture = gray
try:
tex_img =
Image.open(io.BytesIO(self.texture_bytes)).convert("RGBA")
except Exception:
tex_img = None
if tex_img:
offset = int(base_for_texture.size[1] * 0.25)
pants_composed =
apply_texture_preserving_translucency_with_offset(base_for_texture, tex_img,
offset, zapat_mask=(self.zapat_masks_per_base[1] if self.zapat_active else None),
exclude_shoes=False)
else:
full_mask, _, _ = detect_mask_from_base(base_for_texture)
tmp = base_for_texture.convert("RGB").convert("RGBA")
tmp.putalpha(full_mask)
pants_composed = tmp
else:
full_mask, _, _ = detect_mask_from_base(base_img)
tmp = base_img.convert("RGB").convert("RGBA")
tmp.putalpha(full_mask)
pants_composed = tmp
neon = Image.new("RGBA", pants_composed.size, (0, 255, 0, 255))
try:
mask_alpha = shoes_mask.convert("L")
colored = Image.composite(neon, pants_composed, mask_alpha)
final_pants = Image.composite(colored, pants_composed,
ImageChops.invert(mask_alpha))
except Exception:
final_pants = pants_composed
out = io.BytesIO()
final_pants.save(out, "PNG")
out.seek(0)
final_pants_bytes = out.read()
mask_only = Image.new("RGBA", shoes_mask.size, (0, 255, 0, 255))
mask_only.putalpha(shoes_mask)
out2 = io.BytesIO()
mask_only.save(out2, "PNG")
out2.seek(0)
mask_bytes = out2.read()
trash_ch = await get_channel_safe(TRASH_CHANNEL)
note = f"[{datetime.now(timezone.utc).isoformat()}] Test zapatos por
{interaction.user}"
if trash_ch:
try:
await trash_ch.send(content=note,
files=[discord.File(io.BytesIO(mask_bytes), filename="shoes_mask.png")])
except Exception:
pass
outputs = []
for idx, b in enumerate(self.base_bytes_list):
if idx == 1:
outputs.append(final_pants_bytes)
else:
try:
base_img_s =
Image.open(io.BytesIO(remove_green_from_bytes(b))).convert("RGBA")
if self.texture_bytes:
alpha = base_img_s.split()[3]
gray = ImageOps.grayscale(base_img_s).convert("RGBA")
gray.putalpha(alpha)
try:
tex_img =
Image.open(io.BytesIO(self.texture_bytes)).convert("RGBA")
except Exception:
tex_img = None
if tex_img:
composed_s =
apply_texture_preserving_translucency_with_offset(gray, tex_img, 0,
zapat_mask=(self.zapat_masks_per_base[idx] if self.zapat_active else None),
exclude_shoes=False)
else:
full_mask, _, _ = detect_mask_from_base(gray)
tmp = gray.convert("RGB").convert("RGBA")
tmp.putalpha(full_mask)
composed_s = tmp
else:
full_mask, _, _ = detect_mask_from_base(base_img_s)
tmp = base_img_s.convert("RGB").convert("RGBA")
tmp.putalpha(full_mask)
composed_s = tmp
out_s = io.BytesIO()
composed_s.save(out_s, "PNG")
out_s.seek(0)
outputs.append(remove_green_from_bytes(out_s.read()))
except Exception:
outputs.append(remove_green_from_bytes(b))
try:
await delete_last_result_message(interaction.user.id)
except Exception:
pass
files = []
for idx, nb in enumerate(outputs):
fn = f"result{self.base_ext}" if len(outputs) == 1 else
f"result_{idx+1}{self.base_ext}"
files.append((fn, nb))
new_view = ImageEditView(self.author_id, self.base_bytes_list,
self.base_ext, self.origin_channel_id, initial_texture_bytes=self.texture_bytes,
has_shirt=self.has_shirt, has_pants=self.has_pants)
new_view.texture_bytes = self.texture_bytes
new_view.texture_url = self.texture_url
new_view.seen_textures = self.seen_textures
new_view.base_seen_urls = self.base_seen_urls
new_view.using_realistic_channel = self.using_realistic_channel
new_view.goth = self.goth
new_view.shades_mode = self.shades_mode
new_view.zapat_active = self.zapat_active
new_view.zapat_masks_per_base = self.zapat_masks_per_base
new_view.name_tag = self.name_tag
if loading:
try:
await loading.delete()
except Exception:
pass
try:
await interaction.message.delete()
except Exception:
pass
await send_to_channel_and_store(channel, interaction.user,
new_view.get_block_text(), files, new_view)
return
except Exception as e:
log.warning(f"Error en test_button: {e}\n{traceback.format_exc()}")
if loading:
try:
await loading.delete()
except Exception:
pass
try:
await interaction.followup.send("Error ejecutando test.",
ephemeral=True)
except Exception:
pass
return

# ---------------- before_invoke: asegurar uso SOLO en servidores ----------------


@bot.before_invoke
async def ensure_guild(ctx: commands.Context):
if ctx.guild is None:
try:
await ctx.message.delete()
except Exception:
pass
return

# ---------------- Commands: shirt / pants / conjunto ----------------


async def send_base_with_interactive_view(ctx: commands.Context, base_bytes_list:
list[bytes], is_shirt_cmd: bool = False, is_pants_cmd: bool = False):
if ctx.guild is None:
try:
await ctx.message.delete()
except Exception:
pass
return
try:
await ctx.message.delete()
except Exception:
pass

processed_bases = []
for b in base_bytes_list:
try:
nb = remove_green_from_bytes(b)
processed_bases.append(nb)
except Exception:
processed_bases.append(b)

tex_b, tex_url = await


fetch_random_attachment_bytes_nonrepeat(CHANNEL_TEXTURES, exclude_urls=set(),
limit=HISTORY_LIMIT)
if not tex_b:
tex_b, tex_url = await
fetch_random_attachment_bytes_nonrepeat(CHANNEL_TEXTURES_REALISTIC,
exclude_urls=set(), limit=HISTORY_LIMIT)
if not tex_b:
tex_b, tex_url = await
fetch_random_attachment_bytes_nonrepeat(CHANNEL_TEXTURES_B, exclude_urls=set(),
limit=HISTORY_LIMIT)

ext = detect_extension_from_bytes(processed_bases[0])
has_shirt = len(processed_bases) >= 1 and (is_shirt_cmd or not is_pants_cmd)
has_pants = len(processed_bases) >= 2 or (is_pants_cmd and len(processed_bases)
>= 1)
view = ImageEditView(ctx.author.id, processed_bases, ext, ctx.channel.id,
initial_texture_bytes=tex_b, has_shirt=has_shirt, has_pants=has_pants)
if tex_url:
view.texture_url = tex_url
view.seen_textures.add(tex_url)
if tex_b:
new_list = await view.build_image_bytes_from_state()
if not new_list:
base_with_overlay_list = []
for b in processed_bases:
nb = await overlay_bytes_on_bytes(b)
base_with_overlay_list.append(nb)
files = []
for idx, nb in enumerate(base_with_overlay_list):
fn = f"base{ext}" if len(base_with_overlay_list) == 1 else
f"base_{idx+1}{ext}"
files.append((fn, nb))
originals = []
for idx, b in enumerate(processed_bases):
originals.append((f"orig_base_{idx+1}.png", b))
if tex_b:
originals.append(("orig_texture.png", tex_b))
await upload_originals_to_trash_simple(ctx.author, originals)
sent = await send_to_channel_and_store(ctx.channel, ctx.author,
view.get_block_text(), files, view)
return sent
else:
files = []
for idx, nb in enumerate(new_list):
fn = f"result{ext}" if len(new_list) == 1 else f"result_{idx+1}
{ext}"
files.append((fn, nb))
originals = []
for idx, b in enumerate(processed_bases):
originals.append((f"orig_base_{idx+1}.png", b))
if tex_b:
originals.append(("orig_texture.png", tex_b))
await upload_originals_to_trash_simple(ctx.author, originals)
sent = await send_to_channel_and_store(ctx.channel, ctx.author,
view.get_block_text(), files, view)
return sent
else:
base_with_overlay_list = []
for b in processed_bases:
nb = await overlay_bytes_on_bytes(b)
base_with_overlay_list.append(nb)
files = []
for idx, nb in enumerate(base_with_overlay_list):
fn = f"base{ext}" if len(base_with_overlay_list) == 1 else
f"base_{idx+1}{ext}"
files.append((fn, nb))
originals = []
for idx, b in enumerate(processed_bases):
originals.append((f"orig_base_{idx+1}.png", b))
await upload_originals_to_trash_simple(ctx.author, originals)
sent = await send_to_channel_and_store(ctx.channel, ctx.author,
view.get_block_text(), files, view)
return sent

@bot.command(name="shirt")
async def shirt_cmd(ctx: commands.Context):
if ctx.guild is None:
return
base_b, base_url = await
fetch_random_attachment_bytes_nonrepeat(CHANNEL_BASES_SHIRT, exclude_urls=set(),
limit=HISTORY_LIMIT)
if not base_b:
return
await send_base_with_interactive_view(ctx, [base_b], is_shirt_cmd=True,
is_pants_cmd=False)

@bot.command(name="pants")
async def pants_cmd(ctx: commands.Context):
if ctx.guild is None:
return
base_b, base_url = await
fetch_random_attachment_bytes_nonrepeat(CHANNEL_BASES_PANTS, exclude_urls=set(),
limit=HISTORY_LIMIT)
if not base_b:
return
await send_base_with_interactive_view(ctx, [base_b], is_shirt_cmd=False,
is_pants_cmd=True)

@bot.command(name="conjunto")
async def conjunto_cmd(ctx: commands.Context):
if ctx.guild is None:
return
shirt_b, _ = await fetch_random_attachment_bytes_nonrepeat(CHANNEL_BASES_SHIRT,
exclude_urls=set(), limit=HISTORY_LIMIT)
pants_b, _ = await fetch_random_attachment_bytes_nonrepeat(CHANNEL_BASES_PANTS,
exclude_urls=set(), limit=HISTORY_LIMIT)
bases = []
if shirt_b:
bases.append(shirt_b)
if pants_b:
bases.append(pants_b)
if not bases:
return
await send_base_with_interactive_view(ctx, bases, is_shirt_cmd=False,
is_pants_cmd=False)

# ---------------- simple test / utilities ----------------


@bot.command(name="test")
async def test_cmd(ctx: commands.Context):
if ctx.guild is None:
return
TEST_MODE[ctx.author.id] = True
try:
await ctx.message.delete()
except Exception:
pass

@bot.command(name="untest")
async def untest_cmd(ctx: commands.Context):
if ctx.guild is None:
return
TEST_MODE.pop(ctx.author.id, None)
try:
await ctx.message.delete()
except Exception:
pass

@bot.event
async def on_ready():
log.info(f"Bot conectado como {bot.user} (ID: {bot.user.id})")

# ---------------- run ----------------


if __name__ == "__main__":
try:
bot.run(TOKEN)
finally:
try:
if os.path.exists(PIDFILE):
os.remove(PIDFILE)
except Exception:
pass

You might also like