956 lines
39 KiB
Python
Executable File
956 lines
39 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Tahgibu Animator
|
||
Animación 2D cuadro por cuadro, simple y sin marca de agua.
|
||
Pensado para andar liviano en equipos modestos (Linux).
|
||
"""
|
||
import tkinter as tk
|
||
from tkinter import ttk, filedialog, messagebox, colorchooser
|
||
import os, json, base64, io, subprocess
|
||
from PIL import Image, ImageTk, ImageDraw, ImageFilter, ImageChops
|
||
|
||
APP_NAME = "Tahgibu Animator"
|
||
VERSION = "1.0.0"
|
||
FORMAT_NAME = "TAHGIBU"
|
||
LEGACY_FORMAT_NAMES = ("TAHGIBU", "ASM") # ASM = formato del proyecto original, se puede seguir abriendo
|
||
FILE_EXT = ".tgb"
|
||
UNDO_LIMIT = 40
|
||
THUMB_W, THUMB_H = 34, 34
|
||
SLOT_W = 44
|
||
|
||
|
||
class TahgibuAnimator:
|
||
def __init__(self, root):
|
||
self.root = root
|
||
self.root.title(f"{APP_NAME} {VERSION}")
|
||
self.root.geometry("1280x800")
|
||
self.root.minsize(980, 650)
|
||
|
||
# --- paleta ---
|
||
self.bg_color = "#171a20"
|
||
self.panel = "#20242b"
|
||
self.panel2 = "#292f38"
|
||
self.accent = "#6c7cff"
|
||
self.text = "#eef1f6"
|
||
self.muted = "#9ca6b5"
|
||
self.root.configure(bg=self.bg_color)
|
||
|
||
# --- estado de la animación ---
|
||
self.W, self.H = 960, 540
|
||
self.fps = 12
|
||
self.current = 0
|
||
self.frames = [Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))]
|
||
self.background = None # imagen original (para guardar)
|
||
self._bg_scaled = None # versión ya escalada (cache, no se recalcula en cada render)
|
||
self._bg_pos = (0, 0)
|
||
self.sprites = [] # lista de (nombre, imagen)
|
||
|
||
# --- herramientas ---
|
||
self.color = "#111111"
|
||
self.brush_size = 7
|
||
self.tool = "solid"
|
||
self.stamp_mode = False
|
||
self.pending_sprite = None
|
||
|
||
# --- reproducción ---
|
||
self.playing = False
|
||
self.play_after = None
|
||
|
||
# --- deshacer / rehacer (por acción, no copia toda la animación) ---
|
||
self.undo_stack = []
|
||
self.redo_stack = []
|
||
|
||
# --- varios ---
|
||
self.tk_image = None
|
||
self.last = None
|
||
self.dirty = False
|
||
self.current_path = None
|
||
self._thumb_cache = {}
|
||
self._onion_index = None
|
||
self._onion_valid = False
|
||
self._onion_prev = None
|
||
self._onion_next = None
|
||
|
||
self.make_ui()
|
||
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
|
||
self.root.update_idletasks() # asegura tamaño real del canvas antes del primer dibujo
|
||
self.render()
|
||
|
||
# ---------------------------------------------------------------- UI ---
|
||
def make_ui(self):
|
||
top = tk.Frame(self.root, bg=self.panel, height=54)
|
||
top.pack(fill="x")
|
||
top.pack_propagate(False)
|
||
|
||
tk.Label(top, text="Tahgibu", bg=self.panel, fg=self.accent,
|
||
font=("DejaVu Sans", 16, "bold")).pack(side="left", padx=(16, 3))
|
||
tk.Label(top, text="ANIMATOR", bg=self.panel, fg=self.text,
|
||
font=("DejaVu Sans", 11, "bold")).pack(side="left", padx=(0, 18))
|
||
|
||
def top_btn(txt, cmd):
|
||
return tk.Button(top, text=txt, command=cmd, bg=self.panel2, fg=self.text,
|
||
activebackground="#3a414d", activeforeground="white",
|
||
relief="flat", padx=10)
|
||
|
||
for txt, cmd in [
|
||
("Nuevo", self.new_project),
|
||
("Abrir", self.open_asm),
|
||
("Guardar", self.save_asm),
|
||
("Guardar como…", self.save_asm_as),
|
||
("Importar", self.import_asm),
|
||
("Pegar imagen", self.paste_image),
|
||
]:
|
||
top_btn(txt, cmd).pack(side="left", padx=3, pady=10)
|
||
|
||
tk.Frame(top, bg="#3a414d", width=1).pack(side="left", fill="y", pady=12, padx=6)
|
||
|
||
top_btn("↶ Deshacer", self.undo).pack(side="left", padx=3, pady=10)
|
||
top_btn("↷ Rehacer", self.redo).pack(side="left", padx=3, pady=10)
|
||
|
||
tk.Frame(top, bg="#3a414d", width=1).pack(side="left", fill="y", pady=12, padx=6)
|
||
|
||
self.export_menu_btn = tk.Menubutton(top, text="Exportar ▾", bg=self.panel2, fg=self.text,
|
||
activebackground="#3a414d", relief="flat", padx=10)
|
||
export_menu = tk.Menu(self.export_menu_btn, tearoff=0)
|
||
export_menu.add_command(label="GIF animado…", command=self.export_gif)
|
||
export_menu.add_command(label="Secuencia de PNG…", command=self.export_png_sequence)
|
||
self.export_menu_btn.configure(menu=export_menu)
|
||
self.export_menu_btn.pack(side="left", padx=3, pady=10)
|
||
|
||
top_btn("❓ Ayuda", self.show_help).pack(side="left", padx=3, pady=10)
|
||
|
||
tk.Button(top, text="▶", command=self.toggle_play, bg=self.accent, fg="white",
|
||
relief="flat", width=4).pack(side="right", padx=(3, 16), pady=10)
|
||
self.fps_var = tk.IntVar(value=self.fps)
|
||
tk.Spinbox(top, from_=1, to=60, textvariable=self.fps_var, width=4,
|
||
bg=self.panel2, fg=self.text, buttonbackground=self.panel2,
|
||
relief="flat").pack(side="right", padx=4)
|
||
tk.Label(top, text="FPS", bg=self.panel, fg=self.muted).pack(side="right")
|
||
|
||
body = tk.Frame(self.root, bg=self.bg_color)
|
||
body.pack(fill="both", expand=True)
|
||
|
||
# ------------------------------------------------------ panel izq ---
|
||
tools = tk.Frame(body, bg=self.panel, width=210)
|
||
tools.pack(side="left", fill="y")
|
||
tools.pack_propagate(False)
|
||
|
||
tk.Label(tools, text="HERRAMIENTAS", bg=self.panel, fg=self.muted,
|
||
font=("DejaVu Sans", 9, "bold")).pack(anchor="w", padx=14, pady=(16, 8))
|
||
|
||
self.tool_buttons = {}
|
||
for key, label in [
|
||
("soft", "◌ Lápiz suave"),
|
||
("solid", "● Lápiz sólido"),
|
||
("eraser", "⌫ Borrador"),
|
||
]:
|
||
b = tk.Button(tools, text=label, command=lambda k=key: self.set_tool(k),
|
||
bg=self.panel2, fg=self.text, activebackground=self.accent,
|
||
activeforeground="white", relief="flat", anchor="w", padx=12)
|
||
b.pack(fill="x", padx=10, pady=3)
|
||
self.tool_buttons[key] = b
|
||
|
||
tk.Button(tools, text="🎨 Elegir color", command=self.choose_color,
|
||
bg=self.panel2, fg=self.text, activebackground="#3a414d",
|
||
relief="flat", anchor="w", padx=12).pack(fill="x", padx=10, pady=(14, 4))
|
||
self.color_preview = tk.Label(tools, text=" ", bg=self.color, relief="solid", bd=1)
|
||
self.color_preview.pack(anchor="w", padx=14, pady=3)
|
||
|
||
size_row = tk.Frame(tools, bg=self.panel)
|
||
size_row.pack(fill="x", padx=14, pady=(14, 0))
|
||
tk.Label(size_row, text="Tamaño", bg=self.panel, fg=self.muted).pack(side="left")
|
||
self.size_label = tk.Label(size_row, text=str(self.brush_size), bg=self.panel, fg=self.text)
|
||
self.size_label.pack(side="right")
|
||
self.size_scale = tk.Scale(tools, from_=1, to=60, orient="horizontal",
|
||
command=self.on_size_change,
|
||
bg=self.panel, fg=self.text, troughcolor="#363d48",
|
||
highlightthickness=0)
|
||
self.size_scale.set(self.brush_size)
|
||
self.size_scale.pack(fill="x", padx=8)
|
||
|
||
self.onion_var = tk.BooleanVar(value=True)
|
||
tk.Checkbutton(tools, text="Onion Skin", variable=self.onion_var,
|
||
command=self.render, bg=self.panel, fg=self.text,
|
||
selectcolor=self.panel2, activebackground=self.panel,
|
||
activeforeground=self.text).pack(anchor="w", padx=10, pady=(12, 2))
|
||
|
||
tk.Button(tools, text="🗑 Vaciar frame actual", command=self.clear_frame,
|
||
bg=self.panel2, fg=self.text, relief="flat", anchor="w",
|
||
padx=12).pack(fill="x", padx=10, pady=(6, 4))
|
||
|
||
tk.Label(tools, text="FONDO", bg=self.panel, fg=self.muted,
|
||
font=("DejaVu Sans", 9, "bold")).pack(anchor="w", padx=14, pady=(16, 6))
|
||
bg_row = tk.Frame(tools, bg=self.panel)
|
||
bg_row.pack(fill="x", padx=10)
|
||
tk.Button(bg_row, text="Elegir…", command=self.choose_background,
|
||
bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(0, 3))
|
||
tk.Button(bg_row, text="Quitar", command=self.clear_background,
|
||
bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(3, 0))
|
||
|
||
tk.Label(tools, text="SPRITES", bg=self.panel, fg=self.muted,
|
||
font=("DejaVu Sans", 9, "bold")).pack(anchor="w", padx=14, pady=(16, 6))
|
||
sprite_btn_row = tk.Frame(tools, bg=self.panel)
|
||
sprite_btn_row.pack(fill="x", padx=10)
|
||
tk.Button(sprite_btn_row, text="+ Añadir", command=self.add_sprite,
|
||
bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(0, 3))
|
||
tk.Button(sprite_btn_row, text="− Quitar", command=self.remove_sprite,
|
||
bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(3, 0))
|
||
self.sprite_list = tk.Listbox(tools, bg="#171a20", fg=self.text, height=6,
|
||
relief="flat", highlightthickness=0)
|
||
self.sprite_list.pack(fill="both", expand=True, padx=10, pady=5)
|
||
tk.Button(tools, text="Poner sprite en el lienzo", command=self.place_sprite,
|
||
bg=self.accent, fg="white", relief="flat").pack(fill="x", padx=10, pady=(2, 10))
|
||
|
||
# ---------------------------------------------------------- lienzo ---
|
||
stage = tk.Frame(body, bg="#0f1115")
|
||
stage.pack(side="left", fill="both", expand=True)
|
||
|
||
self.canvas = tk.Canvas(stage, bg="#0f1115", highlightthickness=0)
|
||
self.canvas.pack(fill="both", expand=True, padx=16, pady=16)
|
||
self.canvas.bind("<Configure>", lambda e: self.render())
|
||
self.canvas.bind("<ButtonPress-1>", self.start_draw)
|
||
self.canvas.bind("<B1-Motion>", self.paint)
|
||
self.canvas.bind("<ButtonRelease-1>", self.end_draw)
|
||
|
||
self.status = tk.Label(stage, text="", bg="#0f1115", fg=self.muted, anchor="w")
|
||
self.status.pack(fill="x", padx=16, pady=(0, 8))
|
||
|
||
# -------------------------------------------------------- timeline ---
|
||
timeline_wrap = tk.Frame(self.root, bg=self.panel, height=150)
|
||
timeline_wrap.pack(fill="x")
|
||
timeline_wrap.pack_propagate(False)
|
||
|
||
row = tk.Frame(timeline_wrap, bg=self.panel)
|
||
row.pack(fill="x")
|
||
tk.Label(row, text="TIMELINE", bg=self.panel, fg=self.text,
|
||
font=("DejaVu Sans", 10, "bold")).pack(side="left", padx=12, pady=8)
|
||
for txt, cmd in [("+ Frame", self.new_frame), ("Duplicar", self.duplicate_frame),
|
||
("Eliminar", self.delete_frame)]:
|
||
tk.Button(row, text=txt, command=cmd, bg=self.panel2, fg=self.text,
|
||
relief="flat").pack(side="left", padx=3)
|
||
self.frame_count_label = tk.Label(row, text="", bg=self.panel, fg=self.muted)
|
||
self.frame_count_label.pack(side="right", padx=12)
|
||
|
||
tl_body = tk.Frame(timeline_wrap, bg=self.panel)
|
||
tl_body.pack(fill="x", padx=10, pady=(0, 8))
|
||
self.timeline = tk.Canvas(tl_body, bg="#15181d", height=92, highlightthickness=0,
|
||
xscrollincrement=20)
|
||
self.timeline.pack(fill="x", side="top")
|
||
self.timeline_scroll = tk.Scrollbar(tl_body, orient="horizontal", command=self.timeline.xview)
|
||
self.timeline_scroll.pack(fill="x", side="top")
|
||
self.timeline.configure(xscrollcommand=self.timeline_scroll.set)
|
||
self.timeline.bind("<Button-1>", self.timeline_click)
|
||
self.timeline.bind("<MouseWheel>", self._on_timeline_wheel)
|
||
self.timeline.bind("<Button-4>", self._on_timeline_wheel)
|
||
self.timeline.bind("<Button-5>", self._on_timeline_wheel)
|
||
|
||
# -------------------------------------------------------- atajos ---
|
||
self.root.bind("<F7>", lambda e: self.new_frame())
|
||
self.root.bind("<Left>", lambda e: self.select_frame(self.current - 1))
|
||
self.root.bind("<Right>", lambda e: self.select_frame(self.current + 1))
|
||
self.root.bind("<Home>", lambda e: self.select_frame(0))
|
||
self.root.bind("<End>", lambda e: self.select_frame(len(self.frames) - 1))
|
||
self.root.bind("<space>", lambda e: self.toggle_play())
|
||
self.root.bind("<Control-z>", lambda e: self.undo())
|
||
self.root.bind("<Control-y>", lambda e: self.redo())
|
||
self.root.bind("<Control-Shift-Z>", lambda e: self.redo())
|
||
self.root.bind("<Control-v>", lambda e: self.paste_image())
|
||
self.root.bind("<Control-s>", lambda e: self.save_asm())
|
||
self.root.bind("<Control-n>", lambda e: self.new_project())
|
||
self.root.bind("<Control-o>", lambda e: self.open_asm())
|
||
self.root.bind("<Escape>", self.on_escape)
|
||
|
||
self.set_tool("solid")
|
||
|
||
# ----------------------------------------------------------- ayudas ---
|
||
def show_help(self):
|
||
messagebox.showinfo(
|
||
"Ayuda - " + APP_NAME,
|
||
"Atajos de teclado:\n\n"
|
||
"F7 — nuevo frame\n"
|
||
"←/→ — frame anterior / siguiente\n"
|
||
"Inicio/Fin — primer / último frame\n"
|
||
"Espacio — reproducir / pausar\n"
|
||
"Ctrl+Z / Ctrl+Y — deshacer / rehacer\n"
|
||
"Ctrl+S — guardar\n"
|
||
"Ctrl+O — abrir\n"
|
||
"Ctrl+N — nuevo proyecto\n"
|
||
"Ctrl+V — pegar imagen del portapapeles\n"
|
||
"Esc — cancelar acción / detener reproducción\n\n"
|
||
"Flujo básico: dibujá en el lienzo, apretá F7 para el siguiente\n"
|
||
"frame, repetí, y usá ▶ para ver la animación."
|
||
)
|
||
|
||
def on_escape(self, e):
|
||
if self.stamp_mode:
|
||
self.stamp_mode = False
|
||
self.pending_sprite = None
|
||
self.canvas.configure(cursor="")
|
||
self.update_status()
|
||
elif self.playing:
|
||
self.stop()
|
||
self.render(full=False)
|
||
|
||
def mark_dirty(self):
|
||
self.dirty = True
|
||
|
||
def confirm_discard(self, action="continuar"):
|
||
if not self.dirty:
|
||
return True
|
||
return messagebox.askyesno("Cambios sin guardar",
|
||
f"Hay cambios sin guardar. ¿Querés {action} de todas formas?")
|
||
|
||
def on_close(self):
|
||
self.stop()
|
||
if self.dirty and not messagebox.askyesno("Salir", "Hay cambios sin guardar. ¿Salir de todas formas?"):
|
||
return
|
||
self.root.destroy()
|
||
|
||
def get_fps(self):
|
||
try:
|
||
v = int(self.fps_var.get())
|
||
return max(1, min(60, v))
|
||
except Exception:
|
||
return self.fps
|
||
|
||
def on_size_change(self, v):
|
||
self.brush_size = max(1, int(float(v)))
|
||
self.size_label.configure(text=str(self.brush_size))
|
||
|
||
def set_tool(self, tool):
|
||
self.tool = tool
|
||
self.stamp_mode = False
|
||
self.pending_sprite = None
|
||
self.canvas.configure(cursor="")
|
||
for k, b in self.tool_buttons.items():
|
||
b.configure(bg=self.accent if k == tool else self.panel2)
|
||
|
||
def choose_color(self):
|
||
picked = colorchooser.askcolor(color=self.color, title="Elegir color")
|
||
if picked and picked[1]:
|
||
self.color = picked[1]
|
||
self.color_preview.configure(bg=self.color)
|
||
|
||
# ------------------------------------------------- deshacer / rehacer ---
|
||
def push_action(self, action):
|
||
self.undo_stack.append(action)
|
||
if len(self.undo_stack) > UNDO_LIMIT:
|
||
self.undo_stack.pop(0)
|
||
self.redo_stack.clear()
|
||
self.mark_dirty()
|
||
|
||
def _begin_frame_edit(self):
|
||
"""Guarda una copia del frame actual ANTES de modificarlo (para poder deshacer)."""
|
||
self.push_action(("paint", self.current, self.frames[self.current].copy()))
|
||
self._invalidate_thumb(self.current)
|
||
|
||
def _invalidate_onion(self):
|
||
self._onion_valid = False
|
||
|
||
def _invalidate_thumb(self, i=None):
|
||
if i is None:
|
||
self._thumb_cache.clear()
|
||
else:
|
||
self._thumb_cache.pop(i, None)
|
||
|
||
def _apply_inverse(self, action):
|
||
"""Aplica una acción y devuelve la acción opuesta (usado tanto por undo como por redo)."""
|
||
kind = action[0]
|
||
if kind == "paint":
|
||
_, idx, img = action
|
||
idx = min(idx, len(self.frames) - 1)
|
||
current_img = self.frames[idx].copy()
|
||
self.frames[idx] = img
|
||
self.current = idx
|
||
self._invalidate_thumb(idx)
|
||
return ("paint", idx, current_img)
|
||
elif kind == "insert":
|
||
_, idx, prev_current = action
|
||
idx = min(idx, len(self.frames) - 1)
|
||
removed = self.frames.pop(idx)
|
||
if not self.frames:
|
||
self.frames = [Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))]
|
||
self.current = min(prev_current, len(self.frames) - 1)
|
||
self._invalidate_thumb()
|
||
return ("delete", idx, removed, prev_current)
|
||
elif kind == "delete":
|
||
_, idx, img, prev_current = action
|
||
idx = min(idx, len(self.frames))
|
||
self.frames.insert(idx, img)
|
||
self.current = idx
|
||
self._invalidate_thumb()
|
||
return ("insert", idx, prev_current)
|
||
return action
|
||
|
||
def undo(self):
|
||
if not self.undo_stack:
|
||
return
|
||
action = self.undo_stack.pop()
|
||
redo_action = self._apply_inverse(action)
|
||
self.redo_stack.append(redo_action)
|
||
if len(self.redo_stack) > UNDO_LIMIT:
|
||
self.redo_stack.pop(0)
|
||
self._invalidate_onion()
|
||
self.mark_dirty()
|
||
self.render()
|
||
|
||
def redo(self):
|
||
if not self.redo_stack:
|
||
return
|
||
action = self.redo_stack.pop()
|
||
undo_action = self._apply_inverse(action)
|
||
self.undo_stack.append(undo_action)
|
||
self._invalidate_onion()
|
||
self.mark_dirty()
|
||
self.render()
|
||
|
||
# ------------------------------------------------------------ frames ---
|
||
def new_project(self):
|
||
if not self.confirm_discard("crear un proyecto nuevo"):
|
||
return
|
||
self.stop()
|
||
self.frames = [Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))]
|
||
self.background = None
|
||
self._bg_scaled = None
|
||
self.sprites = []
|
||
self.sprite_list.delete(0, "end")
|
||
self.current = 0
|
||
self.current_path = None
|
||
self.undo_stack.clear()
|
||
self.redo_stack.clear()
|
||
self._invalidate_onion()
|
||
self._invalidate_thumb()
|
||
self.dirty = False
|
||
self.render()
|
||
|
||
def new_frame(self):
|
||
idx = self.current + 1
|
||
prev_current = self.current
|
||
self.frames.insert(idx, Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0)))
|
||
self.current = idx
|
||
self.push_action(("insert", idx, prev_current))
|
||
self._invalidate_onion()
|
||
self._invalidate_thumb()
|
||
self.render()
|
||
|
||
def duplicate_frame(self):
|
||
idx = self.current + 1
|
||
prev_current = self.current
|
||
self.frames.insert(idx, self.frames[self.current].copy())
|
||
self.current = idx
|
||
self.push_action(("insert", idx, prev_current))
|
||
self._invalidate_onion()
|
||
self._invalidate_thumb()
|
||
self.render()
|
||
|
||
def delete_frame(self):
|
||
if len(self.frames) <= 1:
|
||
messagebox.showinfo(APP_NAME, "No podés borrar el único frame que queda.")
|
||
return
|
||
idx = self.current
|
||
img = self.frames[idx]
|
||
prev_current = self.current
|
||
del self.frames[idx]
|
||
self.current = min(idx, len(self.frames) - 1)
|
||
self.push_action(("delete", idx, img, prev_current))
|
||
self._invalidate_onion()
|
||
self._invalidate_thumb()
|
||
self.render()
|
||
|
||
def clear_frame(self):
|
||
self._begin_frame_edit()
|
||
self.frames[self.current] = Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))
|
||
self.render()
|
||
|
||
def select_frame(self, i):
|
||
i = max(0, min(i, len(self.frames) - 1))
|
||
if i == self.current:
|
||
return
|
||
self.current = i
|
||
self.render()
|
||
|
||
def timeline_click(self, e):
|
||
cx = self.timeline.canvasx(e.x)
|
||
i = int((cx) // (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()
|