from dataclasses import dataclass import math from pathlib import Path import re import tkinter as tk from tkinter import filedialog, messagebox, ttk from .export_settings_dialog import ExportSettings, ExportSettingsDialog SUPPORTED_EXTENSIONS = {".png", ".gif", ".ppm", ".pgm"} @dataclass class FrameItem: path: Path image: tk.PhotoImage thumbnail: tk.PhotoImage def _natural_sort_key(path: Path): parts = re.split(r"(\d+)", path.name.lower()) return [int(part) if part.isdigit() else part for part in parts] class FlipbookCreatorDialog(tk.Toplevel): def __init__(self, parent: tk.Misc) -> None: super().__init__(parent) self.title("Flipbook Creator") self.geometry("1100x700") self.minsize(900, 580) self.transient(parent) self.frame_items: list[FrameItem] = [] self.folder_path: Path | None = None self.drag_index: int | None = None self.export_settings = ExportSettings() self.rendered_image: tk.PhotoImage | None = None self.preview_image: tk.PhotoImage | None = None self.player_running = False self.player_after_id: str | None = None self.player_frame_index = 0 self.preview_mode_var = tk.StringVar(value="Rendered") self.fps_var = tk.StringVar(value="8") self._build_ui() self.protocol("WM_DELETE_WINDOW", self._on_close) def _build_ui(self) -> None: container = ttk.Frame(self, padding=10) container.pack(fill="both", expand=True) container.columnconfigure(0, weight=1) container.rowconfigure(2, weight=1) container.rowconfigure(3, weight=1) header = ttk.Frame(container) header.grid(row=0, column=0, sticky="ew") header.columnconfigure(1, weight=1) ttk.Button(header, text="Load Folder", command=self._load_folder).grid(row=0, column=0, padx=(0, 8)) self.folder_label = ttk.Label(header, text="No folder loaded") self.folder_label.grid(row=0, column=1, sticky="w") info_frame = ttk.LabelFrame(container, text="Basic Info") info_frame.grid(row=1, column=0, sticky="ew", pady=(10, 8)) info_frame.columnconfigure(0, weight=1) self.info_label = ttk.Label( info_frame, text="Frames: 0 | Max Frame Size: - | Grid: auto | Supported: .png, .gif, .ppm, .pgm", ) self.info_label.grid(row=0, column=0, sticky="w", padx=8, pady=8) middle_panes = tk.PanedWindow(container, orient=tk.HORIZONTAL, sashrelief=tk.RAISED, sashwidth=6) middle_panes.grid(row=2, column=0, sticky="nsew", pady=(0, 8)) order_frame = ttk.LabelFrame(middle_panes, text="Frame Order (Drag to Sort)") order_frame.columnconfigure(0, weight=1) order_frame.rowconfigure(0, weight=1) self.order_listbox = tk.Listbox(order_frame, selectmode=tk.SINGLE, activestyle="none", exportselection=False) self.order_listbox.grid(row=0, column=0, sticky="nsew", padx=(8, 0), pady=8) order_scroll = ttk.Scrollbar(order_frame, orient=tk.VERTICAL, command=self.order_listbox.yview) order_scroll.grid(row=0, column=1, sticky="ns", padx=(0, 8), pady=8) self.order_listbox.configure(yscrollcommand=order_scroll.set) self.order_listbox.bind("", self._on_drag_start) self.order_listbox.bind("", self._on_drag_motion) self.order_listbox.bind("", self._on_drag_end) all_preview_frame = ttk.LabelFrame(middle_panes, text="All Images Preview") all_preview_frame.columnconfigure(0, weight=1) all_preview_frame.rowconfigure(0, weight=1) self.all_preview_canvas = tk.Canvas(all_preview_frame, highlightthickness=0) self.all_preview_canvas.grid(row=0, column=0, sticky="nsew", padx=8, pady=(8, 0)) all_preview_vsb = ttk.Scrollbar(all_preview_frame, orient=tk.VERTICAL, command=self.all_preview_canvas.yview) all_preview_vsb.grid(row=0, column=1, sticky="ns", padx=(0, 8), pady=(8, 0)) all_preview_hsb = ttk.Scrollbar(all_preview_frame, orient=tk.HORIZONTAL, command=self.all_preview_canvas.xview) all_preview_hsb.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 8)) self.all_preview_canvas.configure(yscrollcommand=all_preview_vsb.set, xscrollcommand=all_preview_hsb.set) self.thumbnail_content = ttk.Frame(self.all_preview_canvas) self.thumbnail_window = self.all_preview_canvas.create_window((0, 0), window=self.thumbnail_content, anchor="nw") self.thumbnail_content.bind("", self._on_thumbnail_content_configure) self.all_preview_canvas.bind("", self._on_thumbnail_canvas_configure) middle_panes.add(order_frame, minsize=260, width=340) middle_panes.add(all_preview_frame, minsize=450) output_panes = tk.PanedWindow(container, orient=tk.HORIZONTAL, sashrelief=tk.RAISED, sashwidth=6) output_panes.grid(row=3, column=0, sticky="nsew") preview_frame = ttk.LabelFrame(output_panes, text="Preview Field") preview_frame.columnconfigure(0, weight=1) preview_frame.rowconfigure(1, weight=1) preview_toolbar = ttk.Frame(preview_frame) preview_toolbar.grid(row=0, column=0, sticky="ew", padx=8, pady=(8, 4)) ttk.Label(preview_toolbar, text="Mode").grid(row=0, column=0, padx=(0, 6)) mode_combo = ttk.Combobox( preview_toolbar, state="readonly", values=["Rendered", "Player"], textvariable=self.preview_mode_var, width=12, ) mode_combo.grid(row=0, column=1, padx=(0, 10)) mode_combo.bind("<>", self._on_preview_mode_changed) self.player_controls = ttk.Frame(preview_toolbar) self.player_controls.grid(row=0, column=2) ttk.Label(self.player_controls, text="FPS").grid(row=0, column=0, padx=(0, 4)) ttk.Spinbox(self.player_controls, from_=1, to=120, textvariable=self.fps_var, width=5).grid( row=0, column=1, padx=(0, 8) ) ttk.Button(self.player_controls, text="Play", command=self._start_player).grid(row=0, column=2, padx=(0, 4)) ttk.Button(self.player_controls, text="Stop", command=self._stop_player).grid(row=0, column=3) self.preview_canvas = tk.Canvas(preview_frame, bg="#202020", width=520, height=230, highlightthickness=0) self.preview_canvas.grid(row=1, column=0, sticky="nsew", padx=8, pady=(4, 8)) actions_frame = ttk.LabelFrame(output_panes, text="Actions") actions_frame.columnconfigure(0, weight=1) ttk.Button(actions_frame, text="Export Settings", command=self._open_export_settings).grid( row=0, column=0, sticky="ew", padx=8, pady=(10, 4) ) ttk.Button(actions_frame, text="Render Preview", command=self._render_preview).grid( row=1, column=0, sticky="ew", padx=8, pady=4 ) ttk.Button(actions_frame, text="Export PNG", command=self._export_png).grid( row=2, column=0, sticky="ew", padx=8, pady=(4, 10) ) output_panes.add(preview_frame, minsize=520) output_panes.add(actions_frame, minsize=170, width=210) self._apply_preview_mode() def _load_folder(self) -> None: chosen_folder = filedialog.askdirectory(parent=self, title="Select folder with frames") if not chosen_folder: return folder = Path(chosen_folder) valid_paths = [path for path in sorted(folder.iterdir(), key=_natural_sort_key) if path.suffix.lower() in SUPPORTED_EXTENSIONS] if not valid_paths: messagebox.showerror( "No valid images", "No supported images were found in this folder.\nSupported: .png, .gif, .ppm, .pgm", parent=self, ) return frame_items: list[FrameItem] = [] skipped_files: list[str] = [] for file_path in valid_paths: try: image = tk.PhotoImage(file=str(file_path)) except tk.TclError: skipped_files.append(file_path.name) continue thumbnail = self._build_thumbnail(image, 72) frame_items.append(FrameItem(path=file_path, image=image, thumbnail=thumbnail)) if not frame_items: messagebox.showerror( "Load failed", "Image files were found, but none could be loaded.", parent=self, ) return self.folder_path = folder self.frame_items = frame_items self.player_frame_index = 0 self._stop_player() self._clear_render_state() self.folder_label.config(text=str(folder)) self._refresh_frame_order() self._refresh_thumbnail_preview() self._update_info() if skipped_files: messagebox.showwarning( "Some files skipped", f"{len(skipped_files)} file(s) could not be loaded:\n" + "\n".join(skipped_files[:10]), parent=self, ) def _build_thumbnail(self, image: tk.PhotoImage, max_size: int) -> tk.PhotoImage: width = max(1, image.width()) height = max(1, image.height()) divisor = max(1, math.ceil(max(width / max_size, height / max_size))) return image.subsample(divisor, divisor) def _refresh_frame_order(self, selected_index: int | None = None) -> None: self.order_listbox.delete(0, tk.END) digits = max(2, len(str(len(self.frame_items)))) for idx, item in enumerate(self.frame_items, start=1): self.order_listbox.insert(tk.END, f"{idx:0{digits}d} - {item.path.name}") if selected_index is not None and self.frame_items: bounded_index = max(0, min(selected_index, len(self.frame_items) - 1)) self.order_listbox.selection_clear(0, tk.END) self.order_listbox.selection_set(bounded_index) self.order_listbox.activate(bounded_index) def _refresh_thumbnail_preview(self) -> None: for child in self.thumbnail_content.winfo_children(): child.destroy() digits = max(2, len(str(len(self.frame_items)))) for idx, item in enumerate(self.frame_items): card = ttk.Frame(self.thumbnail_content, padding=4) card.grid(row=idx, column=0, sticky="w") thumb_label = ttk.Label(card, image=item.thumbnail) thumb_label.grid(row=0, column=0, rowspan=2, padx=(0, 8)) thumb_label.image = item.thumbnail ttk.Label(card, text=f"{idx + 1:0{digits}d}. {item.path.name}").grid(row=0, column=1, sticky="w") ttk.Label(card, text=f"{item.image.width()}x{item.image.height()} px").grid(row=1, column=1, sticky="w") def _update_info(self) -> None: if not self.frame_items: self.info_label.config(text="Frames: 0 | Max Frame Size: - | Grid: auto | Supported: .png, .gif, .ppm, .pgm") return max_width = max(item.image.width() for item in self.frame_items) max_height = max(item.image.height() for item in self.frame_items) rows = self.export_settings.rows if self.export_settings.rows > 0 else "auto" columns = self.export_settings.columns if self.export_settings.columns > 0 else "auto" self.info_label.config( text=f"Frames: {len(self.frame_items)} | Max Frame Size: {max_width}x{max_height} | " f"Grid: rows={rows}, columns={columns} | Supported: .png, .gif, .ppm, .pgm" ) def _on_drag_start(self, event) -> None: if not self.frame_items: self.drag_index = None return self.drag_index = self.order_listbox.nearest(event.y) def _on_drag_motion(self, event) -> None: if self.drag_index is None or not self.frame_items: return target_index = self.order_listbox.nearest(event.y) if target_index == self.drag_index or target_index < 0 or target_index >= len(self.frame_items): return item = self.frame_items.pop(self.drag_index) self.frame_items.insert(target_index, item) self.drag_index = target_index self.player_frame_index = target_index self._refresh_frame_order(selected_index=target_index) self._refresh_thumbnail_preview() self._stop_player() self._clear_render_state() def _on_drag_end(self, _event) -> None: self.drag_index = None def _resolve_grid(self, count: int) -> tuple[int, int]: rows = self.export_settings.rows columns = self.export_settings.columns if rows < 0 or columns < 0: raise ValueError("Rows and columns must be zero or positive.") if rows == 0 and columns == 0: if self.export_settings.orientation == "vertical": return count, 1 return 1, count if rows == 0: rows = math.ceil(count / columns) elif columns == 0: columns = math.ceil(count / rows) if rows * columns < count: raise ValueError("Rows and columns are too small for the current number of frames.") return rows, columns def _compose_sprite_sheet(self) -> tk.PhotoImage | None: if not self.frame_items: return None max_width = max(item.image.width() for item in self.frame_items) max_height = max(item.image.height() for item in self.frame_items) count = len(self.frame_items) spacing = self.export_settings.spacing margin = self.export_settings.margin rows, columns = self._resolve_grid(count) sheet_width = (2 * margin) + (columns * max_width) + ((columns - 1) * spacing) sheet_height = (2 * margin) + (rows * max_height) + ((rows - 1) * spacing) composed = tk.PhotoImage(width=sheet_width, height=sheet_height) for idx, item in enumerate(self.frame_items): row = idx // columns column = idx % columns x_pos = margin + (column * (max_width + spacing)) y_pos = margin + (row * (max_height + spacing)) composed.tk.call(str(composed), "copy", str(item.image), "-to", x_pos, y_pos) return composed def _fit_image_to_preview(self, image: tk.PhotoImage) -> tk.PhotoImage: canvas_width = max(1, self.preview_canvas.winfo_width()) canvas_height = max(1, self.preview_canvas.winfo_height()) if canvas_width <= 1: canvas_width = int(self.preview_canvas.cget("width")) if canvas_height <= 1: canvas_height = int(self.preview_canvas.cget("height")) divisor = max( 1, math.ceil(image.width() / canvas_width), math.ceil(image.height() / canvas_height), ) if divisor > 1: return image.subsample(divisor, divisor) return image def _show_image_on_preview(self, image: tk.PhotoImage) -> None: canvas_width = max(1, self.preview_canvas.winfo_width()) canvas_height = max(1, self.preview_canvas.winfo_height()) if canvas_width <= 1: canvas_width = int(self.preview_canvas.cget("width")) if canvas_height <= 1: canvas_height = int(self.preview_canvas.cget("height")) self.preview_image = self._fit_image_to_preview(image) self.preview_canvas.delete("all") self.preview_canvas.create_image(canvas_width // 2, canvas_height // 2, image=self.preview_image, anchor="center") def _render_preview(self) -> None: if not self.frame_items: messagebox.showerror("Cannot render", "Load a folder with frames before rendering.", parent=self) return if self.preview_mode_var.get() == "Player": self._stop_player() self._render_player_frame(self.player_frame_index) return try: composed = self._compose_sprite_sheet() except ValueError as exc: messagebox.showerror("Cannot render", str(exc), parent=self) return if composed is None: messagebox.showerror("Cannot render", "Load a folder with frames before rendering.", parent=self) return self._stop_player() self.rendered_image = composed self._show_image_on_preview(composed) def _render_player_frame(self, index: int) -> None: if not self.frame_items: return frame_count = len(self.frame_items) self.player_frame_index = index % frame_count frame = self.frame_items[self.player_frame_index].image self._show_image_on_preview(frame) def _start_player(self) -> None: if not self.frame_items: messagebox.showerror("Cannot play", "Load a folder with frames before playing.", parent=self) return if self.preview_mode_var.get() != "Player": self.preview_mode_var.set("Player") self._apply_preview_mode() try: fps = int(self.fps_var.get()) except ValueError: messagebox.showerror("Invalid FPS", "FPS must be a whole number.", parent=self) return if fps < 1 or fps > 120: messagebox.showerror("Invalid FPS", "FPS must be between 1 and 120.", parent=self) return self.player_running = True self._schedule_next_player_tick(fps) def _schedule_next_player_tick(self, fps: int) -> None: if not self.player_running: return self._render_player_frame(self.player_frame_index) self.player_frame_index = (self.player_frame_index + 1) % len(self.frame_items) delay_ms = max(1, int(1000 / fps)) self.player_after_id = self.after(delay_ms, self._player_tick) def _player_tick(self) -> None: if not self.player_running: return try: fps = int(self.fps_var.get()) except ValueError: self._stop_player() messagebox.showerror("Invalid FPS", "FPS must be a whole number.", parent=self) return if fps < 1 or fps > 120: self._stop_player() messagebox.showerror("Invalid FPS", "FPS must be between 1 and 120.", parent=self) return self._schedule_next_player_tick(fps) def _stop_player(self) -> None: self.player_running = False if self.player_after_id is not None: self.after_cancel(self.player_after_id) self.player_after_id = None def _on_preview_mode_changed(self, _event) -> None: self._apply_preview_mode() self._clear_preview_canvas() def _apply_preview_mode(self) -> None: if self.preview_mode_var.get() == "Player": self.player_controls.grid(row=0, column=2) else: self.player_controls.grid_remove() self._stop_player() def _open_export_settings(self) -> None: dialog = ExportSettingsDialog(self, self.export_settings) updated_settings = dialog.show() if updated_settings is None: return self.export_settings = updated_settings self._update_info() self._stop_player() self._clear_render_state() def _export_png(self) -> None: if not self.frame_items: messagebox.showerror("Cannot export", "Load frames before exporting.", parent=self) return try: composed = self._compose_sprite_sheet() except ValueError as exc: messagebox.showerror("Cannot export", str(exc), parent=self) return if composed is None: messagebox.showerror("Cannot export", "Load frames before exporting.", parent=self) return start_directory = str(self.folder_path) if self.folder_path else str(Path.home()) output_path = filedialog.asksaveasfilename( parent=self, title="Export flipbook PNG", initialdir=start_directory, initialfile=self.export_settings.output_name, defaultextension=".png", filetypes=[("PNG Image", "*.png")], ) if not output_path: return try: composed.write(output_path, format="png") except tk.TclError as exc: messagebox.showerror("Export failed", f"PNG export failed: {exc}", parent=self) return self.rendered_image = composed messagebox.showinfo("Export complete", f"Saved:\n{output_path}", parent=self) def _clear_preview_canvas(self) -> None: self.preview_image = None self.preview_canvas.delete("all") def _clear_render_state(self) -> None: self.rendered_image = None self._clear_preview_canvas() def _on_thumbnail_content_configure(self, _event) -> None: self.all_preview_canvas.configure(scrollregion=self.all_preview_canvas.bbox("all")) def _on_thumbnail_canvas_configure(self, event) -> None: self.all_preview_canvas.itemconfigure(self.thumbnail_window, width=event.width) def _on_close(self) -> None: self._stop_player() self.destroy()