This commit is contained in:
Clinv committed 2026-09-19 13:41:55 -03:00
1 parent d5dd2da1cb
commit adb5f65bc7
6 files changed
+1042

No files matched your search

View File
Whitespace-only changes.
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
# Tahgibu Animator
Animador 2D cuadro por cuadro, simple y sin marca de agua. Pensado para andar
liviano incluso en notebooks modestas con gráfica integrada.
## Características
- Frame por frame real, con miniaturas en la timeline.
- Timeline con scroll (rueda del mouse) para animaciones con muchos frames.
- `F7` crea un frame nuevo. `←` / `→` navegan. `Inicio`/`Fin` van al primero/último.
- `Ctrl+V` pega imágenes del portapapeles (necesita `wl-clipboard` en Wayland).
- Dos lápices:
- Lápiz suave: acumula densidad al pasar varias veces (con difuminado).
- Lápiz sólido: 100% de densidad.
- Borrador que **de verdad borra** (baja la transparencia del trazo).
- Selector de color y tamaño de pincel.
- Onion Skin (ve el frame anterior/siguiente semi-transparente).
- Fondos: botón para elegir o quitar una imagen de fondo.
- Sprites: se agregan, se pueden quitar de la lista, y se colocan con un clic
directo en el lienzo (apuntá y hacé clic; `Esc` cancela).
- Deshacer / Rehacer (`Ctrl+Z` / `Ctrl+Y`) livianos en memoria: no duplican
toda la animación en cada trazo, así no se cuelga en equipos con poca RAM.
- FPS configurable y reproducción (`Espacio`).
- Exportar a **GIF animado** o a una **secuencia de PNG**.
- Guardar / abrir / importar proyectos `.tgb` (el archivo lleva adentro todos
los frames, el fondo y los sprites, así se puede mover a otra PC sin
necesitar los archivos originales).
- Confirma antes de perder cambios sin guardar (al crear nuevo, abrir otro
proyecto o cerrar la ventana).
- Sin marca de agua, nunca.
## Qué se corrigió respecto a la versión anterior (ASM Animator)
- **El borrador no borraba nada** (dibujaba "transparente sobre transparente"
y no tenía ningún efecto visible). Ahora sí funciona.
- **No existía ningún botón para poner un fondo**, aunque el formato de
archivo y el propio programa lo mencionaban. Se agregó "Fondo: Elegir/Quitar".
- **No había Rehacer** (`redo`), solo Deshacer.
- El Deshacer copiaba **toda la animación completa** en cada trazo de lápiz.
Con varios frames esto podía consumir varios GB de RAM y trabar el equipo.
Ahora cada acción guarda solo lo mínimo necesario para revertirla.
- El lápiz suave aplicaba un difuminado sobre el lienzo completo en cada
movimiento del mouse, algo pesado en gráficas integradas. Ahora solo procesa
la zona del trazo.
- La timeline no tenía forma de desplazarse: con muchos frames, los cuadros
quedaban fuera de la pantalla y no se podían seleccionar. Ahora tiene
scroll y miniaturas reales de cada frame.
- Se podía perder trabajo sin guardar sin ningún aviso al cerrar o crear un
proyecto nuevo. Ahora se confirma antes.
- Los sprites agregados no se guardaban dentro del proyecto `.tgb`. Ahora sí.
- No había forma de sacar la animación fuera del programa. Se agregó
exportar a GIF y a secuencia de PNG.
## Instalar en CachyOS / Arch
```bash
sudo pacman -S python python-pillow wl-clipboard
./install.sh
```
Después abrí **Tahgibu Animator** desde el menú de aplicaciones (o ejecutá
directamente `python3 tahgibu_animator.py`).
## Formato de archivo
`.tgb` es el formato propio del proyecto (JSON con las imágenes en base64
adentro). También podés **abrir e importar** archivos `.asm` de la versión
anterior del programa: son totalmente compatibles.
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e
APP_DIR="$HOME/.local/share/tahgibu-animator"
mkdir -p "$APP_DIR" "$HOME/.local/share/applications"
cp tahgibu_animator.py "$APP_DIR/tahgibu_animator.py"
chmod +x "$APP_DIR/tahgibu_animator.py"
cp tahgibu-animator.desktop "$HOME/.local/share/applications/tahgibu-animator.desktop"
echo "Tahgibu Animator instalado."
echo "Dependencias necesarias: python3 y python-pillow."
echo "Para pegar imágenes con Ctrl+V en KDE/Wayland: sudo pacman -S wl-clipboard"
echo "Abrilo desde el menú de aplicaciones como: Tahgibu Animator"
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env xdg-open
[Desktop Entry]
Name=Tahgibu Animator
Comment=Animación 2D cuadro por cuadro, simple y sin marca de agua
Exec=python3 %h/.local/share/tahgibu-animator/tahgibu_animator.py
Icon=multimedia-animation
Terminal=false
Type=Application
Categories=Graphics;2DGraphics;Video;
StartupNotify=true
+955
View File
@@ -0,0 +1,955 @@
#!/usr/bin/env python3
"""
Tahgibu Animator
Animación 2D cuadro por cuadro, simple y sin marca de agua.
Pensado para andar liviano en equipos modestos (Linux).
"""
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, colorchooser
import os, json, base64, io, subprocess
from PIL import Image, ImageTk, ImageDraw, ImageFilter, ImageChops
APP_NAME = "Tahgibu Animator"
VERSION = "1.0.0"
FORMAT_NAME = "TAHGIBU"
LEGACY_FORMAT_NAMES = ("TAHGIBU", "ASM") # ASM = formato del proyecto original, se puede seguir abriendo
FILE_EXT = ".tgb"
UNDO_LIMIT = 40
THUMB_W, THUMB_H = 34, 34
SLOT_W = 44
class TahgibuAnimator:
def __init__(self, root):
self.root = root
self.root.title(f"{APP_NAME} {VERSION}")
self.root.geometry("1280x800")
self.root.minsize(980, 650)
# --- paleta ---
self.bg_color = "#171a20"
self.panel = "#20242b"
self.panel2 = "#292f38"
self.accent = "#6c7cff"
self.text = "#eef1f6"
self.muted = "#9ca6b5"
self.root.configure(bg=self.bg_color)
# --- estado de la animación ---
self.W, self.H = 960, 540
self.fps = 12
self.current = 0
self.frames = [Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))]
self.background = None # imagen original (para guardar)
self._bg_scaled = None # versión ya escalada (cache, no se recalcula en cada render)
self._bg_pos = (0, 0)
self.sprites = [] # lista de (nombre, imagen)
# --- herramientas ---
self.color = "#111111"
self.brush_size = 7
self.tool = "solid"
self.stamp_mode = False
self.pending_sprite = None
# --- reproducción ---
self.playing = False
self.play_after = None
# --- deshacer / rehacer (por acción, no copia toda la animación) ---
self.undo_stack = []
self.redo_stack = []
# --- varios ---
self.tk_image = None
self.last = None
self.dirty = False
self.current_path = None
self._thumb_cache = {}
self._onion_index = None
self._onion_valid = False
self._onion_prev = None
self._onion_next = None
self.make_ui()
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
self.root.update_idletasks() # asegura tamaño real del canvas antes del primer dibujo
self.render()
# ---------------------------------------------------------------- UI ---
def make_ui(self):
top = tk.Frame(self.root, bg=self.panel, height=54)
top.pack(fill="x")
top.pack_propagate(False)
tk.Label(top, text="Tahgibu", bg=self.panel, fg=self.accent,
font=("DejaVu Sans", 16, "bold")).pack(side="left", padx=(16, 3))
tk.Label(top, text="ANIMATOR", bg=self.panel, fg=self.text,
font=("DejaVu Sans", 11, "bold")).pack(side="left", padx=(0, 18))
def top_btn(txt, cmd):
return tk.Button(top, text=txt, command=cmd, bg=self.panel2, fg=self.text,
activebackground="#3a414d", activeforeground="white",
relief="flat", padx=10)
for txt, cmd in [
("Nuevo", self.new_project),
("Abrir", self.open_asm),
("Guardar", self.save_asm),
("Guardar como…", self.save_asm_as),
("Importar", self.import_asm),
("Pegar imagen", self.paste_image),
]:
top_btn(txt, cmd).pack(side="left", padx=3, pady=10)
tk.Frame(top, bg="#3a414d", width=1).pack(side="left", fill="y", pady=12, padx=6)
top_btn("↶ Deshacer", self.undo).pack(side="left", padx=3, pady=10)
top_btn("↷ Rehacer", self.redo).pack(side="left", padx=3, pady=10)
tk.Frame(top, bg="#3a414d", width=1).pack(side="left", fill="y", pady=12, padx=6)
self.export_menu_btn = tk.Menubutton(top, text="Exportar ▾", bg=self.panel2, fg=self.text,
activebackground="#3a414d", relief="flat", padx=10)
export_menu = tk.Menu(self.export_menu_btn, tearoff=0)
export_menu.add_command(label="GIF animado…", command=self.export_gif)
export_menu.add_command(label="Secuencia de PNG…", command=self.export_png_sequence)
self.export_menu_btn.configure(menu=export_menu)
self.export_menu_btn.pack(side="left", padx=3, pady=10)
top_btn("❓ Ayuda", self.show_help).pack(side="left", padx=3, pady=10)
tk.Button(top, text="▶", command=self.toggle_play, bg=self.accent, fg="white",
relief="flat", width=4).pack(side="right", padx=(3, 16), pady=10)
self.fps_var = tk.IntVar(value=self.fps)
tk.Spinbox(top, from_=1, to=60, textvariable=self.fps_var, width=4,
bg=self.panel2, fg=self.text, buttonbackground=self.panel2,
relief="flat").pack(side="right", padx=4)
tk.Label(top, text="FPS", bg=self.panel, fg=self.muted).pack(side="right")
body = tk.Frame(self.root, bg=self.bg_color)
body.pack(fill="both", expand=True)
# ------------------------------------------------------ panel izq ---
tools = tk.Frame(body, bg=self.panel, width=210)
tools.pack(side="left", fill="y")
tools.pack_propagate(False)
tk.Label(tools, text="HERRAMIENTAS", bg=self.panel, fg=self.muted,
font=("DejaVu Sans", 9, "bold")).pack(anchor="w", padx=14, pady=(16, 8))
self.tool_buttons = {}
for key, label in [
("soft", "◌ Lápiz suave"),
("solid", "● Lápiz sólido"),
("eraser", "⌫ Borrador"),
]:
b = tk.Button(tools, text=label, command=lambda k=key: self.set_tool(k),
bg=self.panel2, fg=self.text, activebackground=self.accent,
activeforeground="white", relief="flat", anchor="w", padx=12)
b.pack(fill="x", padx=10, pady=3)
self.tool_buttons[key] = b
tk.Button(tools, text="🎨 Elegir color", command=self.choose_color,
bg=self.panel2, fg=self.text, activebackground="#3a414d",
relief="flat", anchor="w", padx=12).pack(fill="x", padx=10, pady=(14, 4))
self.color_preview = tk.Label(tools, text=" ", bg=self.color, relief="solid", bd=1)
self.color_preview.pack(anchor="w", padx=14, pady=3)
size_row = tk.Frame(tools, bg=self.panel)
size_row.pack(fill="x", padx=14, pady=(14, 0))
tk.Label(size_row, text="Tamaño", bg=self.panel, fg=self.muted).pack(side="left")
self.size_label = tk.Label(size_row, text=str(self.brush_size), bg=self.panel, fg=self.text)
self.size_label.pack(side="right")
self.size_scale = tk.Scale(tools, from_=1, to=60, orient="horizontal",
command=self.on_size_change,
bg=self.panel, fg=self.text, troughcolor="#363d48",
highlightthickness=0)
self.size_scale.set(self.brush_size)
self.size_scale.pack(fill="x", padx=8)
self.onion_var = tk.BooleanVar(value=True)
tk.Checkbutton(tools, text="Onion Skin", variable=self.onion_var,
command=self.render, bg=self.panel, fg=self.text,
selectcolor=self.panel2, activebackground=self.panel,
activeforeground=self.text).pack(anchor="w", padx=10, pady=(12, 2))
tk.Button(tools, text="🗑 Vaciar frame actual", command=self.clear_frame,
bg=self.panel2, fg=self.text, relief="flat", anchor="w",
padx=12).pack(fill="x", padx=10, pady=(6, 4))
tk.Label(tools, text="FONDO", bg=self.panel, fg=self.muted,
font=("DejaVu Sans", 9, "bold")).pack(anchor="w", padx=14, pady=(16, 6))
bg_row = tk.Frame(tools, bg=self.panel)
bg_row.pack(fill="x", padx=10)
tk.Button(bg_row, text="Elegir…", command=self.choose_background,
bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(0, 3))
tk.Button(bg_row, text="Quitar", command=self.clear_background,
bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(3, 0))
tk.Label(tools, text="SPRITES", bg=self.panel, fg=self.muted,
font=("DejaVu Sans", 9, "bold")).pack(anchor="w", padx=14, pady=(16, 6))
sprite_btn_row = tk.Frame(tools, bg=self.panel)
sprite_btn_row.pack(fill="x", padx=10)
tk.Button(sprite_btn_row, text="+ Añadir", command=self.add_sprite,
bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(0, 3))
tk.Button(sprite_btn_row, text="− Quitar", command=self.remove_sprite,
bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(3, 0))
self.sprite_list = tk.Listbox(tools, bg="#171a20", fg=self.text, height=6,
relief="flat", highlightthickness=0)
self.sprite_list.pack(fill="both", expand=True, padx=10, pady=5)
tk.Button(tools, text="Poner sprite en el lienzo", command=self.place_sprite,
bg=self.accent, fg="white", relief="flat").pack(fill="x", padx=10, pady=(2, 10))
# ---------------------------------------------------------- lienzo ---
stage = tk.Frame(body, bg="#0f1115")
stage.pack(side="left", fill="both", expand=True)
self.canvas = tk.Canvas(stage, bg="#0f1115", highlightthickness=0)
self.canvas.pack(fill="both", expand=True, padx=16, pady=16)
self.canvas.bind("<Configure>", lambda e: self.render())
self.canvas.bind("<ButtonPress-1>", self.start_draw)
self.canvas.bind("<B1-Motion>", self.paint)
self.canvas.bind("<ButtonRelease-1>", self.end_draw)
self.status = tk.Label(stage, text="", bg="#0f1115", fg=self.muted, anchor="w")
self.status.pack(fill="x", padx=16, pady=(0, 8))
# -------------------------------------------------------- timeline ---
timeline_wrap = tk.Frame(self.root, bg=self.panel, height=150)
timeline_wrap.pack(fill="x")
timeline_wrap.pack_propagate(False)
row = tk.Frame(timeline_wrap, bg=self.panel)
row.pack(fill="x")
tk.Label(row, text="TIMELINE", bg=self.panel, fg=self.text,
font=("DejaVu Sans", 10, "bold")).pack(side="left", padx=12, pady=8)
for txt, cmd in [("+ Frame", self.new_frame), ("Duplicar", self.duplicate_frame),
("Eliminar", self.delete_frame)]:
tk.Button(row, text=txt, command=cmd, bg=self.panel2, fg=self.text,
relief="flat").pack(side="left", padx=3)
self.frame_count_label = tk.Label(row, text="", bg=self.panel, fg=self.muted)
self.frame_count_label.pack(side="right", padx=12)
tl_body = tk.Frame(timeline_wrap, bg=self.panel)
tl_body.pack(fill="x", padx=10, pady=(0, 8))
self.timeline = tk.Canvas(tl_body, bg="#15181d", height=92, highlightthickness=0,
xscrollincrement=20)
self.timeline.pack(fill="x", side="top")
self.timeline_scroll = tk.Scrollbar(tl_body, orient="horizontal", command=self.timeline.xview)
self.timeline_scroll.pack(fill="x", side="top")
self.timeline.configure(xscrollcommand=self.timeline_scroll.set)
self.timeline.bind("<Button-1>", self.timeline_click)
self.timeline.bind("<MouseWheel>", self._on_timeline_wheel)
self.timeline.bind("<Button-4>", self._on_timeline_wheel)
self.timeline.bind("<Button-5>", self._on_timeline_wheel)
# -------------------------------------------------------- atajos ---
self.root.bind("<F7>", lambda e: self.new_frame())
self.root.bind("<Left>", lambda e: self.select_frame(self.current - 1))
self.root.bind("<Right>", lambda e: self.select_frame(self.current + 1))
self.root.bind("<Home>", lambda e: self.select_frame(0))
self.root.bind("<End>", lambda e: self.select_frame(len(self.frames) - 1))
self.root.bind("<space>", lambda e: self.toggle_play())
self.root.bind("<Control-z>", lambda e: self.undo())
self.root.bind("<Control-y>", lambda e: self.redo())
self.root.bind("<Control-Shift-Z>", lambda e: self.redo())
self.root.bind("<Control-v>", lambda e: self.paste_image())
self.root.bind("<Control-s>", lambda e: self.save_asm())
self.root.bind("<Control-n>", lambda e: self.new_project())
self.root.bind("<Control-o>", lambda e: self.open_asm())
self.root.bind("<Escape>", self.on_escape)
self.set_tool("solid")
# ----------------------------------------------------------- ayudas ---
def show_help(self):
messagebox.showinfo(
"Ayuda - " + APP_NAME,
"Atajos de teclado:\n\n"
"F7 — nuevo frame\n"
"←/→ — frame anterior / siguiente\n"
"Inicio/Fin — primer / último frame\n"
"Espacio — reproducir / pausar\n"
"Ctrl+Z / Ctrl+Y — deshacer / rehacer\n"
"Ctrl+S — guardar\n"
"Ctrl+O — abrir\n"
"Ctrl+N — nuevo proyecto\n"
"Ctrl+V — pegar imagen del portapapeles\n"
"Esc — cancelar acción / detener reproducción\n\n"
"Flujo básico: dibujá en el lienzo, apretá F7 para el siguiente\n"
"frame, repetí, y usá ▶ para ver la animación."
)
def on_escape(self, e):
if self.stamp_mode:
self.stamp_mode = False
self.pending_sprite = None
self.canvas.configure(cursor="")
self.update_status()
elif self.playing:
self.stop()
self.render(full=False)
def mark_dirty(self):
self.dirty = True
def confirm_discard(self, action="continuar"):
if not self.dirty:
return True
return messagebox.askyesno("Cambios sin guardar",
f"Hay cambios sin guardar. ¿Querés {action} de todas formas?")
def on_close(self):
self.stop()
if self.dirty and not messagebox.askyesno("Salir", "Hay cambios sin guardar. ¿Salir de todas formas?"):
return
self.root.destroy()
def get_fps(self):
try:
v = int(self.fps_var.get())
return max(1, min(60, v))
except Exception:
return self.fps
def on_size_change(self, v):
self.brush_size = max(1, int(float(v)))
self.size_label.configure(text=str(self.brush_size))
def set_tool(self, tool):
self.tool = tool
self.stamp_mode = False
self.pending_sprite = None
self.canvas.configure(cursor="")
for k, b in self.tool_buttons.items():
b.configure(bg=self.accent if k == tool else self.panel2)
def choose_color(self):
picked = colorchooser.askcolor(color=self.color, title="Elegir color")
if picked and picked[1]:
self.color = picked[1]
self.color_preview.configure(bg=self.color)
# ------------------------------------------------- deshacer / rehacer ---
def push_action(self, action):
self.undo_stack.append(action)
if len(self.undo_stack) > UNDO_LIMIT:
self.undo_stack.pop(0)
self.redo_stack.clear()
self.mark_dirty()
def _begin_frame_edit(self):
"""Guarda una copia del frame actual ANTES de modificarlo (para poder deshacer)."""
self.push_action(("paint", self.current, self.frames[self.current].copy()))
self._invalidate_thumb(self.current)
def _invalidate_onion(self):
self._onion_valid = False
def _invalidate_thumb(self, i=None):
if i is None:
self._thumb_cache.clear()
else:
self._thumb_cache.pop(i, None)
def _apply_inverse(self, action):
"""Aplica una acción y devuelve la acción opuesta (usado tanto por undo como por redo)."""
kind = action[0]
if kind == "paint":
_, idx, img = action
idx = min(idx, len(self.frames) - 1)
current_img = self.frames[idx].copy()
self.frames[idx] = img
self.current = idx
self._invalidate_thumb(idx)
return ("paint", idx, current_img)
elif kind == "insert":
_, idx, prev_current = action
idx = min(idx, len(self.frames) - 1)
removed = self.frames.pop(idx)
if not self.frames:
self.frames = [Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))]
self.current = min(prev_current, len(self.frames) - 1)
self._invalidate_thumb()
return ("delete", idx, removed, prev_current)
elif kind == "delete":
_, idx, img, prev_current = action
idx = min(idx, len(self.frames))
self.frames.insert(idx, img)
self.current = idx
self._invalidate_thumb()
return ("insert", idx, prev_current)
return action
def undo(self):
if not self.undo_stack:
return
action = self.undo_stack.pop()
redo_action = self._apply_inverse(action)
self.redo_stack.append(redo_action)
if len(self.redo_stack) > UNDO_LIMIT:
self.redo_stack.pop(0)
self._invalidate_onion()
self.mark_dirty()
self.render()
def redo(self):
if not self.redo_stack:
return
action = self.redo_stack.pop()
undo_action = self._apply_inverse(action)
self.undo_stack.append(undo_action)
self._invalidate_onion()
self.mark_dirty()
self.render()
# ------------------------------------------------------------ frames ---
def new_project(self):
if not self.confirm_discard("crear un proyecto nuevo"):
return
self.stop()
self.frames = [Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))]
self.background = None
self._bg_scaled = None
self.sprites = []
self.sprite_list.delete(0, "end")
self.current = 0
self.current_path = None
self.undo_stack.clear()
self.redo_stack.clear()
self._invalidate_onion()
self._invalidate_thumb()
self.dirty = False
self.render()
def new_frame(self):
idx = self.current + 1
prev_current = self.current
self.frames.insert(idx, Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0)))
self.current = idx
self.push_action(("insert", idx, prev_current))
self._invalidate_onion()
self._invalidate_thumb()
self.render()
def duplicate_frame(self):
idx = self.current + 1
prev_current = self.current
self.frames.insert(idx, self.frames[self.current].copy())
self.current = idx
self.push_action(("insert", idx, prev_current))
self._invalidate_onion()
self._invalidate_thumb()
self.render()
def delete_frame(self):
if len(self.frames) <= 1:
messagebox.showinfo(APP_NAME, "No podés borrar el único frame que queda.")
return
idx = self.current
img = self.frames[idx]
prev_current = self.current
del self.frames[idx]
self.current = min(idx, len(self.frames) - 1)
self.push_action(("delete", idx, img, prev_current))
self._invalidate_onion()
self._invalidate_thumb()
self.render()
def clear_frame(self):
self._begin_frame_edit()
self.frames[self.current] = Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))
self.render()
def select_frame(self, i):
i = max(0, min(i, len(self.frames) - 1))
if i == self.current:
return
self.current = i
self.render()
def timeline_click(self, e):
cx = self.timeline.canvasx(e.x)
i = int((cx - 22) // SLOT_W)
if 0 <= i < len(self.frames):
self.select_frame(i)
def _on_timeline_wheel(self, event):
if getattr(event, "delta", 0):
self.timeline.xview_scroll(-3 if event.delta > 0 else 3, "units")
elif getattr(event, "num", None) == 4:
self.timeline.xview_scroll(-3, "units")
elif getattr(event, "num", None) == 5:
self.timeline.xview_scroll(3, "units")
# -------------------------------------------------------- background ---
def choose_background(self):
path = filedialog.askopenfilename(
filetypes=[("Imágenes", "*.png *.jpg *.jpeg *.webp *.bmp"), ("Todos", "*.*")])
if not path:
return
try:
im = Image.open(path).convert("RGBA")
except Exception as e:
messagebox.showerror("Fondo", f"No se pudo abrir la imagen:\n{e}")
return
self.background = im
self._rebuild_bg_cache()
self.mark_dirty()
self.render()
def clear_background(self):
self.background = None
self._bg_scaled = None
self.mark_dirty()
self.render()
def _rebuild_bg_cache(self):
if self.background is None:
self._bg_scaled = None
return
bg = self.background.copy()
bg.thumbnail((self.W, self.H), Image.Resampling.LANCZOS)
self._bg_scaled = bg
self._bg_pos = ((self.W - bg.width) // 2, (self.H - bg.height) // 2)
# ------------------------------------------------------------ pintar ---
def coords(self, e):
cw = max(1, self.canvas.winfo_width() - 32)
ch = max(1, self.canvas.winfo_height() - 32)
scale = min(cw / self.W, ch / self.H)
if scale <= 0:
scale = 1
ox = (self.canvas.winfo_width() - self.W * scale) / 2
oy = (self.canvas.winfo_height() - self.H * scale) / 2
return (e.x - ox) / scale, (e.y - oy) / scale
def start_draw(self, e):
x, y = self.coords(e)
if not (0 <= x < self.W and 0 <= y < self.H):
return
if self.stamp_mode and self.pending_sprite is not None:
self.stamp_sprite_at(x, y)
return
self._begin_frame_edit()
self.last = (x, y)
self.paint(e)
def paint(self, e):
if self.last is None:
return
x, y = self.coords(e)
x = max(0, min(self.W - 1, x))
y = max(0, min(self.H - 1, y))
im = self.frames[self.current]
if self.tool == "eraser":
self.erase_line(im, self.last, (x, y), self.brush_size)
elif self.tool == "soft":
self.soft_line(im, self.last, (x, y), self.brush_size, self.hex_rgba(self.color))
else:
draw = ImageDraw.Draw(im, "RGBA")
width = max(1, int(self.brush_size))
color = self.hex_rgba(self.color)
draw.line([self.last, (x, y)], fill=color, width=width, joint="curve")
r = width / 2
draw.ellipse([x - r, y - r, x + r, y + r], fill=color)
self.last = (x, y)
self._invalidate_thumb(self.current)
self.render(full=False)
def end_draw(self, e):
self.last = None
self.render(full=True)
def hex_rgba(self, h):
h = h.lstrip("#")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) + (255,)
def soft_line(self, im, a, b, size, color):
x1, y1 = a
x2, y2 = b
r = size / 2
blur = max(1, size / 3)
pad = int(r + blur * 2 + 2)
minx = max(0, int(min(x1, x2) - pad))
miny = max(0, int(min(y1, y2) - pad))
maxx = min(im.width, int(max(x1, x2) + pad))
maxy = min(im.height, int(max(y1, y2) + pad))
w, h = max(1, maxx - minx), max(1, maxy - miny)
layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(layer, "RGBA")
dist = max(1, int(((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5))
for n in range(dist + 1):
t = n / dist
x = x1 + (x2 - x1) * t - minx
y = y1 + (y2 - y1) * t - miny
d.ellipse((x - r, y - r, x + r, y + r), fill=(color[0], color[1], color[2], 70))
layer = layer.filter(ImageFilter.GaussianBlur(blur))
im.alpha_composite(layer, (minx, miny))
def erase_line(self, im, a, b, size):
x1, y1 = a
x2, y2 = b
r = size / 2
pad = int(r + 2)
minx = max(0, int(min(x1, x2) - pad))
miny = max(0, int(min(y1, y2) - pad))
maxx = min(im.width, int(max(x1, x2) + pad))
maxy = min(im.height, int(max(y1, y2) + pad))
w, h = max(1, maxx - minx), max(1, maxy - miny)
mask = Image.new("L", (w, h), 0)
d = ImageDraw.Draw(mask)
width = max(1, int(size))
lx1, ly1 = x1 - minx, y1 - miny
lx2, ly2 = x2 - minx, y2 - miny
d.line([(lx1, ly1), (lx2, ly2)], fill=255, width=width, joint="curve")
d.ellipse((lx1 - r, ly1 - r, lx1 + r, ly1 + r), fill=255)
d.ellipse((lx2 - r, ly2 - r, lx2 + r, ly2 + r), fill=255)
region = im.crop((minx, miny, maxx, maxy))
alpha = region.getchannel("A")
inv_mask = mask.point(lambda p: 255 - p)
region.putalpha(ImageChops.multiply(alpha, inv_mask))
im.paste(region, (minx, miny))
# ------------------------------------------------------------ sprites ---
def add_sprite(self):
paths = filedialog.askopenfilenames(
filetypes=[("Imágenes", "*.png *.jpg *.jpeg *.webp *.gif"), ("Todos", "*.*")])
for p in paths:
try:
im = Image.open(p).convert("RGBA")
self.sprites.append((os.path.basename(p), im))
self.sprite_list.insert("end", os.path.basename(p))
self.mark_dirty()
except Exception as e:
messagebox.showerror("Sprite", str(e))
def remove_sprite(self):
sel = self.sprite_list.curselection()
if not sel:
return
i = sel[0]
del self.sprites[i]
self.sprite_list.delete(i)
self.mark_dirty()
def place_sprite(self):
sel = self.sprite_list.curselection()
if not sel:
messagebox.showinfo(APP_NAME, "Elegí primero un sprite de la lista.")
return
_, im = self.sprites[sel[0]]
self.pending_sprite = im
self.stamp_mode = True
self.canvas.configure(cursor="crosshair")
self.status.configure(text="Hacé clic en el lienzo para colocar el sprite (Esc para cancelar).")
def stamp_sprite_at(self, x, y):
self._begin_frame_edit()
layer = self.pending_sprite.copy()
maxw, maxh = self.W // 2, self.H // 2
if layer.width > maxw or layer.height > maxh:
layer.thumbnail((maxw, maxh), Image.Resampling.LANCZOS)
px = int(x - layer.width / 2)
py = int(y - layer.height / 2)
self.frames[self.current].alpha_composite(layer, (px, py))
self.stamp_mode = False
self.pending_sprite = None
self.canvas.configure(cursor="")
self.render()
def paste_image(self):
im = self._read_clipboard_image()
if im is None:
messagebox.showinfo(
"Pegar imagen",
"No encontré una imagen en el portapapeles.\n"
"En CachyOS/Wayland instalá wl-clipboard: sudo pacman -S wl-clipboard")
return
self._begin_frame_edit()
im.thumbnail((self.W, self.H), Image.Resampling.LANCZOS)
x = (self.W - im.width) // 2
y = (self.H - im.height) // 2
self.frames[self.current].alpha_composite(im, (x, y))
self.render()
def _read_clipboard_image(self):
for cmd in (["wl-paste", "--no-newline", "--type", "image/png"],
["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]):
try:
p = subprocess.run(cmd, capture_output=True, timeout=2)
if p.returncode == 0 and p.stdout:
return Image.open(io.BytesIO(p.stdout)).convert("RGBA")
except Exception:
continue
return None
# ---------------------------------------------------------- render ----
def composite(self, index):
out = Image.new("RGBA", (self.W, self.H), (255, 255, 255, 255))
if self._bg_scaled is not None:
out.alpha_composite(self._bg_scaled, self._bg_pos)
out.alpha_composite(self.frames[index])
return out
def _ensure_onion_cache(self):
if self._onion_valid and self._onion_index == self.current:
return
self._onion_index = self.current
self._onion_valid = True
if self.current > 0:
prev = self.frames[self.current - 1].copy()
prev.putalpha(prev.getchannel("A").point(lambda p: int(p * 0.22)))
self._onion_prev = prev
else:
self._onion_prev = None
if self.current < len(self.frames) - 1:
nxt = self.frames[self.current + 1].copy()
nxt.putalpha(nxt.getchannel("A").point(lambda p: int(p * 0.12)))
self._onion_next = nxt
else:
self._onion_next = None
def render(self, full=True):
cw = max(50, self.canvas.winfo_width() - 32)
ch = max(50, self.canvas.winfo_height() - 32)
scale = min(cw / self.W, ch / self.H)
size = (max(1, int(self.W * scale)), max(1, int(self.H * scale)))
img = self.composite(self.current)
if self.onion_var.get():
self._ensure_onion_cache()
if self._onion_prev is not None:
img.alpha_composite(self._onion_prev)
if self._onion_next is not None:
img.alpha_composite(self._onion_next)
resample = Image.Resampling.LANCZOS if full else Image.Resampling.BILINEAR
img = img.resize(size, resample)
self.tk_image = ImageTk.PhotoImage(img)
self.canvas.delete("all")
self.canvas.create_image(self.canvas.winfo_width() / 2, self.canvas.winfo_height() / 2,
image=self.tk_image)
if full:
self.draw_timeline()
self.update_status()
def update_status(self):
mode = "colocando sprite" if self.stamp_mode else self.tool
star = " •" if self.dirty else ""
self.status.configure(
text=f"Frame {self.current + 1}/{len(self.frames)} • {self.get_fps()} FPS • {mode}{star}")
self.frame_count_label.configure(text=f"{len(self.frames)} frame(s)")
def _get_thumb(self, i):
if i not in self._thumb_cache:
im = self.composite(i).convert("RGB")
im.thumbnail((THUMB_W, THUMB_H), Image.Resampling.LANCZOS)
self._thumb_cache[i] = ImageTk.PhotoImage(im)
return self._thumb_cache[i]
def draw_timeline(self):
self.timeline.delete("all")
total = len(self.frames)
view_w = self.timeline.winfo_width() or 600
xleft = self.timeline.canvasx(0)
first = max(0, int((xleft - 22) // SLOT_W) - 1)
last = min(total - 1, int((xleft + view_w - 22) // SLOT_W) + 1)
self._thumb_refs = []
for i in range(first, last + 1):
x = 22 + i * SLOT_W
self.timeline.create_text(x, 10, text=str(i + 1), fill="#8f99a8")
fill = self.accent if i == self.current else "#333a45"
self.timeline.create_rectangle(x - 19, 22, x + 19, 22 + THUMB_H + 4, outline=fill, width=2)
thumb = self._get_thumb(i)
self.timeline.create_image(x, 24 + THUMB_H // 2, image=thumb)
self._thumb_refs.append(thumb)
self.timeline.configure(scrollregion=(0, 0, 22 + total * SLOT_W + 40, 92))
# asegura que el frame actual quede visible
cur_x = 22 + self.current * SLOT_W
if cur_x < xleft + 40 or cur_x > xleft + view_w - 40:
frac_total = max(1, 22 + total * SLOT_W + 40)
self.timeline.xview_moveto(max(0.0, (cur_x - view_w / 2) / frac_total))
# ------------------------------------------------------ reproducción ---
def toggle_play(self):
if self.playing:
self.stop()
else:
self.playing = True
self.play_next()
def stop(self):
self.playing = False
if self.play_after:
try:
self.root.after_cancel(self.play_after)
except Exception:
pass
self.play_after = None
def play_next(self):
if not self.playing:
return
self.current = (self.current + 1) % len(self.frames)
self.render()
delay = max(1, int(1000 / self.get_fps()))
self.play_after = self.root.after(delay, self.play_next)
# ------------------------------------------------------- exportación ---
def export_gif(self):
if len(self.frames) < 1:
return
path = filedialog.asksaveasfilename(defaultextension=".gif",
filetypes=[("GIF animado", "*.gif")])
if not path:
return
try:
duration_ms = max(20, int(1000 / self.get_fps()))
composed = [self.composite(i).convert("RGB") for i in range(len(self.frames))]
composed[0].save(path, save_all=True, append_images=composed[1:],
duration=duration_ms, loop=0)
messagebox.showinfo("Exportar GIF", f"GIF exportado:\n{path}")
except Exception as e:
messagebox.showerror("Exportar GIF", str(e))
def export_png_sequence(self):
folder = filedialog.askdirectory(title="Elegí una carpeta para los PNG")
if not folder:
return
try:
for i in range(len(self.frames)):
self.composite(i).save(os.path.join(folder, f"frame_{i + 1:03d}.png"))
messagebox.showinfo("Exportar PNG", f"Se exportaron {len(self.frames)} imágenes a:\n{folder}")
except Exception as e:
messagebox.showerror("Exportar PNG", str(e))
# ------------------------------------------------------ guardar/abrir ---
def project_data(self):
payload = {
"format": FORMAT_NAME, "version": 2, "app": APP_NAME,
"fps": self.get_fps(), "width": self.W, "height": self.H,
"community_owned": True, "frames": [], "sprites": [],
}
for im in self.frames:
buf = io.BytesIO()
im.save(buf, "PNG")
payload["frames"].append(base64.b64encode(buf.getvalue()).decode())
if self.background is not None:
buf = io.BytesIO()
self.background.save(buf, "PNG")
payload["background"] = base64.b64encode(buf.getvalue()).decode()
for name, im in self.sprites:
buf = io.BytesIO()
im.save(buf, "PNG")
payload["sprites"].append({"name": name, "image": base64.b64encode(buf.getvalue()).decode()})
return payload
def load_project_data(self, data):
if data.get("format") not in LEGACY_FORMAT_NAMES:
raise ValueError("No es un archivo de Tahgibu Animator válido")
self.stop()
self.fps_var.set(max(1, min(60, int(data.get("fps", 12)))))
self.W = int(data.get("width", 960))
self.H = int(data.get("height", 540))
frames = []
for raw in data.get("frames", []):
im = Image.open(io.BytesIO(base64.b64decode(raw))).convert("RGBA")
if im.size != (self.W, self.H):
im = im.resize((self.W, self.H), Image.Resampling.LANCZOS)
frames.append(im)
self.frames = frames or [Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))]
self.current = 0
self.background = None
if data.get("background"):
self.background = Image.open(io.BytesIO(base64.b64decode(data["background"]))).convert("RGBA")
self._rebuild_bg_cache()
self.sprites = []
self.sprite_list.delete(0, "end")
for s in data.get("sprites", []):
try:
im = Image.open(io.BytesIO(base64.b64decode(s["image"]))).convert("RGBA")
self.sprites.append((s.get("name", "sprite"), im))
self.sprite_list.insert("end", s.get("name", "sprite"))
except Exception:
pass
self.undo_stack.clear()
self.redo_stack.clear()
self._invalidate_onion()
self._invalidate_thumb()
self.dirty = False
self.render()
def save_asm(self):
if self.current_path:
self._write_project(self.current_path)
else:
self.save_asm_as()
def save_asm_as(self):
path = filedialog.asksaveasfilename(defaultextension=FILE_EXT,
filetypes=[("Tahgibu Animator", f"*{FILE_EXT}")])
if not path:
return
self._write_project(path)
def _write_project(self, path):
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(self.project_data(), f, separators=(",", ":"))
self.current_path = path
self.dirty = False
self.update_status()
messagebox.showinfo(APP_NAME, "Proyecto guardado.")
except Exception as e:
messagebox.showerror(APP_NAME, f"No se pudo guardar:\n{e}")
def open_asm(self):
if not self.confirm_discard("abrir otro proyecto"):
return
path = filedialog.askopenfilename(filetypes=[("Tahgibu Animator", f"*{FILE_EXT} *.asm"),
("Todos", "*.*")])
if not path:
return
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
self.load_project_data(data)
self.current_path = path
except Exception as e:
messagebox.showerror(APP_NAME, f"No se pudo abrir:\n{e}")
def import_asm(self):
path = filedialog.askopenfilename(filetypes=[("Tahgibu Animator", f"*{FILE_EXT} *.asm"),
("Todos", "*.*")])
if not path:
return
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
if data.get("format") not in LEGACY_FORMAT_NAMES:
raise ValueError("Archivo incompatible")
incoming = []
for raw in data.get("frames", []):
im = Image.open(io.BytesIO(base64.b64decode(raw))).convert("RGBA")
im = im.resize((self.W, self.H), Image.Resampling.LANCZOS)
incoming.append(im)
if incoming:
idx = self.current + 1
self.frames[idx:idx] = incoming
self.current = idx
# Importar varios frames de golpe no se registra en Deshacer (Ctrl+Z)
# por simplicidad; se avisa al usuario en el mensaje de abajo.
self._invalidate_onion()
self._invalidate_thumb()
self.mark_dirty()
self.render()
messagebox.showinfo("Importar", f"Se importaron {len(incoming)} frame(s). "
"Nota: esta acción no se puede deshacer con Ctrl+Z.")
except Exception as e:
messagebox.showerror("Importar", str(e))
if __name__ == "__main__":
root = tk.Tk()
app = TahgibuAnimator(root)
root.mainloop()