diff --git a/README.md b/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/Tahgibu-Animator.zip b/Tahgibu-Animator.zip new file mode 100644 index 0000000..ef852d1 Binary files /dev/null and b/Tahgibu-Animator.zip differ diff --git a/Tahgibu-Animator/README.md b/Tahgibu-Animator/README.md new file mode 100644 index 0000000..3100def --- /dev/null +++ b/Tahgibu-Animator/README.md @@ -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. diff --git a/Tahgibu-Animator/install.sh b/Tahgibu-Animator/install.sh new file mode 100755 index 0000000..b576a9f --- /dev/null +++ b/Tahgibu-Animator/install.sh @@ -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" diff --git a/Tahgibu-Animator/tahgibu-animator.desktop b/Tahgibu-Animator/tahgibu-animator.desktop new file mode 100755 index 0000000..4ed5e64 --- /dev/null +++ b/Tahgibu-Animator/tahgibu-animator.desktop @@ -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 diff --git a/Tahgibu-Animator/tahgibu_animator.py b/Tahgibu-Animator/tahgibu_animator.py new file mode 100755 index 0000000..e86ffca --- /dev/null +++ b/Tahgibu-Animator/tahgibu_animator.py @@ -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("", lambda e: self.render()) + self.canvas.bind("", self.start_draw) + self.canvas.bind("", self.paint) + self.canvas.bind("", 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("", self.timeline_click) + self.timeline.bind("", self._on_timeline_wheel) + self.timeline.bind("", self._on_timeline_wheel) + self.timeline.bind("", self._on_timeline_wheel) + + # -------------------------------------------------------- atajos --- + self.root.bind("", lambda e: self.new_frame()) + self.root.bind("", lambda e: self.select_frame(self.current - 1)) + self.root.bind("", lambda e: self.select_frame(self.current + 1)) + self.root.bind("", lambda e: self.select_frame(0)) + self.root.bind("", lambda e: self.select_frame(len(self.frames) - 1)) + self.root.bind("", lambda e: self.toggle_play()) + self.root.bind("", lambda e: self.undo()) + self.root.bind("", lambda e: self.redo()) + self.root.bind("", lambda e: self.redo()) + self.root.bind("", lambda e: self.paste_image()) + self.root.bind("", lambda e: self.save_asm()) + self.root.bind("", lambda e: self.new_project()) + self.root.bind("", lambda e: self.open_asm()) + self.root.bind("", 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()