#!/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()