From d5dd2da1cb3c06e33854ea88be6b119682487812 Mon Sep 17 00:00:00 2001 From: TEC-GO Date: Sat, 19 Sep 2026 12:59:30 -0300 Subject: [PATCH 1/5] first commit --- README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 From adb5f65bc7c2b92e5d2c41d5343487c42a4a5d97 Mon Sep 17 00:00:00 2001 From: TEC-GO Date: Sat, 19 Sep 2026 13:41:55 -0300 Subject: [PATCH 2/5] cambios --- README.md | 0 Tahgibu-Animator.zip | Bin 0 -> 12581 bytes Tahgibu-Animator/README.md | 64 ++ Tahgibu-Animator/install.sh | 13 + Tahgibu-Animator/tahgibu-animator.desktop | 10 + Tahgibu-Animator/tahgibu_animator.py | 955 ++++++++++++++++++++++ 6 files changed, 1042 insertions(+) delete mode 100644 README.md create mode 100644 Tahgibu-Animator.zip create mode 100644 Tahgibu-Animator/README.md create mode 100755 Tahgibu-Animator/install.sh create mode 100755 Tahgibu-Animator/tahgibu-animator.desktop create mode 100755 Tahgibu-Animator/tahgibu_animator.py 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 0000000000000000000000000000000000000000..ef852d15a449ebe8b3760f73eb591638e843aaae GIT binary patch literal 12581 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 CWSH9k literal 0 HcmV?d00001 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() From 16b95b1d819d6182dccf5b70e51539e2614acea8 Mon Sep 17 00:00:00 2001 From: AlenikaKoda Date: Sat, 19 Sep 2026 14:13:13 -0300 Subject: [PATCH 3/5] Fix timeline frame buttons --- Tahgibu-Animator/README.md => README.md | 0 Tahgibu-Animator.zip | Bin 12581 -> 0 bytes Tahgibu-Animator/tahgibu-animator.desktop | 10 ---------- Tahgibu-Animator/install.sh => install.sh | 0 .../tahgibu_animator.py => tahgibu_animator.py | 2 +- 5 files changed, 1 insertion(+), 11 deletions(-) rename Tahgibu-Animator/README.md => README.md (100%) delete mode 100644 Tahgibu-Animator.zip delete mode 100755 Tahgibu-Animator/tahgibu-animator.desktop rename Tahgibu-Animator/install.sh => install.sh (100%) rename Tahgibu-Animator/tahgibu_animator.py => tahgibu_animator.py (99%) diff --git a/Tahgibu-Animator/README.md b/README.md similarity index 100% rename from Tahgibu-Animator/README.md rename to README.md diff --git a/Tahgibu-Animator.zip b/Tahgibu-Animator.zip deleted file mode 100644 index ef852d15a449ebe8b3760f73eb591638e843aaae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12581 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 CWSH9k diff --git a/Tahgibu-Animator/tahgibu-animator.desktop b/Tahgibu-Animator/tahgibu-animator.desktop deleted file mode 100755 index 4ed5e64..0000000 --- a/Tahgibu-Animator/tahgibu-animator.desktop +++ /dev/null @@ -1,10 +0,0 @@ -#!/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/install.sh b/install.sh similarity index 100% rename from Tahgibu-Animator/install.sh rename to install.sh diff --git a/Tahgibu-Animator/tahgibu_animator.py b/tahgibu_animator.py similarity index 99% rename from Tahgibu-Animator/tahgibu_animator.py rename to tahgibu_animator.py index e86ffca..e115524 100755 --- a/Tahgibu-Animator/tahgibu_animator.py +++ b/tahgibu_animator.py @@ -471,7 +471,7 @@ class TahgibuAnimator: def timeline_click(self, e): cx = self.timeline.canvasx(e.x) - i = int((cx - 22) // SLOT_W) + i = int((cx) // (SLOT_W)) if 0 <= i < len(self.frames): self.select_frame(i) From b3d43a500489b9267f892e54b95fa26af682b3c0 Mon Sep 17 00:00:00 2001 From: Acrux Development Date: Sat, 19 Sep 2026 14:23:25 -0300 Subject: [PATCH 4/5] Added requirements.txt --- requirements.txt | Bin 0 -> 30 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..67b7068fd81bb301a8d6b6328d97df2fe8a5b63b GIT binary patch literal 30 lcmezWuYe(wA%`J{A)ld~!Ir_6!H~g-L65 Date: Sat, 19 Sep 2026 16:56:17 -0300 Subject: [PATCH 5/5] Add pyinstaller --- .gitignore | 3 +++ build.bat | 2 ++ build.sh | 2 ++ 3 files changed, 7 insertions(+) create mode 100644 .gitignore create mode 100644 build.bat create mode 100644 build.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..490310b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +dist/ +*.spec \ No newline at end of file diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..0bb2bb4 --- /dev/null +++ b/build.bat @@ -0,0 +1,2 @@ +python -m pip install pyinstaller +python pyinstaller .\tahgibu_animator.py \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..0bb2bb4 --- /dev/null +++ b/build.sh @@ -0,0 +1,2 @@ +python -m pip install pyinstaller +python pyinstaller .\tahgibu_animator.py \ No newline at end of file