mirror of
https://github.com/coraxcode/GIFCraft.git
synced 2025-07-21 21:01:08 +02:00
2944 lines
128 KiB
Python
2944 lines
128 KiB
Python
import tkinter as tk
|
|
from tkinter import filedialog, messagebox, simpledialog, ttk, colorchooser
|
|
from tkinter import Menu, Checkbutton, IntVar, Scrollbar, Frame, Canvas
|
|
from PIL import Image, ImageTk, ImageDraw, ImageFont, ImageSequence, ImageEnhance, ImageFilter, ImageColor, ImageOps
|
|
import os
|
|
import random
|
|
import platform
|
|
import threading
|
|
import numpy as np
|
|
import time
|
|
import cv2
|
|
|
|
|
|
class GIFEditor:
|
|
def __init__(self, master):
|
|
"""Initialize the GIF editor with the main window and UI setup."""
|
|
self.master = master
|
|
self.master.title("GIFCraft - GIF Editor")
|
|
self.master.geometry("800x600")
|
|
|
|
# Initial settings
|
|
self.frame_index = 0
|
|
self.frames = []
|
|
self.delays = []
|
|
self.is_playing = False
|
|
self.history = []
|
|
self.redo_stack = []
|
|
self.current_file = None
|
|
self.checkbox_vars = []
|
|
self.check_all = tk.BooleanVar(value=False)
|
|
self.is_preview_mode = False
|
|
self.preview_width = 200
|
|
self.preview_height = 150
|
|
|
|
# Draw mode settings
|
|
self.is_draw_mode = False
|
|
self.brush_color = "#000000"
|
|
self.brush_size = 5
|
|
self.tool = 'brush'
|
|
self.is_drawing = False
|
|
self.last_x = None
|
|
self.last_y = None
|
|
|
|
# Setup UI and bindings
|
|
self.setup_ui()
|
|
self.bind_keyboard_events()
|
|
|
|
def update_title(self):
|
|
"""Update the window title to reflect the current file state."""
|
|
if self.frames:
|
|
title = f"GIFCraft - GIF Editor - {os.path.basename(self.current_file)}" if self.current_file else "GIFCraft - GIF Editor - Unsaved File"
|
|
self.master.title(title)
|
|
else:
|
|
self.master.title("GIFCraft - GIF Editor")
|
|
|
|
def setup_ui(self):
|
|
"""Set up the user interface."""
|
|
self.setup_menu()
|
|
self.setup_frame_list()
|
|
self.setup_control_frame()
|
|
|
|
def setup_menu(self):
|
|
"""Set up the menu bar."""
|
|
self.menu_bar = Menu(self.master)
|
|
self.create_file_menu()
|
|
self.create_edit_menu()
|
|
self.create_frames_menu()
|
|
self.create_effects_menu()
|
|
self.create_animation_menu()
|
|
self.create_help_menu()
|
|
self.master.config(menu=self.menu_bar)
|
|
|
|
def create_file_menu(self):
|
|
"""Create the File menu."""
|
|
file_menu = Menu(self.menu_bar, tearoff=0)
|
|
file_menu.add_command(label="New", command=self.new_file, accelerator="Ctrl+N")
|
|
file_menu.add_command(label="Load GIF/PNG/WebP", command=self.load_file, accelerator="Ctrl+O")
|
|
file_menu.add_separator()
|
|
file_menu.add_command(label="Save", command=self.save, accelerator="Ctrl+S")
|
|
file_menu.add_command(label="Save As High Quality GIF", command=self.save_as_high_quality_gif)
|
|
file_menu.add_command(label="Save As", command=self.save_as, accelerator="Ctrl+Shift+S")
|
|
file_menu.add_separator()
|
|
file_menu.add_command(label="Extract Video Frames", command=self.extract_video_frames)
|
|
file_menu.add_command(label="Extract Frames Gif", command=self.extract_frames_gif)
|
|
file_menu.add_separator()
|
|
file_menu.add_command(label="Exit", command=self.master.quit)
|
|
self.menu_bar.add_cascade(label="File", menu=file_menu)
|
|
|
|
def create_edit_menu(self):
|
|
"""Create the Edit menu."""
|
|
edit_menu = Menu(self.menu_bar, tearoff=0)
|
|
edit_menu.add_command(label="Undo", command=self.undo, accelerator="Ctrl+Z")
|
|
edit_menu.add_command(label="Redo", command=self.redo, accelerator="Ctrl+Y")
|
|
edit_menu.add_separator()
|
|
edit_menu.add_command(label="Copy", command=self.copy_frames, accelerator="Ctrl+C")
|
|
edit_menu.add_command(label="Paste", command=self.paste_frames, accelerator="Ctrl+V")
|
|
edit_menu.add_separator()
|
|
edit_menu.add_command(label="Rotate Selected Frames 180º", command=self.rotate_selected_frames_180)
|
|
edit_menu.add_command(label="Rotate Selected Frames 90º CW", command=self.rotate_selected_frames_90_cw)
|
|
edit_menu.add_command(label="Rotate Selected Frames 90º CCW", command=self.rotate_selected_frames_90_ccw)
|
|
edit_menu.add_command(label="Rotate Selected Frames...", command=self.rotate_selected_frames)
|
|
edit_menu.add_separator()
|
|
edit_menu.add_command(label="Flip Selected Frames Horizontal", command=self.flip_selected_frames_horizontal)
|
|
edit_menu.add_command(label="Flip Selected Frames Vertical", command=self.flip_selected_frames_vertical)
|
|
self.menu_bar.add_cascade(label="Edit", menu=edit_menu)
|
|
|
|
def create_frames_menu(self):
|
|
"""Create the frames menu."""
|
|
frames_menu = Menu(self.menu_bar, tearoff=0)
|
|
frames_menu.add_command(label="Next frame", command=self.next_frame, accelerator="Arrow Right")
|
|
frames_menu.add_command(label="Previous frame", command=self.previous_frame, accelerator="Arrow Left")
|
|
frames_menu.add_separator()
|
|
frames_menu.add_command(label="Go to Beginning", command=self.go_to_beginning, accelerator="Ctrl+Arrow Right")
|
|
frames_menu.add_command(label="Go to end", command=self.go_to_end, accelerator="Ctrl+Arrow Left")
|
|
frames_menu.add_separator()
|
|
frames_menu.add_command(label="Move Frame Up", command=self.move_frame_up, accelerator="Arrow Up")
|
|
frames_menu.add_command(label="Move Frame Down", command=self.move_frame_down, accelerator="Arrow Down")
|
|
frames_menu.add_command(label="Move Selected Frames", command=self.prompt_and_move_selected_frames)
|
|
frames_menu.add_separator()
|
|
frames_menu.add_command(label="Merge Selected Frames", command=self.merge_frames, accelerator="M")
|
|
frames_menu.add_separator()
|
|
frames_menu.add_command(label="Add Image", command=self.add_image, accelerator="Ctrl+I")
|
|
frames_menu.add_command(label="Add Text", command=self.add_text_frame)
|
|
frames_menu.add_command(label="Apply Frame 1", command=self.apply_frame_1_)
|
|
frames_menu.add_command(label="Add Overlay Frame", command=self.apply_overlay_frame)
|
|
frames_menu.add_command(label="Add Empty Frame", command=self.add_empty_frame)
|
|
frames_menu.add_command(label="Delete Frame(s)", command=self.delete_frames, accelerator="Del")
|
|
frames_menu.add_command(label="Delete Unchecked Frame(s)", command=self.delete_unchecked_frames, accelerator="Ctrl+Del")
|
|
frames_menu.add_separator()
|
|
frames_menu.add_command(label="Check/Uncheck All", command=self.toggle_check_all, accelerator="A")
|
|
frames_menu.add_command(label="Check Even or Odd Frames", command=self.mark_even_odd_frames)
|
|
frames_menu.add_command(label="Check Frames Relative to Cursor", command=self.mark_frames_relative_to_cursor)
|
|
frames_menu.add_command(label="Go to Frame", command=self.go_to_frame, accelerator="Ctrl+G")
|
|
frames_menu.add_separator()
|
|
frames_menu.add_command(label="Crop Frames", command=self.crop_frames)
|
|
frames_menu.add_command(label="Resize Frames", command=self.resize_frames_dialog)
|
|
self.menu_bar.add_cascade(label="Frames", menu=frames_menu)
|
|
|
|
def create_effects_menu(self):
|
|
"""Create the Effects menu."""
|
|
effects_menu = Menu(self.menu_bar, tearoff=0)
|
|
effects_menu.add_command(label="Crossfade Effect", command=self.crossfade_effect)
|
|
effects_menu.add_command(label="Reverse Frames", command=self.reverse_frames)
|
|
effects_menu.add_command(label="Desaturate Frames", command=self.desaturate_frames)
|
|
effects_menu.add_command(label="Sharpness Effect", command=self.apply_sharpening_effect)
|
|
effects_menu.add_command(label="Strange Sharpness Effect", command=self.apply_strange_sharpening_effect)
|
|
effects_menu.add_command(label="Posterize Effect", command=self.apply_posterize_effect)
|
|
effects_menu.add_command(label="Halftones Effect", command=self.apply_halftones_effect)
|
|
effects_menu.add_command(label="Vignette Effect", command=self.apply_vignette_effect)
|
|
effects_menu.add_command(label="Ghost Detection Effect", command=self.ghost_detection_effect)
|
|
effects_menu.add_command(label="Anaglyph Effect (3D)", command=self.apply_anaglyph_effect)
|
|
effects_menu.add_command(label="Kinetoscope Effect", command=self.apply_kinetoscope_effect)
|
|
effects_menu.add_command(label="Invert Colors", command=self.invert_colors_of_selected_frames)
|
|
effects_menu.add_command(label="Glitch Effect", command=self.apply_random_glitch_effect)
|
|
effects_menu.add_command(label="Sketch Effect", command=self.apply_sketch_effect)
|
|
effects_menu.add_command(label="Tint", command=self.apply_tint)
|
|
effects_menu.add_command(label="Adjust Brightness and Contrast", command=self.prompt_and_apply_brightness_contrast)
|
|
effects_menu.add_command(label="Adjust Hue, Saturation, and Lightness", command=self.adjust_hsl)
|
|
effects_menu.add_command(label="Zoom Effect", command=self.apply_zoom_effect)
|
|
effects_menu.add_command(label="Zoom Effect Click", command=self.apply_zoom_effect_click)
|
|
effects_menu.add_command(label="Blur Effect", command=self.apply_blur_effect)
|
|
effects_menu.add_command(label="Zoom and Speed Blur Effect", command=self.apply_zoom_and_speed_blur_effect)
|
|
effects_menu.add_command(label="Noise Effect", command=self.apply_noise_effect)
|
|
effects_menu.add_command(label="Pixelate Effect", command=self.apply_pixelate_effect)
|
|
effects_menu.add_command(label="Reduce Transparency", command=self.reduce_transparency_of_checked_frames)
|
|
effects_menu.add_command(label="Slide Transition Effect", command=self.slide_transition_effect)
|
|
self.menu_bar.add_cascade(label="Effects", menu=effects_menu)
|
|
|
|
def create_animation_menu(self):
|
|
"""Create the Animation menu."""
|
|
animation_menu = Menu(self.menu_bar, tearoff=0)
|
|
animation_menu.add_command(label="Play/Stop Animation", command=self.toggle_play_pause, accelerator="Space")
|
|
animation_menu.add_command(label="Change Preview Resolution", command=self.change_preview_resolution)
|
|
animation_menu.add_command(label="Transparent Frames Preview", command=self.toggle_transparent_frames_preview, accelerator="T")
|
|
animation_menu.add_command(label="Draw Mode", command=self.toggle_draw_mode, accelerator="W")
|
|
self.menu_bar.add_cascade(label="Animation", menu=animation_menu)
|
|
|
|
def create_help_menu(self):
|
|
"""Create the Help menu."""
|
|
help_menu = Menu(self.menu_bar, tearoff=0)
|
|
help_menu.add_command(label="About", command=self.show_about)
|
|
self.menu_bar.add_cascade(label="Help", menu=help_menu)
|
|
|
|
def check_any_frame_selected(self):
|
|
"""
|
|
Check if there is any frame with the checkbox marked.
|
|
If not, show a message informing that no checkbox is marked.
|
|
|
|
Returns:
|
|
bool: True if a frame is selected, False otherwise.
|
|
"""
|
|
if any(var.get() for var in self.checkbox_vars):
|
|
return True
|
|
else:
|
|
messagebox.showwarning("No Frame Selected", "No frames are selected. Please select a frame to apply the effect.")
|
|
return False
|
|
|
|
# MENU FILE
|
|
|
|
def new_file(self, event=None):
|
|
"""Create a new file, prompting to save unsaved changes if any."""
|
|
if self.frames:
|
|
response = messagebox.askyesnocancel("Unsaved Changes", "Do you want to save the current file before creating a new one?")
|
|
if response: # Yes
|
|
self.save()
|
|
if self.frames: # If saving was cancelled or failed, do not proceed
|
|
return
|
|
elif response is None: # Cancel
|
|
return
|
|
|
|
# Reset the editor state for a new file
|
|
self.frames = []
|
|
self.delays = []
|
|
self.checkbox_vars = []
|
|
self.current_file = None
|
|
self.frame_index = 0
|
|
self.base_size = None # Clear the base size
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
self.update_title()
|
|
|
|
def load_file(self, event=None):
|
|
"""Load multiple image files (GIF, PNG, WEBP) and add them to the frame list."""
|
|
file_paths = filedialog.askopenfilenames(filetypes=[("Image files", "*.gif *.png *.webp")])
|
|
if not file_paths:
|
|
return
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
try:
|
|
for file_path in file_paths:
|
|
with Image.open(file_path) as img:
|
|
for frame in ImageSequence.Iterator(img):
|
|
if not self.frames:
|
|
self.base_size = frame.size # Store the size of the first frame
|
|
resized_frame = self.resize_to_base_size(frame.copy())
|
|
self.frames.append(resized_frame)
|
|
delay = int(frame.info.get('duration', 100)) # Ensure delay is always an integer
|
|
self.delays.append(delay)
|
|
var = IntVar()
|
|
var.trace_add('write', lambda *args, i=len(self.checkbox_vars): self.set_current_frame(i))
|
|
self.checkbox_vars.append(var)
|
|
self.frame_index = 0
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
self.update_title()
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to load files: {e}")
|
|
|
|
def save(self, event=None):
|
|
"""Save the current frames and delays to a GIF file."""
|
|
if self.current_file:
|
|
self.save_to_file(self.current_file)
|
|
else:
|
|
self.save_as()
|
|
|
|
def save_as(self, event=None):
|
|
"""Save the current frames and delays to a file with the selected format."""
|
|
file_path = filedialog.asksaveasfilename(defaultextension=".gif", filetypes=[("GIF files", "*.gif"), ("PNG files", "*.png"), ("WebP files", "*.webp")])
|
|
if file_path:
|
|
self.save_to_file(file_path)
|
|
self.current_file = file_path
|
|
self.update_title()
|
|
|
|
def save_as_high_quality_gif(self):
|
|
"""Save the current frames and delays to a high-quality GIF file using dithering."""
|
|
file_path = filedialog.asksaveasfilename(defaultextension=".gif", filetypes=[("GIF files", "*.gif")])
|
|
if file_path:
|
|
try:
|
|
images = [frame.convert("RGB").quantize(method=0) for frame in self.frames]
|
|
images[0].save(file_path, save_all=True, append_images=images[1:], duration=self.delays, loop=0, dither=Image.NONE)
|
|
self.current_file = file_path
|
|
self.update_title()
|
|
messagebox.showinfo("Success", "High-quality GIF saved successfully!")
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to save high-quality GIF: {e}")
|
|
|
|
def extract_video_frames(self):
|
|
"""Extract frames from a video file and save them as images with progress tracking and cancel option."""
|
|
file_path = filedialog.askopenfilename(filetypes=[("Video files", "*.mp4 *.avi *.mkv")])
|
|
if not file_path:
|
|
return
|
|
|
|
output_dir = filedialog.askdirectory()
|
|
if not output_dir:
|
|
return
|
|
|
|
extract_all = messagebox.askyesno("Extract All Frames", "Do you want to extract all frames from the video?")
|
|
start_time_seconds, end_time_seconds = None, None
|
|
|
|
if not extract_all:
|
|
start_time_str = simpledialog.askstring("Start Time", "Enter start time (HH:MM:SS):")
|
|
end_time_str = simpledialog.askstring("End Time", "Enter end time (HH:MM:SS):")
|
|
|
|
if not start_time_str or not end_time_str:
|
|
return
|
|
|
|
try:
|
|
start_time_seconds = self.time_str_to_seconds(start_time_str)
|
|
end_time_seconds = self.time_str_to_seconds(end_time_str)
|
|
except ValueError:
|
|
messagebox.showerror("Invalid Time Format", "Please enter a valid time format (HH:MM:SS).")
|
|
return
|
|
|
|
def cancel_extraction():
|
|
nonlocal cancel
|
|
cancel = True
|
|
|
|
def extract_frames():
|
|
nonlocal cancel
|
|
start_time = time.time()
|
|
|
|
try:
|
|
cap = cv2.VideoCapture(file_path)
|
|
if not cap.isOpened():
|
|
messagebox.showerror("Error", "Failed to open video file.")
|
|
progress_window.destroy()
|
|
return
|
|
|
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
|
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
extracted_frames = 0
|
|
|
|
start_frame = int(start_time_seconds * fps) if start_time_seconds is not None else 0
|
|
end_frame = int(end_time_seconds * fps) if end_time_seconds is not None else frame_count
|
|
|
|
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
|
|
|
success, frame = cap.read()
|
|
current_frame = start_frame
|
|
|
|
while success and not cancel and current_frame <= end_frame:
|
|
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
image = Image.fromarray(frame)
|
|
frame_path = os.path.join(output_dir, f"frame_{extracted_frames + 1}.png")
|
|
image.save(frame_path)
|
|
extracted_frames += 1
|
|
|
|
progress_var.set((extracted_frames / (end_frame - start_frame)) * 100)
|
|
progress_window.update_idletasks()
|
|
success, frame = cap.read()
|
|
current_frame += 1
|
|
|
|
cap.release()
|
|
end_time = time.time()
|
|
elapsed_time = end_time - start_time
|
|
|
|
if cancel:
|
|
messagebox.showinfo("Cancelled", "Frame extraction cancelled.")
|
|
else:
|
|
messagebox.showinfo("Success", f"Extracted {extracted_frames} frames in {elapsed_time:.2f} seconds!")
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to extract frames: {e}")
|
|
finally:
|
|
progress_window.destroy()
|
|
|
|
cancel = False
|
|
|
|
progress_window = tk.Toplevel(self.master)
|
|
progress_window.title("Extracting Frames")
|
|
progress_window.geometry("300x100")
|
|
progress_var = tk.DoubleVar()
|
|
progress_bar = ttk.Progressbar(progress_window, variable=progress_var, maximum=100)
|
|
progress_bar.pack(expand=True, fill=tk.BOTH, padx=20, pady=20)
|
|
cancel_button = tk.Button(progress_window, text="Cancel", command=cancel_extraction)
|
|
cancel_button.pack(pady=10)
|
|
|
|
extraction_thread = threading.Thread(target=extract_frames)
|
|
extraction_thread.start()
|
|
|
|
def time_str_to_seconds(self, time_str):
|
|
"""Convert time string in HH:MM:SS format to seconds."""
|
|
h, m, s = map(int, time_str.split(':'))
|
|
return h * 3600 + m * 60 + s
|
|
|
|
def extract_frames_gif(self):
|
|
"""Extract the frames and save them as individual images."""
|
|
if not self.frames:
|
|
messagebox.showerror("Error", "No frames to extract.")
|
|
return
|
|
|
|
folder_path = filedialog.askdirectory()
|
|
if not folder_path:
|
|
return
|
|
|
|
try:
|
|
for i, frame in enumerate(self.frames):
|
|
frame_path = os.path.join(folder_path, f"frame_{i + 1}.png")
|
|
frame.save(frame_path)
|
|
messagebox.showinfo("Success", "Frames extracted successfully!")
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to extract frames: {e}")
|
|
|
|
def save_to_file(self, file_path):
|
|
"""Save the frames and delays to the specified file in the given format."""
|
|
if self.frames:
|
|
try:
|
|
_, ext = os.path.splitext(file_path)
|
|
ext = ext[1:].lower() # Remove the dot and convert to lowercase
|
|
if ext == 'gif':
|
|
self.frames[0].save(file_path, save_all=True, append_images=self.frames[1:], duration=self.delays, loop=0)
|
|
elif ext == 'png':
|
|
self.frames[0].save(file_path, save_all=True, append_images=self.frames[1:], duration=self.delays, loop=0, format='PNG')
|
|
elif ext == 'webp':
|
|
self.frames[0].save(file_path, save_all=True, append_images=self.frames[1:], duration=self.delays, loop=0, format='WEBP')
|
|
else:
|
|
messagebox.showerror("Error", f"Unsupported file format: {ext.upper()}")
|
|
return
|
|
self.current_file = file_path
|
|
self.update_title()
|
|
messagebox.showinfo("Success", f"{ext.upper()} saved successfully!")
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to save {ext.upper()}: {e}")
|
|
|
|
# MENU EDIT
|
|
|
|
def undo(self, event=None):
|
|
"""Undo the last action."""
|
|
if self.history:
|
|
self.redo_stack.append((self.frames.copy(), self.delays.copy(), [var.get() for var in self.checkbox_vars], self.frame_index, self.current_file))
|
|
self.frames, self.delays, checkbox_states, self.frame_index, self.current_file = self.history.pop()
|
|
self.checkbox_vars = [IntVar(value=state) for state in checkbox_states]
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
var.trace_add('write', lambda *args, i=i: self.set_current_frame(i))
|
|
self.base_size = self.frames[0].size if self.frames else None # Reset base size based on the remaining frames
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
self.update_title()
|
|
self.check_all.set(False) # Reset the check_all variable to ensure consistency
|
|
|
|
def redo(self, event=None):
|
|
"""Redo the last undone action."""
|
|
if self.redo_stack:
|
|
self.history.append((self.frames.copy(), self.delays.copy(), [var.get() for var in self.checkbox_vars], self.frame_index, self.current_file))
|
|
self.frames, self.delays, checkbox_states, self.frame_index, self.current_file = self.redo_stack.pop()
|
|
self.checkbox_vars = [IntVar(value=state) for state in checkbox_states]
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
var.trace_add('write', lambda *args, i=i: self.set_current_frame(i))
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
self.update_title()
|
|
self.check_all.set(False) # Reset the check_all variable to ensure consistency
|
|
|
|
def copy_frames(self, event=None):
|
|
"""Copy the selected frames to the clipboard."""
|
|
self.copied_frames = [(self.frames[i].copy(), self.delays[i]) for i in range(len(self.checkbox_vars)) if self.checkbox_vars[i].get() == 1]
|
|
if not self.copied_frames:
|
|
messagebox.showinfo("Info", "No frames selected to copy.")
|
|
else:
|
|
messagebox.showinfo("Info", f"Copied {len(self.copied_frames)} frame(s).")
|
|
|
|
def paste_frames(self, event=None):
|
|
"""Paste the copied frames below the selected frames with all checkboxes checked."""
|
|
if not hasattr(self, 'copied_frames') or not self.copied_frames:
|
|
messagebox.showerror("Error", "No frames to paste. Please copy frames first.")
|
|
return
|
|
|
|
selected_indices = [i for i, var in enumerate(self.checkbox_vars) if var.get() == 1]
|
|
if not selected_indices:
|
|
messagebox.showinfo("Info", "No frames selected to paste after. Pasting at the end.")
|
|
insert_index = len(self.frames)
|
|
else:
|
|
insert_index = max(selected_indices) + 1
|
|
|
|
self.save_state()
|
|
|
|
for frame, delay in self.copied_frames:
|
|
self.frames.insert(insert_index, frame)
|
|
self.delays.insert(insert_index, delay)
|
|
var = IntVar(value=1)
|
|
var.trace_add('write', lambda *args, i=insert_index: self.set_current_frame(i))
|
|
self.checkbox_vars.insert(insert_index, var)
|
|
insert_index += 1
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
# MENU FRAMES
|
|
|
|
def rotate_selected_frames_180(self):
|
|
"""Rotate the selected frames 180 degrees."""
|
|
self.save_state()
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
for i, frame in enumerate(self.frames):
|
|
if self.checkbox_vars[i].get() == 1:
|
|
self.frames[i] = frame.rotate(180)
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def rotate_selected_frames_90_cw(self):
|
|
"""Rotate the selected frames 90 degrees clockwise."""
|
|
self.save_state()
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
for i, frame in enumerate(self.frames):
|
|
if self.checkbox_vars[i].get() == 1:
|
|
self.frames[i] = frame.rotate(-90, expand=True)
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def rotate_selected_frames_90_ccw(self):
|
|
"""Rotate the selected frames 90 degrees counterclockwise."""
|
|
self.save_state()
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
for i, frame in enumerate(self.frames):
|
|
if self.checkbox_vars[i].get() == 1:
|
|
self.frames[i] = frame.rotate(90, expand=True)
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def rotate_selected_frames(self):
|
|
"""Rotate the selected frames by a user-specified number of degrees."""
|
|
self.save_state()
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
try:
|
|
angle = simpledialog.askfloat("Rotate Frames", "Enter the rotation angle in degrees:", parent=self.master)
|
|
if angle is None:
|
|
return
|
|
|
|
self.save_state()
|
|
|
|
for i, frame in enumerate(self.frames):
|
|
if self.checkbox_vars[i].get() == 1:
|
|
self.frames[i] = frame.rotate(angle, expand=True)
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
except ValueError:
|
|
messagebox.showerror("Invalid Input", "Please enter a valid number for the rotation angle.")
|
|
|
|
def flip_selected_frames_horizontal(self):
|
|
"""Flip the selected frames horizontally."""
|
|
self.save_state()
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
for i, frame in enumerate(self.frames):
|
|
if self.checkbox_vars[i].get() == 1:
|
|
self.frames[i] = frame.transpose(Image.FLIP_LEFT_RIGHT)
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def flip_selected_frames_vertical(self):
|
|
"""Flip the selected frames vertically."""
|
|
self.save_state()
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
for i, frame in enumerate(self.frames):
|
|
if self.checkbox_vars[i].get() == 1:
|
|
self.frames[i] = frame.transpose(Image.FLIP_TOP_BOTTOM)
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def next_frame(self, event=None):
|
|
"""Show the next frame without altering the scrollbar position."""
|
|
if self.frame_index < len(self.frames) - 1:
|
|
self.frame_index += 1
|
|
self.show_frame()
|
|
|
|
def previous_frame(self, event=None):
|
|
"""Show the previous frame without altering the scrollbar position."""
|
|
if self.frame_index > 0:
|
|
self.frame_index -= 1
|
|
self.show_frame()
|
|
|
|
def go_to_beginning(self, event=None):
|
|
"""Move the cursor to the beginning of the frame list."""
|
|
if self.frames:
|
|
self.frame_index = 0
|
|
self.show_frame()
|
|
self.focus_current_frame()
|
|
|
|
def go_to_end(self, event=None):
|
|
"""Move the cursor to the end of the frame list."""
|
|
if self.frames:
|
|
self.frame_index = len(self.frames) - 1
|
|
self.show_frame()
|
|
self.focus_current_frame()
|
|
|
|
def move_frame_up(self, event=None):
|
|
"""Move the selected frame up in the list."""
|
|
selected_indices = [i for i, var in enumerate(self.checkbox_vars) if var.get() == 1]
|
|
|
|
if len(selected_indices) != 1:
|
|
messagebox.showwarning("Selection Error", "Please select exactly one frame to move.")
|
|
return
|
|
|
|
selected_index = selected_indices[0]
|
|
|
|
if selected_index == 0:
|
|
messagebox.showinfo("Move Up", "The selected frame is already at the top.")
|
|
return
|
|
|
|
self.save_state()
|
|
|
|
self.frames[selected_index], self.frames[selected_index - 1] = self.frames[selected_index - 1], self.frames[selected_index]
|
|
self.delays[selected_index], self.delays[selected_index - 1] = self.delays[selected_index - 1], self.delays[selected_index]
|
|
|
|
self.checkbox_vars[selected_index].set(0)
|
|
self.checkbox_vars[selected_index - 1].set(1)
|
|
|
|
self.frame_index = selected_index - 1
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def move_frame_down(self, event=None):
|
|
"""Move the selected frame down in the list."""
|
|
selected_indices = [i for i, var in enumerate(self.checkbox_vars) if var.get() == 1]
|
|
|
|
if len(selected_indices) != 1:
|
|
messagebox.showwarning("Selection Error", "Please select exactly one frame to move.")
|
|
return
|
|
|
|
selected_index = selected_indices[0]
|
|
|
|
if selected_index == len(self.frames) - 1:
|
|
messagebox.showinfo("Move Down", "The selected frame is already at the bottom.")
|
|
return
|
|
|
|
self.save_state()
|
|
|
|
self.frames[selected_index], self.frames[selected_index + 1] = self.frames[selected_index + 1], self.frames[selected_index]
|
|
self.delays[selected_index], self.delays[selected_index + 1] = self.delays[selected_index + 1], self.delays[selected_index]
|
|
|
|
self.checkbox_vars[selected_index].set(0)
|
|
self.checkbox_vars[selected_index + 1].set(1)
|
|
|
|
self.frame_index = selected_index + 1
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def prompt_and_move_selected_frames(self):
|
|
"""Prompt the user for the target position and move the selected frames."""
|
|
if not self.frames:
|
|
messagebox.showerror("Error", "No frames available to move.")
|
|
return
|
|
|
|
target_position = simpledialog.askinteger("Move Frames", "Enter the target position (0-based index):",
|
|
minvalue=0, maxvalue=len(self.frames) - 1)
|
|
if target_position is not None:
|
|
self.move_selected_frames(target_position)
|
|
|
|
def move_selected_frames(self, target_position):
|
|
"""
|
|
Move selected frames to a specific target position.
|
|
|
|
Parameters:
|
|
- target_position (int): The position where the selected frames should be moved.
|
|
|
|
This function moves all the frames with checkboxes checked to the specified target position in a safe and consistent manner.
|
|
"""
|
|
if not self.frames:
|
|
messagebox.showerror("Error", "No frames available to move.")
|
|
return
|
|
|
|
if target_position < 0 or target_position >= len(self.frames):
|
|
messagebox.showerror("Error", "Invalid target position.")
|
|
return
|
|
|
|
selected_indices = [i for i, var in enumerate(self.checkbox_vars) if var.get() == 1]
|
|
|
|
if not selected_indices:
|
|
messagebox.showinfo("Info", "No frames selected to move.")
|
|
return
|
|
|
|
self.save_state()
|
|
|
|
selected_frames = [self.frames[i] for i in selected_indices]
|
|
selected_delays = [self.delays[i] for i in selected_indices]
|
|
|
|
for index in reversed(selected_indices):
|
|
del self.frames[index]
|
|
del self.delays[index]
|
|
del self.checkbox_vars[index]
|
|
|
|
for i, (frame, delay) in enumerate(zip(selected_frames, selected_delays)):
|
|
insert_position = target_position + i
|
|
self.frames.insert(insert_position, frame)
|
|
self.delays.insert(insert_position, delay)
|
|
var = IntVar(value=1)
|
|
var.trace_add('write', lambda *args, i=insert_position: self.set_current_frame(i))
|
|
self.checkbox_vars.insert(insert_position, var)
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def merge_frames(self, event=None):
|
|
"""Merge the checked frames from top to bottom respecting transparency."""
|
|
self.save_state()
|
|
|
|
checked_indices = [i for i, var in enumerate(self.checkbox_vars) if var.get() == 1]
|
|
|
|
if not checked_indices:
|
|
messagebox.showinfo("Info", "No frames selected for merging.")
|
|
return
|
|
|
|
base_frame = self.frames[checked_indices[-1]].copy()
|
|
for index in reversed(checked_indices[:-1]):
|
|
frame = self.frames[index]
|
|
base_frame = Image.alpha_composite(base_frame, frame)
|
|
|
|
self.frames[checked_indices[-1]] = base_frame
|
|
for index in reversed(checked_indices[:-1]):
|
|
del self.frames[index]
|
|
del self.delays[index]
|
|
del self.checkbox_vars[index]
|
|
|
|
self.frame_index = checked_indices[-1]
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
messagebox.showinfo("Success", "Frames merged successfully!")
|
|
|
|
def add_image(self, event=None):
|
|
"""Add images to the frames."""
|
|
file_paths = filedialog.askopenfilenames(filetypes=[("Image files", "*.jpg *.jpeg *.png *.webp *.gif *.bmp")])
|
|
if not file_paths:
|
|
return
|
|
|
|
self.save_state()
|
|
try:
|
|
for file_path in file_paths:
|
|
with Image.open(file_path) as image:
|
|
if not self.frames:
|
|
self.base_size = image.size
|
|
image = self.resize_to_base_size(image.copy())
|
|
self.frames.append(image)
|
|
self.delays.append(100)
|
|
var = IntVar()
|
|
var.trace_add('write', lambda *args, i=len(self.checkbox_vars): self.set_current_frame(i))
|
|
self.checkbox_vars.append(var)
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to add images: {e}")
|
|
|
|
def add_text_frame(self):
|
|
"""Create a frame with text using user inputs for font, size, color, outline, and position."""
|
|
if len(self.frames) < 1:
|
|
messagebox.showerror("Error", "There are no frames available to use as a reference.")
|
|
return
|
|
|
|
def get_system_fonts():
|
|
"""Retrieve a list of system fonts available on the user's machine."""
|
|
font_dirs = []
|
|
if platform.system() == "Windows":
|
|
windir = os.environ.get('WINDIR')
|
|
if windir:
|
|
font_dirs = [os.path.join(windir, 'Fonts')]
|
|
elif platform.system() == "Darwin":
|
|
font_dirs = ["/Library/Fonts", "~/Library/Fonts"]
|
|
elif platform.system() == "Linux":
|
|
font_dirs = ["/usr/share/fonts", "~/.local/share/fonts", "~/.fonts"]
|
|
|
|
fonts = set()
|
|
for font_dir in font_dirs:
|
|
font_dir = os.path.expanduser(font_dir)
|
|
if os.path.isdir(font_dir):
|
|
for root, _, files in os.walk(font_dir):
|
|
for file in files:
|
|
if file.lower().endswith(('.ttf', '.otf')):
|
|
fonts.add(os.path.join(root, file))
|
|
return sorted(fonts, key=lambda f: os.path.basename(f).lower())
|
|
|
|
fonts = get_system_fonts()
|
|
font_names = [os.path.basename(f).replace('.ttf', '').replace('.otf', '') for f in fonts]
|
|
default_font = None
|
|
|
|
if platform.system() == 'Windows':
|
|
default_font = 'arial'
|
|
elif platform.system() == 'Darwin':
|
|
default_font = next((f for f in font_names if 'arial' in f.lower()), None)
|
|
elif platform.system() == 'Linux':
|
|
default_font = next((f for f in font_names if 'dejavusans' in f.lower()), None)
|
|
|
|
if not default_font and fonts:
|
|
default_font = font_names[0]
|
|
|
|
if not fonts:
|
|
messagebox.showerror("Error", "No fonts found on the system.")
|
|
return
|
|
|
|
top = tk.Toplevel(self.master)
|
|
top.title("Add Text to Frame")
|
|
|
|
font_preview_label = tk.Label(top, text="Sample Text", font=(default_font, 14))
|
|
font_preview_label.grid(row=0, column=0, columnspan=3, padx=10, pady=10, sticky="n")
|
|
|
|
tk.Label(top, text="Enter text to display:").grid(row=1, column=0, padx=10, pady=5)
|
|
text_entry = tk.Entry(top, width=30)
|
|
text_entry.grid(row=1, column=1, padx=10, pady=5)
|
|
|
|
tk.Label(top, text="Choose Font:").grid(row=2, column=0, padx=10, pady=5)
|
|
font_combobox = ttk.Combobox(top, values=font_names, width=28)
|
|
font_combobox.set(default_font)
|
|
font_combobox.grid(row=2, column=1, padx=10, pady=5)
|
|
|
|
def update_font_preview(event):
|
|
selected_font_name = font_combobox.get()
|
|
selected_font_path = next((f for f in fonts if os.path.basename(f).replace('.ttf', '').replace('.otf', '') == selected_font_name), None)
|
|
if selected_font_path:
|
|
try:
|
|
preview_font = ImageFont.truetype(selected_font_path, 14)
|
|
font_preview_label.config(font=(selected_font_name, 14))
|
|
except IOError:
|
|
font_preview_label.config(font=("Arial", 14))
|
|
else:
|
|
font_preview_label.config(font=("Arial", 14))
|
|
|
|
font_combobox.bind("<<ComboboxSelected>>", update_font_preview)
|
|
|
|
tk.Label(top, text="Enter font size (in pixels):").grid(row=3, column=0, padx=10, pady=5)
|
|
font_size_entry = tk.Entry(top, width=30)
|
|
font_size_entry.grid(row=3, column=1, padx=10, pady=5)
|
|
font_size_entry.insert(0, "20")
|
|
|
|
bold_var = tk.BooleanVar()
|
|
italic_var = tk.BooleanVar()
|
|
tk.Checkbutton(top, text="Bold", variable=bold_var).grid(row=4, column=0, padx=10, pady=5)
|
|
tk.Checkbutton(top, text="Italic", variable=italic_var).grid(row=4, column=1, padx=10, pady=5)
|
|
|
|
tk.Label(top, text="Choose text color:").grid(row=5, column=0, padx=10, pady=5)
|
|
text_color_button = tk.Button(top, text="Select Color")
|
|
text_color_button.grid(row=5, column=1, padx=10, pady=5)
|
|
|
|
text_color = "#FFFFFF"
|
|
|
|
def choose_text_color():
|
|
nonlocal text_color
|
|
color_code = colorchooser.askcolor(title="Choose text color")
|
|
if color_code:
|
|
text_color = color_code[1]
|
|
|
|
text_color_button.config(command=choose_text_color)
|
|
|
|
tk.Label(top, text="Choose outline color:").grid(row=6, column=0, padx=10, pady=5)
|
|
outline_color_button = tk.Button(top, text="Select Color")
|
|
outline_color_button.grid(row=6, column=1, padx=10, pady=5)
|
|
|
|
outline_color = "#000000"
|
|
|
|
def choose_outline_color():
|
|
nonlocal outline_color
|
|
color_code = colorchooser.askcolor(title="Choose outline color")
|
|
if color_code:
|
|
outline_color = color_code[1]
|
|
|
|
outline_color_button.config(command=choose_outline_color)
|
|
|
|
tk.Label(top, text="Enter outline thickness (0 to 5):").grid(row=7, column=0, padx=10, pady=5)
|
|
outline_thickness_entry = tk.Entry(top, width=30)
|
|
outline_thickness_entry.grid(row=7, column=1, padx=10, pady=5)
|
|
|
|
tk.Label(top, text="Choose text position:").grid(row=8, column=0, padx=10, pady=5)
|
|
position_options = ["Top", "Center", "Bottom", "Mouse"]
|
|
position_combobox = ttk.Combobox(top, values=position_options, width=28)
|
|
position_combobox.set("Center")
|
|
position_combobox.grid(row=8, column=1, padx=10, pady=5)
|
|
|
|
tk.Label(top, text="Enter margin (default is 30):").grid(row=9, column=0, padx=10, pady=5)
|
|
margin_entry = tk.Entry(top, width=30)
|
|
margin_entry.grid(row=9, column=1, padx=10, pady=5)
|
|
margin_entry.insert(0, "30")
|
|
|
|
def submit():
|
|
text = text_entry.get()
|
|
font_choice = font_combobox.get()
|
|
font_size = font_size_entry.get()
|
|
outline_thickness = outline_thickness_entry.get()
|
|
position_choice = position_combobox.get().lower()
|
|
margin = margin_entry.get()
|
|
|
|
if not text or not font_choice or not font_size.isdigit() or not outline_thickness.isdigit() or not margin.isdigit():
|
|
messagebox.showerror("Error", "Please fill all fields correctly.")
|
|
return
|
|
|
|
font_path = next((f for f in fonts if os.path.basename(f).replace('.ttf', '').replace('.otf', '') == font_choice), default_font)
|
|
font_size = int(font_size)
|
|
outline_thickness = int(outline_thickness)
|
|
margin = int(margin)
|
|
bold = bold_var.get()
|
|
italic = italic_var.get()
|
|
|
|
if bold and italic:
|
|
font_style = "bolditalic"
|
|
elif bold:
|
|
font_style = "bold"
|
|
elif italic:
|
|
font_style = "italic"
|
|
else:
|
|
font_style = "regular"
|
|
|
|
base_size = self.frames[0].size
|
|
new_frame = Image.new("RGBA", base_size, (0, 0, 0, 0))
|
|
|
|
try:
|
|
font = ImageFont.truetype(font_path, font_size)
|
|
except IOError:
|
|
messagebox.showerror("Error", f"Failed to load font: {font_choice}. Using default font.")
|
|
font = ImageFont.truetype(default_font, font_size)
|
|
|
|
draw = ImageDraw.Draw(new_frame)
|
|
|
|
text_bbox = draw.textbbox((0, 0), text, font=font)
|
|
text_width, text_height = text_bbox[2] - text_bbox[0], text_bbox[3] - text_bbox[1]
|
|
|
|
if position_choice == "top":
|
|
text_position = (max(0, (base_size[0] - text_width) // 2), margin)
|
|
elif position_choice == "center":
|
|
text_position = (max(0, (base_size[0] - text_width) // 2), max(0, (base_size[1] - text_height) // 2))
|
|
elif position_choice == "bottom":
|
|
text_position = (max(0, (base_size[0] - text_width) // 2), base_size[1] - text_height - margin)
|
|
if text_position[1] + text_height > base_size[1] - margin:
|
|
text_position = (text_position[0], base_size[1] - text_height - margin)
|
|
elif position_choice == "mouse":
|
|
ref_frame = self.frames[0].copy()
|
|
ref_image = ImageTk.PhotoImage(ref_frame)
|
|
|
|
mouse_top = tk.Toplevel(self.master)
|
|
mouse_top.title("Click to Position Text")
|
|
canvas = tk.Canvas(mouse_top, width=ref_frame.width, height=ref_frame.height)
|
|
canvas.pack()
|
|
canvas.create_image(0, 0, anchor=tk.NW, image=ref_image)
|
|
|
|
text_position = [0, 0]
|
|
|
|
def on_click(event):
|
|
text_position[0] = min(max(0, event.x - text_width // 2), base_size[0] - text_width)
|
|
text_position[1] = min(max(0, event.y - text_height // 2), base_size[1] - text_height)
|
|
mouse_top.destroy()
|
|
|
|
canvas.bind("<Button-1>", on_click)
|
|
self.master.wait_window(mouse_top)
|
|
|
|
if outline_thickness > 0:
|
|
for dx in range(-outline_thickness, outline_thickness + 1):
|
|
for dy in range(-outline_thickness, outline_thickness + 1):
|
|
if dx != 0 or dy != 0:
|
|
draw.text((text_position[0] + dx, text_position[1] + dy), text, font=font, fill=outline_color)
|
|
draw.text(text_position, text, font=font, fill=text_color)
|
|
|
|
self.frames.insert(0, new_frame)
|
|
self.delays.insert(0, 100)
|
|
var = tk.IntVar()
|
|
var.trace_add('write', lambda *args, i=0: self.set_current_frame(i))
|
|
self.checkbox_vars.insert(0, var)
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
top.destroy()
|
|
|
|
tk.Button(top, text="Add Text", command=submit).grid(row=10, column=0, columnspan=2, pady=10)
|
|
|
|
def apply_frame_1_(self):
|
|
"""Apply the content of Frame 1 to all checked frames, respecting the transparency of Frame 1."""
|
|
if not self.frames:
|
|
messagebox.showerror("Error", "No frames available to apply the effect.")
|
|
return
|
|
|
|
if not self.checkbox_vars[0].get():
|
|
messagebox.showinfo("Info", "Frame 1 is not checked. Please check Frame 1 to use it as the source frame.")
|
|
return
|
|
|
|
frame_1 = self.frames[0].convert("RGBA")
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if i != 0 and var.get() == 1:
|
|
target_frame = self.frames[i].convert("RGBA")
|
|
combined_frame = Image.alpha_composite(target_frame, frame_1)
|
|
self.frames[i] = combined_frame
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def apply_overlay_frame(self):
|
|
"""Apply an overlay frame (watermark or border) to the selected frames with user-defined transparency."""
|
|
overlay_file = filedialog.askopenfilename(filetypes=[("Image files", "*.png *.jpg *.jpeg *.gif")])
|
|
if not overlay_file:
|
|
return
|
|
|
|
intensity = simpledialog.askfloat("Overlay Frame Transparency", "Enter transparency intensity (0.0 to 1.0):", minvalue=0.0, maxvalue=1.0)
|
|
if intensity is None:
|
|
return
|
|
|
|
distort_overlay = messagebox.askyesno("Distort Overlay", "Do you want to distort the overlay image to match the frame size?")
|
|
|
|
self.save_state()
|
|
try:
|
|
overlay_image = Image.open(overlay_file).convert("RGBA")
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to load overlay image: {e}")
|
|
return
|
|
|
|
def apply_transparent_overlay(frame, overlay, intensity, distort):
|
|
frame = frame.convert("RGBA")
|
|
overlay = overlay.copy()
|
|
|
|
if distort:
|
|
overlay = overlay.resize(frame.size, Image.LANCZOS)
|
|
else:
|
|
overlay_width, overlay_height = overlay.size
|
|
frame_width, frame_height = frame.size
|
|
x_offset = (frame_width - overlay_width) // 2
|
|
y_offset = (frame_height - overlay_height) // 2
|
|
new_overlay = Image.new("RGBA", frame.size, (0, 0, 0, 0))
|
|
new_overlay.paste(overlay, (x_offset, y_offset))
|
|
overlay = new_overlay
|
|
|
|
alpha = overlay.split()[3]
|
|
alpha = ImageEnhance.Brightness(alpha).enhance(intensity)
|
|
overlay.putalpha(alpha)
|
|
|
|
return Image.alpha_composite(frame, overlay)
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i].convert("RGBA")
|
|
self.frames[i] = apply_transparent_overlay(frame, overlay_image, intensity, distort_overlay)
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def add_empty_frame(self):
|
|
"""Add an empty frame with an optional background color. If there are no frames, prompt for the size of the new frame."""
|
|
if not self.frames:
|
|
width = simpledialog.askinteger("Frame Size", "Enter frame width:", minvalue=1)
|
|
height = simpledialog.askinteger("Frame Size", "Enter frame height:", minvalue=1)
|
|
if not width or not height:
|
|
messagebox.showerror("Invalid Input", "Width and height must be positive integers.")
|
|
return
|
|
frame_size = (width, height)
|
|
|
|
color_code = simpledialog.askstring("Add Empty Frame", "Enter background color (hex code, e.g., #FFFFFF for white):")
|
|
else:
|
|
frame_size = self.frames[0].size
|
|
|
|
color_code = simpledialog.askstring("Add Empty Frame", "Enter background color (hex code, e.g., #FFFFFF for white):")
|
|
|
|
if color_code and len(color_code) == 7 and color_code[0] == '#':
|
|
try:
|
|
Image.new("RGBA", (1, 1), color_code).verify()
|
|
except ValueError:
|
|
messagebox.showerror("Invalid Color", "The entered color code is invalid. Using transparent background instead.")
|
|
color_code = None
|
|
else:
|
|
color_code = None
|
|
|
|
self.save_state()
|
|
|
|
try:
|
|
new_frame = Image.new("RGBA", frame_size, color_code if color_code else (0, 0, 0, 0))
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to create a new frame: {e}")
|
|
return
|
|
|
|
self.frames.append(new_frame)
|
|
self.delays.append(100)
|
|
var = IntVar()
|
|
var.trace_add('write', lambda *args, i=len(self.checkbox_vars): self.set_current_frame(i))
|
|
self.checkbox_vars.append(var)
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def delete_frames(self, event=None):
|
|
"""Delete the selected frames."""
|
|
if not self.frames:
|
|
messagebox.showerror("Error", "No frames to delete.")
|
|
return
|
|
|
|
self.save_state()
|
|
indices_to_delete = [i for i, var in enumerate(self.checkbox_vars) if var.get() == 1]
|
|
|
|
if not indices_to_delete:
|
|
messagebox.showinfo("Info", "No frames selected for deletion.")
|
|
return
|
|
|
|
for index in reversed(indices_to_delete):
|
|
del self.frames[index]
|
|
del self.delays[index]
|
|
del self.checkbox_vars[index]
|
|
|
|
if self.frame_index >= len(self.frames):
|
|
self.frame_index = max(0, len(self.frames) - 1)
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def delete_unchecked_frames(self, event=None):
|
|
"""Delete all frames that are not checked in the checkbox list."""
|
|
if not self.frames:
|
|
messagebox.showerror("Error", "No frames to delete.")
|
|
return
|
|
|
|
self.save_state()
|
|
|
|
indices_to_keep = [i for i, var in enumerate(self.checkbox_vars) if var.get() == 1]
|
|
|
|
if not indices_to_keep:
|
|
messagebox.showinfo("Info", "No frames are checked to keep.")
|
|
return
|
|
|
|
self.frames = [self.frames[i] for i in indices_to_keep]
|
|
self.delays = [self.delays[i] for i in indices_to_keep]
|
|
self.checkbox_vars = [self.checkbox_vars[i] for i in indices_to_keep]
|
|
|
|
if self.frame_index >= len(self.frames):
|
|
self.frame_index = max(0, len(self.frames) - 1)
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def toggle_check_all(self, event=None):
|
|
"""Toggle all checkboxes in the frame list without scrolling or changing the displayed frame."""
|
|
self.save_state()
|
|
new_state = not self.check_all.get()
|
|
self.check_all.set(new_state)
|
|
|
|
for var in self.checkbox_vars:
|
|
var.trace_remove('write', var.trace_info()[0][1])
|
|
|
|
for var in self.checkbox_vars:
|
|
var.set(1 if new_state else 0)
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
var.trace_add('write', lambda *args, i=i: self.set_current_frame(i))
|
|
|
|
self.update_frame_list()
|
|
|
|
def mark_even_odd_frames(self):
|
|
"""Mark the checkboxes of all even or odd frames based on user input."""
|
|
self.save_state()
|
|
choice = simpledialog.askinteger("Select Frames", "Enter 1 to mark odd frames, 2 to mark even frames:")
|
|
|
|
if choice not in [1, 2]:
|
|
messagebox.showerror("Invalid Input", "Please enter 1 for odd frames or 2 for even frames.")
|
|
return
|
|
|
|
for var in self.checkbox_vars:
|
|
var.set(0)
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if choice == 1 and i % 2 == 0:
|
|
var.set(1)
|
|
elif choice == 2 and i % 2 != 0:
|
|
var.set(1)
|
|
|
|
def mark_frames_relative_to_cursor(self):
|
|
"""Mark all frames that are below or above the cursor in the frame list based on user input."""
|
|
direction = simpledialog.askstring("Mark Frames", "Enter 'up' to mark frames above or 'down' to mark frames below the current frame:")
|
|
|
|
if direction not in ["up", "down"]:
|
|
messagebox.showerror("Invalid Input", "Please enter 'up' or 'down'.")
|
|
return
|
|
|
|
if direction == "up":
|
|
for i in range(self.frame_index + 1):
|
|
self.checkbox_vars[i].set(1)
|
|
elif direction == "down":
|
|
for i in range(self.frame_index, len(self.checkbox_vars)):
|
|
self.checkbox_vars[i].set(1)
|
|
|
|
self.update_frame_list()
|
|
|
|
def go_to_frame(self, event=None):
|
|
"""Prompt the user to enter a frame number and go to that frame."""
|
|
if not self.frames:
|
|
messagebox.showerror("Error", "No frames available.")
|
|
return
|
|
|
|
frame_number = simpledialog.askinteger("Go to Frame", "Enter frame number:", minvalue=1, maxvalue=len(self.frames))
|
|
|
|
if frame_number is not None:
|
|
self.frame_index = frame_number - 1
|
|
self.show_frame()
|
|
self.focus_current_frame()
|
|
|
|
def crop_frames(self):
|
|
"""Crop the selected frames based on user input values for each side."""
|
|
selected_indices = [i for i, var in enumerate(self.checkbox_vars) if var.get() == 1]
|
|
if not selected_indices:
|
|
messagebox.showinfo("Info", "No frames selected for cropping.")
|
|
return
|
|
|
|
try:
|
|
crop_left = int(simpledialog.askstring("Crop", "Enter pixels to crop from the left:", parent=self.master))
|
|
crop_right = int(simpledialog.askstring("Crop", "Enter pixels to crop from the right:", parent=self.master))
|
|
crop_top = int(simpledialog.askstring("Crop", "Enter pixels to crop from the top:", parent=self.master))
|
|
crop_bottom = int(simpledialog.askstring("Crop", "Enter pixels to crop from the bottom:", parent=self.master))
|
|
except (TypeError, ValueError):
|
|
messagebox.showerror("Invalid Input", "Please enter valid integers for cropping values.")
|
|
return
|
|
|
|
if crop_left < 0 or crop_right < 0 or crop_top < 0 or crop_bottom < 0:
|
|
messagebox.showerror("Invalid Input", "Crop values must be non-negative integers.")
|
|
return
|
|
|
|
self.save_state()
|
|
|
|
for index in selected_indices:
|
|
frame = self.frames[index]
|
|
width, height = frame.size
|
|
left = max(0, crop_left)
|
|
top = max(0, crop_top)
|
|
right = width - max(0, crop_right)
|
|
bottom = height - max(0, crop_bottom)
|
|
|
|
if right <= left or bottom <= top:
|
|
messagebox.showerror("Invalid Crop Values", "Cropping values are too large.")
|
|
return
|
|
|
|
cropped_frame = frame.crop((left, top, right, bottom))
|
|
self.frames[index] = cropped_frame
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def resize_frames_dialog(self):
|
|
"""Open a simple dialog to get new size and resize all frames."""
|
|
if not any(var.get() for var in self.checkbox_vars):
|
|
messagebox.showinfo("info", "No frames are selected for resizing.")
|
|
return
|
|
|
|
width = simpledialog.askinteger("Input", "Enter new width:", parent=self.master, minvalue=1)
|
|
height = simpledialog.askinteger("Input", "Enter new height:", parent=self.master, minvalue=1)
|
|
|
|
if width and height:
|
|
self.resize_frames(width, height)
|
|
|
|
def resize_frames(self, new_width, new_height):
|
|
"""Resize all checked frames to the specified width and height."""
|
|
self.save_state()
|
|
for i, frame in enumerate(self.frames):
|
|
if self.checkbox_vars[i].get():
|
|
self.frames[i] = frame.resize((new_width, new_height), Image.LANCZOS)
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
# MENU EFFECTS
|
|
|
|
def crossfade_effect(self):
|
|
"""Apply crossfade effect between checked frames with user-defined transition frames."""
|
|
checked_indices = [i for i, var in enumerate(self.checkbox_vars) if var.get() == 1]
|
|
|
|
if len(checked_indices) < 2:
|
|
messagebox.showinfo("Info", "Need at least two checked frames to apply crossfade effect.")
|
|
return
|
|
|
|
# Ask user for the number of transition frames
|
|
transition_frames_count = simpledialog.askinteger("Crossfade Frames", "Enter the number of transition frames:", parent=self.master)
|
|
if transition_frames_count is None or transition_frames_count < 1:
|
|
messagebox.showerror("Error", "Number of transition frames must be at least 1.")
|
|
return
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
crossfade_frames = []
|
|
crossfade_delays = []
|
|
|
|
def blend_frames(frame1, frame2, alpha):
|
|
"""Blend two frames with given alpha."""
|
|
return Image.blend(frame1, frame2, alpha)
|
|
|
|
for idx in range(len(checked_indices) - 1):
|
|
i = checked_indices[idx]
|
|
j = checked_indices[idx + 1]
|
|
|
|
frame1 = self.frames[i].convert("RGBA")
|
|
frame2 = self.frames[j].convert("RGBA")
|
|
crossfade_frames.append(frame1)
|
|
crossfade_delays.append(self.delays[i])
|
|
|
|
# Generate crossfade frames
|
|
for step in range(1, transition_frames_count + 1):
|
|
alpha = step / float(transition_frames_count + 1)
|
|
blended_frame = blend_frames(frame1, frame2, alpha)
|
|
crossfade_frames.append(blended_frame)
|
|
crossfade_delays.append(self.delays[i] // (transition_frames_count + 1))
|
|
|
|
# Insert crossfade frames and delays at the correct positions
|
|
for idx in range(len(checked_indices) - 1, -1, -1):
|
|
i = checked_indices[idx]
|
|
self.frames.pop(i)
|
|
self.delays.pop(i)
|
|
self.checkbox_vars.pop(i)
|
|
|
|
insert_index = checked_indices[0]
|
|
for frame, delay in zip(crossfade_frames, crossfade_delays):
|
|
self.frames.insert(insert_index, frame)
|
|
self.delays.insert(insert_index, delay)
|
|
var = IntVar(value=1)
|
|
var.trace_add('write', lambda *args, i=insert_index: self.set_current_frame(i))
|
|
self.checkbox_vars.insert(insert_index, var)
|
|
insert_index += 1
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def reverse_frames(self):
|
|
"""Apply reverse effect to the selected frames."""
|
|
self.save_state() # Save the state before making changes
|
|
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
|
|
# Get indices of selected frames
|
|
indices_to_reverse = [i for i, var in enumerate(self.checkbox_vars) if var.get() == 1]
|
|
|
|
if not indices_to_reverse:
|
|
messagebox.showwarning("No Frame Selected", "No frames are selected. Please select a frame to apply the effect.")
|
|
return
|
|
|
|
# Extract the selected frames and their delays
|
|
frames_to_reverse = [self.frames[i] for i in indices_to_reverse]
|
|
delays_to_reverse = [self.delays[i] for i in indices_to_reverse]
|
|
|
|
# Reverse the selected frames and their delays
|
|
frames_to_reverse.reverse()
|
|
delays_to_reverse.reverse()
|
|
|
|
# Replace the selected frames and their delays with the reversed versions
|
|
for idx, i in enumerate(indices_to_reverse):
|
|
self.frames[i] = frames_to_reverse[idx]
|
|
self.delays[i] = delays_to_reverse[idx]
|
|
|
|
self.show_frame()
|
|
self.update_frame_list()
|
|
|
|
def desaturate_frames(self):
|
|
"""Apply desaturation effect to the selected frames."""
|
|
self.save_state() # Save the state before making changes
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i]
|
|
self.frames[i] = frame.convert("L").convert("RGBA") # Convert to grayscale and then back to RGBA
|
|
self.show_frame()
|
|
self.update_frame_list()
|
|
|
|
def apply_sharpening_effect(self):
|
|
"""Apply a sharpening effect to the selected frames with user-defined intensity."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
# Prompt the user for the sharpening intensity
|
|
sharpening_intensity = simpledialog.askfloat(
|
|
"Sharpening Effect",
|
|
"Enter sharpening intensity (e.g., 9.0 for 900%):",
|
|
minvalue=1.0
|
|
)
|
|
|
|
if sharpening_intensity is None:
|
|
return # User canceled the dialog
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i]
|
|
# Apply the sharpening filter with the user-defined intensity
|
|
enhancer = ImageEnhance.Sharpness(frame)
|
|
self.frames[i] = enhancer.enhance(sharpening_intensity)
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
|
|
def apply_strange_sharpening_effect(self):
|
|
"""Apply a specialized sharpening effect to the selected frames for ghost and UFO photo studies."""
|
|
self.save_state() # Save the state before making changes
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i]
|
|
# Convert to grayscale to highlight edges more effectively
|
|
gray_frame = frame.convert("L")
|
|
|
|
# Apply a strong edge enhancement filter
|
|
edge_enhanced = gray_frame.filter(ImageFilter.EDGE_ENHANCE_MORE)
|
|
|
|
# Sharpen the image dramatically
|
|
sharpener = ImageEnhance.Sharpness(edge_enhanced)
|
|
sharpened_frame = sharpener.enhance(10.0) # Increase sharpness significantly
|
|
|
|
# Optionally, enhance contrast to make features stand out more
|
|
contrast_enhancer = ImageEnhance.Contrast(sharpened_frame)
|
|
enhanced_frame = contrast_enhancer.enhance(2.0) # Increase contrast
|
|
|
|
# Convert back to RGBA (if needed)
|
|
self.frames[i] = enhanced_frame.convert("RGBA")
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def apply_posterize_effect(self):
|
|
"""Apply a posterize effect to the selected frames with configurable intensity."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
|
|
# Default intensity value
|
|
default_levels = 4 # Default number of posterization levels
|
|
|
|
# Prompt user for intensity value
|
|
levels = simpledialog.askinteger("Posterize Intensity", "Enter number of levels (2-20):", initialvalue=default_levels, minvalue=2, maxvalue=20)
|
|
if levels is None:
|
|
levels = default_levels
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
def posterize(frame, levels):
|
|
"""Posterize the frame to the specified number of levels."""
|
|
# Convert to grayscale
|
|
frame = frame.convert("RGB")
|
|
quantized = frame.quantize(colors=levels, method=Image.FASTOCTREE)
|
|
return quantized.convert("RGBA")
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i].convert("RGBA")
|
|
frame = posterize(frame, levels)
|
|
self.frames[i] = frame
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def apply_halftones_effect(self):
|
|
"""Apply a halftones effect to the selected frames."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
|
|
# Default intensity values
|
|
default_halftones_intensity = 10
|
|
|
|
# Prompt user for intensity values
|
|
halftones_intensity = simpledialog.askinteger(
|
|
"Halftones Intensity",
|
|
"Enter halftones intensity (1-100):",
|
|
initialvalue=default_halftones_intensity,
|
|
minvalue=1,
|
|
maxvalue=100
|
|
)
|
|
if halftones_intensity is None:
|
|
halftones_intensity = default_halftones_intensity
|
|
|
|
# Prompt user for shape
|
|
shape = simpledialog.askstring(
|
|
"Halftones Shape",
|
|
"Enter halftones shape (dot/square):",
|
|
initialvalue="dot"
|
|
)
|
|
if shape is None or shape.lower() not in ["dot", "square"]:
|
|
shape = "dot"
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
def halftones_effect(frame, intensity, shape):
|
|
"""Convert the frame to a halftones style effect."""
|
|
frame = frame.convert("L") # Convert to grayscale
|
|
width, height = frame.size
|
|
pixels = np.array(frame)
|
|
|
|
# Create a new image for the halftone effect
|
|
halftone_frame = Image.new("L", (width, height), "white")
|
|
draw = ImageDraw.Draw(halftone_frame)
|
|
|
|
dot_size = int(256 / intensity)
|
|
for y in range(0, height, dot_size):
|
|
for x in range(0, width, dot_size):
|
|
# Calculate the average brightness in the dot's area
|
|
region = pixels[y:y + dot_size, x:x + dot_size]
|
|
brightness = np.mean(region)
|
|
|
|
# Map brightness to dot size
|
|
size = dot_size * (1 - brightness / 255.0)
|
|
if shape == "dot":
|
|
draw.ellipse(
|
|
(x, y, x + size, y + size),
|
|
fill="black"
|
|
)
|
|
elif shape == "square":
|
|
draw.rectangle(
|
|
(x, y, x + size, y + size),
|
|
fill="black"
|
|
)
|
|
|
|
return halftone_frame.convert("RGBA")
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i].convert("RGBA")
|
|
frame = halftones_effect(frame, halftones_intensity, shape)
|
|
self.frames[i] = frame
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def apply_vignette_effect(self):
|
|
"""Apply a vignette effect to the selected frames."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
|
|
# Default intensity and color values
|
|
default_vignette_intensity = 50
|
|
default_vignette_color = "#000000"
|
|
default_vignette_shape = "round"
|
|
|
|
# Prompt user for intensity values
|
|
vignette_intensity = simpledialog.askinteger(
|
|
"Vignette Intensity",
|
|
"Enter vignette intensity (1-100):",
|
|
initialvalue=default_vignette_intensity,
|
|
minvalue=1,
|
|
maxvalue=100
|
|
)
|
|
if vignette_intensity is None:
|
|
vignette_intensity = default_vignette_intensity
|
|
|
|
# Prompt user for color
|
|
vignette_color = colorchooser.askcolor(
|
|
title="Choose Vignette Color",
|
|
initialcolor=default_vignette_color
|
|
)[1]
|
|
if vignette_color is None:
|
|
vignette_color = default_vignette_color
|
|
|
|
# Prompt user for shape
|
|
vignette_shape = simpledialog.askstring(
|
|
"Vignette Shape",
|
|
"Enter vignette shape (round/square):",
|
|
initialvalue=default_vignette_shape
|
|
)
|
|
if vignette_shape is None or vignette_shape.lower() not in ["round", "square"]:
|
|
vignette_shape = default_vignette_shape
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
def vignette_effect(frame, intensity, color, shape):
|
|
"""Apply a vignette effect to the frame."""
|
|
width, height = frame.size
|
|
vignette = Image.new("RGBA", (width, height), color + "00")
|
|
draw = ImageDraw.Draw(vignette)
|
|
|
|
if shape == "round":
|
|
max_distance = np.sqrt((width / 2) ** 2 + (height / 2) ** 2)
|
|
for y in range(height):
|
|
for x in range(width):
|
|
distance = np.sqrt((x - width / 2) ** 2 + (y - height / 2) ** 2)
|
|
alpha = int(255 * (distance / max_distance) * (intensity / 100))
|
|
alpha = min(255, alpha)
|
|
r, g, b = ImageColor.getrgb(color)
|
|
vignette.putpixel((x, y), (r, g, b, alpha))
|
|
elif shape == "square":
|
|
for y in range(height):
|
|
for x in range(width):
|
|
distance_x = abs(x - width / 2)
|
|
distance_y = abs(y - height / 2)
|
|
max_distance = max(distance_x, distance_y)
|
|
alpha = int(255 * (max_distance / (max(width, height) / 2)) * (intensity / 100))
|
|
alpha = min(255, alpha)
|
|
r, g, b = ImageColor.getrgb(color)
|
|
vignette.putpixel((x, y), (r, g, b, alpha))
|
|
|
|
return Image.alpha_composite(frame, vignette)
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i].convert("RGBA")
|
|
frame = vignette_effect(frame, vignette_intensity, vignette_color, vignette_shape)
|
|
self.frames[i] = frame
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def ghost_detection_effect(self):
|
|
"""Apply a ghost detection effect to the selected frames."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
|
|
# Function to enhance and apply a ghostly effect
|
|
def apply_ghost_effect(frame):
|
|
# Convert to grayscale
|
|
gray_frame = frame.convert("L")
|
|
|
|
# Enhance contrast using histogram equalization
|
|
equalized_frame = ImageOps.equalize(gray_frame)
|
|
|
|
# Apply Gaussian blur to reduce noise
|
|
blurred_frame = equalized_frame.filter(ImageFilter.GaussianBlur(2))
|
|
|
|
# Use adaptive thresholding to create a binary image
|
|
threshold_frame = blurred_frame.point(lambda p: p > 128 and 255)
|
|
|
|
# Apply edge detection (using Canny edge detector if possible)
|
|
edges = threshold_frame.filter(ImageFilter.FIND_EDGES)
|
|
|
|
# Enhance edges to make them more prominent
|
|
enhancer = ImageEnhance.Contrast(edges)
|
|
enhanced_edges = enhancer.enhance(2.0)
|
|
|
|
# Invert the image to create a ghostly effect
|
|
inverted_image = ImageOps.invert(enhanced_edges)
|
|
|
|
# Convert back to RGBA
|
|
ghost_frame = inverted_image.convert("RGBA")
|
|
|
|
# Blend the original frame with the ghost frame to create a more realistic apparition
|
|
blended_frame = Image.blend(frame.convert("RGBA"), ghost_frame, alpha=0.5)
|
|
|
|
return blended_frame
|
|
|
|
# Apply the ghost effect to selected frames
|
|
self.save_state() # Save the state before making changes
|
|
for i in range(len(self.frames)):
|
|
if self.checkbox_vars[i].get() == 1:
|
|
self.frames[i] = apply_ghost_effect(self.frames[i])
|
|
|
|
self.show_frame()
|
|
self.update_frame_list()
|
|
|
|
def apply_anaglyph_effect(self):
|
|
"""Apply anaglyph (red-blue) effect to the selected frames with user-defined intensities for red and blue channels."""
|
|
self.save_state() # Save the state before making changes
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
|
|
# Ask user for the intensity of the red channel offset
|
|
red_intensity = simpledialog.askinteger(
|
|
"Anaglyph Effect - Red Channel Intensity",
|
|
"Enter the intensity for the red channel (default is 5, recommended range 3-10):",
|
|
initialvalue=5,
|
|
minvalue=1,
|
|
maxvalue=20
|
|
)
|
|
|
|
if red_intensity is None:
|
|
return # User cancelled the dialog
|
|
|
|
# Ask user for the intensity of the blue channel offset
|
|
blue_intensity = simpledialog.askinteger(
|
|
"Anaglyph Effect - Blue Channel Intensity",
|
|
"Enter the intensity for the blue channel (default is 5, recommended range 3-10):",
|
|
initialvalue=5,
|
|
minvalue=1,
|
|
maxvalue=20
|
|
)
|
|
|
|
if blue_intensity is None:
|
|
return # User cancelled the dialog
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i].convert("RGB")
|
|
r, g, b = frame.split()
|
|
|
|
# Offset the red and blue channels
|
|
r = r.transform(r.size, Image.AFFINE, (1, 0, -red_intensity, 0, 1, 0)) # Red channel shifted to the left
|
|
b = b.transform(b.size, Image.AFFINE, (1, 0, blue_intensity, 0, 1, 0)) # Blue channel shifted to the right
|
|
|
|
anaglyph_frame = Image.merge("RGB", (r, g, b)).convert("RGBA")
|
|
self.frames[i] = anaglyph_frame
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def apply_kinetoscope_effect(self):
|
|
"""Apply an old Kinetoscope film effect to the selected frames with configurable intensity."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
# Default intensity values
|
|
default_noise_intensity = 30
|
|
default_scratches_intensity = 10
|
|
default_sepia_intensity = 1.0 # Sepia intensity is a factor, not percentage
|
|
default_jitter_intensity = 5
|
|
default_vertical_lines_intensity = 5 # New intensity for vertical lines
|
|
|
|
# Prompt user for intensity values
|
|
noise_intensity = simpledialog.askinteger(
|
|
"Noise Intensity", "Enter noise intensity (0-100):", initialvalue=default_noise_intensity, minvalue=0, maxvalue=100)
|
|
if noise_intensity is None:
|
|
noise_intensity = default_noise_intensity
|
|
|
|
scratches_intensity = simpledialog.askinteger(
|
|
"Scratches Intensity", "Enter number of scratches (0-100):", initialvalue=default_scratches_intensity, minvalue=0, maxvalue=100)
|
|
if scratches_intensity is None:
|
|
scratches_intensity = default_scratches_intensity
|
|
|
|
sepia_intensity = simpledialog.askfloat(
|
|
"Sepia Intensity", "Enter sepia intensity (0.0-2.0):", initialvalue=default_sepia_intensity, minvalue=0.0, maxvalue=2.0)
|
|
if sepia_intensity is None:
|
|
sepia_intensity = default_sepia_intensity
|
|
|
|
jitter_intensity = simpledialog.askinteger(
|
|
"Jitter Intensity", "Enter jitter intensity (0-20):", initialvalue=default_jitter_intensity, minvalue=0, maxvalue=20)
|
|
if jitter_intensity is None:
|
|
jitter_intensity = default_jitter_intensity
|
|
|
|
vertical_lines_intensity = simpledialog.askinteger(
|
|
"Vertical Lines Intensity", "Enter vertical lines intensity (0-100):", initialvalue=default_vertical_lines_intensity, minvalue=0, maxvalue=100)
|
|
if vertical_lines_intensity is None:
|
|
vertical_lines_intensity = default_vertical_lines_intensity
|
|
|
|
vertical_lines_color = simpledialog.askstring(
|
|
"Vertical Lines Color", "Enter vertical lines color in hexadecimal (e.g., #FFFFFF):", initialvalue="#FFFFFF")
|
|
if vertical_lines_color is None:
|
|
vertical_lines_color = "#FFFFFF"
|
|
|
|
scratches_color = simpledialog.askstring(
|
|
"Scratches Color", "Enter scratches color in hexadecimal (e.g., #FFFFFF):", initialvalue="#FFFFFF")
|
|
if scratches_color is None:
|
|
scratches_color = "#FFFFFF"
|
|
|
|
def add_noise(frame, intensity):
|
|
"""Add noise to the frame."""
|
|
width, height = frame.size
|
|
pixels = frame.load()
|
|
for _ in range(int(width * height * intensity / 100)):
|
|
x = random.randint(0, width - 1)
|
|
y = random.randint(0, height - 1)
|
|
noise = random.randint(-intensity, intensity)
|
|
r, g, b, a = pixels[x, y]
|
|
pixels[x, y] = (max(0, min(255, r + noise)), max(0, min(255, g + noise)), max(0, min(255, b + noise)), a)
|
|
return frame
|
|
|
|
def add_scratches(frame, num_scratches, color):
|
|
"""Add realistic scratches to the frame."""
|
|
draw = ImageDraw.Draw(frame)
|
|
width, height = frame.size
|
|
for _ in range(num_scratches):
|
|
x_start = random.randint(0, width - 1)
|
|
y_start = random.randint(0, height - 1)
|
|
length = random.randint(20, 100) # Length of the scratch
|
|
angle = random.uniform(-0.5, 0.5) # Small angle to simulate vertical scratches
|
|
for i in range(length):
|
|
x = int(x_start + i * angle)
|
|
y = y_start + i
|
|
if 0 <= x < width and 0 <= y < height:
|
|
# Draw a small dot to make it look like a scratch
|
|
draw.line([(x, y), (x, y)], fill=color, width=1)
|
|
return frame
|
|
|
|
def apply_sepia(frame, intensity):
|
|
"""Apply a sepia tone to the frame."""
|
|
width, height = frame.size
|
|
pixels = frame.load()
|
|
for y in range(height):
|
|
for x in range(width):
|
|
r, g, b, a = pixels[x, y]
|
|
|
|
tr = int(0.393 * r + 0.769 * g + 0.189 * b)
|
|
tg = int(0.349 * r + 0.686 * g + 0.168 * b)
|
|
tb = int(0.272 * r + 0.534 * g + 0.131 * b)
|
|
|
|
tr = min(255, int(tr * intensity))
|
|
tg = min(255, int(tg * intensity))
|
|
tb = min(255, int(tb * intensity))
|
|
|
|
pixels[x, y] = (tr, tg, tb, a)
|
|
return frame
|
|
|
|
def jitter_frame(frame, max_jitter):
|
|
"""Jitter the frame slightly to simulate film jitter."""
|
|
width, height = frame.size
|
|
jitter_x = random.randint(-max_jitter, max_jitter)
|
|
jitter_y = random.randint(-max_jitter, max_jitter)
|
|
new_frame = Image.new("RGBA", frame.size, (0, 0, 0, 0))
|
|
new_frame.paste(frame, (jitter_x, jitter_y))
|
|
return new_frame
|
|
|
|
def add_vertical_lines(frame, intensity, color):
|
|
"""Add random vertical lines to the frame to simulate an old film effect."""
|
|
draw = ImageDraw.Draw(frame)
|
|
width, height = frame.size
|
|
num_lines = max(1, int(width * intensity / 100)) # Ensure at least one line
|
|
for _ in range(num_lines):
|
|
x = random.randint(0, width - 1)
|
|
line_thickness = 1 # Thin lines for a more authentic old film effect
|
|
draw.line([(x, 0), (x, height)], fill=color, width=line_thickness)
|
|
return frame
|
|
|
|
# Apply effects to each selected frame
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i].convert("RGBA")
|
|
frame = add_noise(frame, noise_intensity)
|
|
frame = add_scratches(frame, scratches_intensity, scratches_color)
|
|
frame = apply_sepia(frame, sepia_intensity)
|
|
frame = jitter_frame(frame, jitter_intensity)
|
|
frame = add_vertical_lines(frame, vertical_lines_intensity, vertical_lines_color)
|
|
self.frames[i] = frame
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def invert_colors_of_selected_frames(self):
|
|
"""Invert colors of the selected frames."""
|
|
if not any(var.get() for var in self.checkbox_vars):
|
|
messagebox.showinfo("Info", "No frames selected for color inversion.")
|
|
return
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
self.frames[i] = ImageOps.invert(self.frames[i].convert("RGB")).convert("RGBA")
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def apply_tint(self):
|
|
"""Apply a tint effect to the selected frames."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
# Prompt user for hex color code and intensity
|
|
color_code = simpledialog.askstring("Tint Effect", "Enter tint color (hex code, e.g., #FF0000 for red):")
|
|
if not color_code or not (color_code.startswith('#') and len(color_code) == 7):
|
|
messagebox.showerror("Invalid Input", "Please enter a valid hex color code (e.g., #FF0000).")
|
|
return
|
|
|
|
intensity = simpledialog.askinteger("Tint Effect", "Enter intensity (0-100):", minvalue=0, maxvalue=100)
|
|
if intensity is None or not (0 <= intensity <= 100):
|
|
messagebox.showerror("Invalid Input", "Please enter an intensity value between 0 and 100.")
|
|
return
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
# Apply the tint effect to the selected frames
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
self.frames[i] = self.tint_image(self.frames[i], color_code, intensity)
|
|
|
|
self.show_frame()
|
|
self.update_frame_list()
|
|
|
|
def tint_image(self, image, color_code, intensity):
|
|
"""Tint an image with the given color and intensity."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
r, g, b = Image.new("RGB", (1, 1), color_code).getpixel((0, 0))
|
|
intensity /= 100.0
|
|
|
|
# Create a tinted image
|
|
tinted_image = Image.new("RGBA", image.size)
|
|
for x in range(image.width):
|
|
for y in range(image.height):
|
|
pixel = image.getpixel((x, y))
|
|
tr = int(pixel[0] + (r - pixel[0]) * intensity)
|
|
tg = int(pixel[1] + (g - pixel[1]) * intensity)
|
|
tb = int(pixel[2] + (b - pixel[2]) * intensity)
|
|
ta = pixel[3]
|
|
tinted_image.putpixel((x, y), (tr, tg, tb, ta))
|
|
|
|
return tinted_image
|
|
|
|
def apply_random_glitch_effect(self):
|
|
"""Apply a random glitch effect to the selected frames."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
def glitch_frame(frame):
|
|
"""Apply glitch effect to a single frame."""
|
|
width, height = frame.size
|
|
|
|
# Convert frame to RGB
|
|
frame = frame.convert("RGB")
|
|
r, g, b = frame.split()
|
|
|
|
# Randomly offset each color channel (Chromatic Aberration)
|
|
r = r.transform(r.size, Image.AFFINE, (1, 0, random.uniform(-3, 3), 0, 1, random.uniform(-3, 3)))
|
|
g = g.transform(g.size, Image.AFFINE, (1, 0, random.uniform(-3, 3), 0, 1, random.uniform(-3, 3)))
|
|
b = b.transform(b.size, Image.AFFINE, (1, 0, random.uniform(-3, 3), 0, 1, random.uniform(-3, 3)))
|
|
|
|
# Merge channels back
|
|
frame = Image.merge("RGB", (r, g, b))
|
|
|
|
# Add displacement mapping
|
|
displacement = Image.effect_noise((width, height), 100)
|
|
displacement = displacement.filter(ImageFilter.GaussianBlur(1))
|
|
displacement = displacement.point(lambda p: p > 128 and 255)
|
|
frame = Image.composite(frame, frame.filter(ImageFilter.GaussianBlur(5)), displacement)
|
|
|
|
# Convert back to RGBA
|
|
frame = frame.convert("RGBA")
|
|
|
|
# Add random noise
|
|
pixels = frame.load()
|
|
for _ in range(random.randint(1000, 3000)):
|
|
x = random.randint(0, width - 1)
|
|
y = random.randint(0, height - 1)
|
|
noise = random.randint(50, 200) # Gray noise
|
|
pixels[x, y] = (noise, noise, noise, pixels[x, y][3])
|
|
|
|
# Add horizontal gray lines with grain
|
|
for _ in range(random.randint(5, 20)):
|
|
y = random.randint(0, height - 1)
|
|
line_height = random.randint(1, 3)
|
|
gray_value = random.randint(50, 200) # Gray line color
|
|
for line in range(line_height):
|
|
if y + line < height:
|
|
for x in range(width):
|
|
grain = random.randint(-20, 20) # Add grain effect
|
|
alpha = pixels[x, y + line][3]
|
|
gray_with_grain = min(max(gray_value + grain, 0), 255)
|
|
pixels[x, y + line] = (gray_with_grain, gray_with_grain, gray_with_grain, alpha)
|
|
|
|
return frame
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i]
|
|
glitched_frame = glitch_frame(frame.copy())
|
|
self.frames[i] = glitched_frame
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def apply_sketch_effect(self):
|
|
"""Apply a sketch effect to the selected frames."""
|
|
self.save_state() # Save the state before making changes
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i].convert("L") # Convert to grayscale
|
|
inverted_frame = ImageOps.invert(frame) # Invert colors
|
|
blurred_frame = inverted_frame.filter(ImageFilter.GaussianBlur(10)) # Apply Gaussian blur
|
|
sketch_frame = Image.blend(frame, blurred_frame, 0.5).convert("RGBA") # Blend the original and blurred frames
|
|
|
|
# Enhance edges
|
|
edge_enhanced_frame = sketch_frame.filter(ImageFilter.EDGE_ENHANCE_MORE)
|
|
self.frames[i] = edge_enhanced_frame
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def prompt_and_apply_brightness_contrast(self):
|
|
"""Prompt the user for brightness and contrast levels, then apply the changes to selected frames."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
brightness = simpledialog.askfloat("Brightness", "Enter brightness level (e.g., 1.0 for no change):", minvalue=0.0)
|
|
contrast = simpledialog.askfloat("Contrast", "Enter contrast level (e.g., 1.0 for no change):", minvalue=0.0)
|
|
|
|
if brightness is not None and contrast is not None:
|
|
self.apply_brightness_contrast(brightness, contrast)
|
|
|
|
def apply_brightness_contrast(self, brightness=1.0, contrast=1.0):
|
|
"""Apply brightness and contrast adjustments to selected frames.
|
|
|
|
Parameters:
|
|
- brightness (float): Brightness factor, where 1.0 means no change, less than 1.0 darkens the image,
|
|
and greater than 1.0 brightens the image.
|
|
- contrast (float): Contrast factor, where 1.0 means no change, less than 1.0 reduces contrast,
|
|
and greater than 1.0 increases contrast.
|
|
"""
|
|
# Save the state before making changes
|
|
self.save_state()
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i]
|
|
# Apply brightness adjustment
|
|
enhancer = ImageEnhance.Brightness(frame)
|
|
frame = enhancer.enhance(brightness)
|
|
# Apply contrast adjustment
|
|
enhancer = ImageEnhance.Contrast(frame)
|
|
frame = enhancer.enhance(contrast)
|
|
self.frames[i] = frame
|
|
|
|
# Update the frame list and show the current frame
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def adjust_hsl(self):
|
|
"""Prompt the user for Hue, Saturation, and Lightness adjustments and apply them to selected frames."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
# Get user input for HSL adjustments
|
|
hue_shift = simpledialog.askfloat("Adjust Hue", "Enter hue shift (-180 to 180):", minvalue=-180, maxvalue=180)
|
|
if hue_shift is None:
|
|
return
|
|
saturation_factor = simpledialog.askfloat("Adjust Saturation", "Enter saturation factor (0.0 to 2.0):", minvalue=0.0, maxvalue=2.0)
|
|
if saturation_factor is None:
|
|
return
|
|
lightness_factor = simpledialog.askfloat("Adjust Lightness", "Enter lightness factor (0.0 to 2.0):", minvalue=0.0, maxvalue=2.0)
|
|
if lightness_factor is None:
|
|
return
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i].convert("RGB")
|
|
|
|
# Adjust Hue
|
|
hsv_image = frame.convert("HSV")
|
|
hsv_data = list(hsv_image.getdata())
|
|
hsv_data = [(int((h + hue_shift) % 360), s, v) for h, s, v in hsv_data]
|
|
hsv_image.putdata(hsv_data)
|
|
frame = hsv_image.convert("RGB")
|
|
|
|
# Adjust Saturation
|
|
enhancer = ImageEnhance.Color(frame)
|
|
frame = enhancer.enhance(saturation_factor)
|
|
|
|
# Adjust Lightness
|
|
enhancer = ImageEnhance.Brightness(frame)
|
|
frame = enhancer.enhance(lightness_factor)
|
|
|
|
self.frames[i] = frame.convert("RGBA")
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def apply_zoom_effect(self):
|
|
"""Apply a zoom effect to the selected frames."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
# Prompt the user for the zoom intensity
|
|
zoom_factor = simpledialog.askfloat("Zoom Effect", "Enter zoom intensity (e.g., 1.2 for 20% zoom in):", minvalue=0.1)
|
|
if zoom_factor is None:
|
|
return
|
|
|
|
# Save the state before making changes for undo functionality
|
|
self.save_state()
|
|
|
|
# Apply zoom effect to each selected frame
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i]
|
|
width, height = frame.size
|
|
new_width = int(width * zoom_factor)
|
|
new_height = int(height * zoom_factor)
|
|
zoomed_frame = frame.resize((new_width, new_height), Image.LANCZOS)
|
|
|
|
# Center crop the zoomed frame to the original size
|
|
left = (new_width - width) // 2
|
|
top = (new_height - height) // 2
|
|
right = left + width
|
|
bottom = top + height
|
|
self.frames[i] = zoomed_frame.crop((left, top, right, bottom))
|
|
|
|
# Update the frame list and show the current frame
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def apply_zoom_effect_click(self):
|
|
"""Apply a zoom effect to the selected frames."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
zoom_factor = simpledialog.askfloat("Zoom Effect", "Enter zoom factor (e.g., 2 for 200% zoom in, 0.5 for 50% zoom out):", minvalue=0.1)
|
|
if zoom_factor is None:
|
|
return
|
|
|
|
checked_indices = [i for i, var in enumerate(self.checkbox_vars) if var.get() == 1]
|
|
if not checked_indices:
|
|
messagebox.showinfo("Zoom Effect", "No frames selected for zooming.")
|
|
return
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
zoom_applied = False
|
|
|
|
def on_click(event, preview_width, preview_height):
|
|
nonlocal zoom_applied
|
|
"""Zoom into or out of the image at the clicked position."""
|
|
for frame_index in checked_indices:
|
|
frame = self.frames[frame_index]
|
|
width, height = frame.size
|
|
click_x = event.x * (width / preview_width)
|
|
click_y = event.y * (height / preview_height)
|
|
|
|
new_width = int(width * zoom_factor)
|
|
new_height = int(height * zoom_factor)
|
|
zoomed_frame = frame.resize((new_width, new_height), Image.LANCZOS)
|
|
|
|
if zoom_factor > 1:
|
|
left = max(0, min(int(click_x * zoom_factor - width // 2), new_width - width))
|
|
top = max(0, min(int(click_y * zoom_factor - height // 2), new_height - height))
|
|
right = left + width
|
|
bottom = top + height
|
|
self.frames[frame_index] = zoomed_frame.crop((left, top, right, bottom))
|
|
else:
|
|
left = max(0, min(int(click_x - new_width // 2), width - new_width))
|
|
top = max(0, min(int(click_y - new_height // 2), height - new_height))
|
|
right = left + new_width
|
|
bottom = top + new_height
|
|
|
|
# Create a new image with the original size and paste the zoomed-out image onto it
|
|
new_frame = Image.new("RGBA", (width, height))
|
|
new_frame.paste(zoomed_frame, (left, top))
|
|
self.frames[frame_index] = new_frame
|
|
|
|
zoom_applied = True
|
|
zoom_window.destroy()
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
# Create a new window to display the image
|
|
zoom_window = tk.Toplevel(self.master)
|
|
zoom_window.title("Click to Zoom")
|
|
|
|
zoom_canvas = tk.Canvas(zoom_window)
|
|
zoom_canvas.pack()
|
|
|
|
def display_preview(frame):
|
|
"""Display the preview of the frame in the zoom window."""
|
|
preview = self.resize_image(frame, max_width=self.preview_width, max_height=self.preview_height)
|
|
preview_width, preview_height = preview.size
|
|
photo = ImageTk.PhotoImage(preview)
|
|
zoom_canvas.config(width=preview_width, height=preview_height)
|
|
zoom_canvas.create_image(0, 0, anchor=tk.NW, image=photo)
|
|
zoom_canvas.image = photo # Keep a reference to avoid garbage collection
|
|
zoom_canvas.bind("<Button-1>", lambda event: on_click(event, preview_width, preview_height))
|
|
|
|
display_preview(self.frames[checked_indices[0]])
|
|
|
|
def on_close():
|
|
zoom_window.destroy()
|
|
|
|
zoom_window.protocol("WM_DELETE_WINDOW", on_close)
|
|
|
|
zoom_window.mainloop()
|
|
|
|
def apply_blur_effect(self):
|
|
"""Apply blur effect to selected frames with user-defined intensity."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
# Prompt user for blur intensity
|
|
blur_intensity = simpledialog.askinteger("Blur Effect", "Enter blur intensity (e.g., 2 for slight blur):", minvalue=0)
|
|
if blur_intensity is None or blur_intensity < 0:
|
|
messagebox.showerror("Invalid Input", "Please enter a valid positive integer for blur intensity.")
|
|
return
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
# Apply the blur effect to the selected frames
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
self.frames[i] = self.frames[i].filter(ImageFilter.GaussianBlur(blur_intensity))
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def apply_zoom_and_speed_blur_effect(self):
|
|
"""Prompt user to apply a zoom or speed blur effect to selected frames."""
|
|
# Check if there is at least one frame selected
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
# Prompt user for effect type
|
|
effect_type = simpledialog.askstring("Choose Effect", "Enter effect type (zoom or speed):")
|
|
if effect_type is None:
|
|
return # User cancelled
|
|
effect_type = effect_type.strip().lower()
|
|
if effect_type not in ["zoom", "speed"]:
|
|
messagebox.showerror("Invalid Input", "Please enter a valid effect type: 'zoom' or 'speed'.")
|
|
return
|
|
|
|
# Prompt user for intensity
|
|
intensity = simpledialog.askfloat("Effect Intensity", "Enter intensity (e.g., 1.2 for zoom, 5 for speed):", minvalue=0.1)
|
|
if intensity is None:
|
|
return # User cancelled
|
|
|
|
# Handle speed blur specific input
|
|
if effect_type == "speed":
|
|
direction = simpledialog.askstring("Speed Blur Direction", "Enter direction (right, left, top, bottom):")
|
|
if direction is None:
|
|
return # User cancelled
|
|
direction = direction.strip().lower()
|
|
if direction not in ["right", "left", "top", "bottom"]:
|
|
messagebox.showerror("Invalid Input", "Please enter a valid direction: 'right', 'left', 'top', 'bottom'.")
|
|
return
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
# Apply the chosen effect to the selected frames
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
if effect_type == "zoom":
|
|
self.frames[i] = self.apply_zoom_blur(self.frames[i], intensity)
|
|
elif effect_type == "speed":
|
|
self.frames[i] = self.apply_speed_blur(self.frames[i], intensity, direction)
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def apply_zoom_blur(self, frame, intensity):
|
|
"""Apply a zoom blur effect to a frame."""
|
|
width, height = frame.size
|
|
|
|
zoomed_frame = frame.copy()
|
|
for i in range(1, int(intensity * 10) + 1):
|
|
zoom_factor = 1 + i * 0.01
|
|
layer = frame.resize((int(width * zoom_factor), int(height * zoom_factor)), Image.LANCZOS)
|
|
layer = layer.crop((
|
|
(layer.width - width) // 2,
|
|
(layer.height - height) // 2,
|
|
(layer.width + width) // 2,
|
|
(layer.height + height) // 2
|
|
))
|
|
zoomed_frame = Image.blend(zoomed_frame, layer, alpha=0.05)
|
|
|
|
return zoomed_frame
|
|
|
|
def apply_speed_blur(self, frame, intensity, direction):
|
|
"""Apply a speed blur effect to a frame in the specified direction."""
|
|
width, height = frame.size
|
|
|
|
speed_blur_frame = frame.copy()
|
|
for i in range(1, int(intensity * 10) + 1):
|
|
offset = int(i * 2)
|
|
if direction == "right":
|
|
matrix = (1, 0, -offset, 0, 1, 0)
|
|
elif direction == "left":
|
|
matrix = (1, 0, offset, 0, 1, 0)
|
|
elif direction == "top":
|
|
matrix = (1, 0, 0, 0, 1, offset)
|
|
elif direction == "bottom":
|
|
matrix = (1, 0, 0, 0, 1, -offset)
|
|
layer = frame.transform(
|
|
frame.size,
|
|
Image.AFFINE,
|
|
matrix,
|
|
resample=Image.BICUBIC
|
|
)
|
|
speed_blur_frame = Image.blend(speed_blur_frame, layer, alpha=0.05)
|
|
|
|
return speed_blur_frame
|
|
|
|
def apply_noise_effect(self):
|
|
"""Apply a noise effect to the selected frames based on user-defined intensity."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
# Prompt the user for the noise intensity
|
|
intensity = simpledialog.askinteger("Noise Effect", "Enter noise intensity (e.g., 10 for slight noise, 100 for heavy noise):", minvalue=1)
|
|
if intensity is None or intensity < 1:
|
|
messagebox.showerror("Invalid Input", "Please enter a valid positive integer for noise intensity.")
|
|
return
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
def add_noise(image, intensity):
|
|
"""Add noise to an image."""
|
|
width, height = image.size
|
|
pixels = image.load()
|
|
|
|
for _ in range(width * height * intensity // 100):
|
|
x = random.randint(0, width - 1)
|
|
y = random.randint(0, height - 1)
|
|
r, g, b, a = pixels[x, y]
|
|
noise = random.randint(-intensity, intensity)
|
|
pixels[x, y] = (
|
|
max(0, min(255, r + noise)),
|
|
max(0, min(255, g + noise)),
|
|
max(0, min(255, b + noise)),
|
|
a
|
|
)
|
|
|
|
return image
|
|
|
|
# Apply the noise effect to the selected frames
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i].convert("RGBA")
|
|
self.frames[i] = add_noise(frame, intensity)
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def apply_pixelate_effect(self):
|
|
"""Apply pixelate effect to selected frames with user-defined intensity."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
# Prompt user for pixelation intensity
|
|
pixel_size = simpledialog.askinteger("Pixelate Effect", "Enter pixel size (e.g., 10 for blocky effect):", minvalue=1)
|
|
if pixel_size is None or pixel_size < 1:
|
|
messagebox.showerror("Invalid Input", "Please enter a valid positive integer for pixel size.")
|
|
return
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
# Apply the pixelate effect to the selected frames
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i]
|
|
width, height = frame.size
|
|
# Resize down to pixel size and back up to original size
|
|
small_frame = frame.resize((width // pixel_size, height // pixel_size), Image.NEAREST)
|
|
pixelated_frame = small_frame.resize(frame.size, Image.NEAREST)
|
|
self.frames[i] = pixelated_frame
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def reduce_transparency_of_checked_frames(self):
|
|
"""Reduce the transparency of the checked frames based on user-defined intensity."""
|
|
if not self.check_any_frame_selected():
|
|
return
|
|
# Prompt the user for the transparency reduction intensity
|
|
intensity = simpledialog.askfloat("Transparency Reduction", "Enter intensity (0 to 1):", minvalue=0.0, maxvalue=1.0)
|
|
|
|
if intensity is None:
|
|
return # User canceled the dialog
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
# Apply the transparency reduction to the checked frames
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
frame = self.frames[i].convert("RGBA")
|
|
# Adjust the alpha channel based on the intensity
|
|
alpha = frame.split()[3]
|
|
alpha = ImageEnhance.Brightness(alpha).enhance(intensity)
|
|
frame.putalpha(alpha)
|
|
self.frames[i] = frame
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
def slide_transition_effect(self):
|
|
"""Apply a slide transition effect to the selected frames based on user input for direction and speed."""
|
|
# Check if there are frames with a checked box
|
|
checked_indices = [i for i, var in enumerate(self.checkbox_vars) if var.get() == 1]
|
|
if len(checked_indices) < 2:
|
|
messagebox.showinfo("Info", "Need at least two checked frames to apply slide transition effect.")
|
|
return
|
|
|
|
# Prompt the user for the direction of the slide
|
|
direction = simpledialog.askstring("Slide Transition Effect", "Enter direction (right, top, left, bottom):")
|
|
if direction is None:
|
|
return # User cancelled
|
|
direction = direction.strip().lower()
|
|
if direction not in ["right", "top", "left", "bottom"]:
|
|
messagebox.showerror("Invalid Input", "Please enter a valid direction: right, top, left, bottom.")
|
|
return
|
|
|
|
# Prompt the user for the speed (intensity) of the slide effect
|
|
speed = simpledialog.askinteger("Slide Transition Effect", "Enter speed (number of transition frames, e.g., 10):", minvalue=1)
|
|
if speed is None or speed < 1:
|
|
messagebox.showerror("Invalid Input", "Please enter a valid positive integer for speed.")
|
|
return
|
|
|
|
self.save_state() # Save the state before making changes
|
|
|
|
slide_frames = []
|
|
slide_delays = []
|
|
|
|
def generate_slide_frames(frame1, frame2, direction, speed):
|
|
"""Generate slide frames transitioning from frame1 to frame2."""
|
|
frames = []
|
|
width, height = frame1.size
|
|
|
|
for step in range(speed):
|
|
new_frame = Image.new("RGBA", (width, height))
|
|
offset = int((step + 1) * (width if direction in ["left", "right"] else height) / speed)
|
|
if direction == "right":
|
|
new_frame.paste(frame2, (-offset, 0))
|
|
new_frame.paste(frame1, (width - offset, 0))
|
|
elif direction == "left":
|
|
new_frame.paste(frame2, (offset, 0))
|
|
new_frame.paste(frame1, (-width + offset, 0))
|
|
elif direction == "top":
|
|
new_frame.paste(frame2, (0, offset))
|
|
new_frame.paste(frame1, (0, -height + offset))
|
|
elif direction == "bottom":
|
|
new_frame.paste(frame2, (0, -offset))
|
|
new_frame.paste(frame1, (0, height - offset))
|
|
frames.append(new_frame)
|
|
|
|
return frames
|
|
|
|
for idx in range(len(checked_indices) - 1):
|
|
i = checked_indices[idx]
|
|
j = checked_indices[idx + 1]
|
|
|
|
frame1 = self.frames[i].convert("RGBA")
|
|
frame2 = self.frames[j].convert("RGBA")
|
|
slide_frames.append(frame1)
|
|
slide_delays.append(self.delays[i])
|
|
|
|
generated_frames = generate_slide_frames(frame1, frame2, direction, speed)
|
|
slide_frames.extend(generated_frames)
|
|
slide_delays.extend([self.delays[i] // speed] * speed)
|
|
|
|
slide_frames.append(self.frames[checked_indices[-1]])
|
|
slide_delays.append(self.delays[checked_indices[-1]])
|
|
|
|
# Remove checked frames in reverse order to maintain correct indices
|
|
for idx in reversed(checked_indices):
|
|
self.frames.pop(idx)
|
|
self.delays.pop(idx)
|
|
self.checkbox_vars.pop(idx)
|
|
|
|
# Insert the slide frames in the correct order
|
|
insert_index = checked_indices[0]
|
|
for frame, delay in zip(slide_frames, slide_delays):
|
|
self.frames.insert(insert_index, frame)
|
|
self.delays.insert(insert_index, delay)
|
|
var = IntVar(value=1)
|
|
var.trace_add('write', lambda *args, i=insert_index: self.set_current_frame(i))
|
|
self.checkbox_vars.insert(insert_index, var)
|
|
insert_index += 1
|
|
|
|
self.update_frame_list()
|
|
self.show_frame()
|
|
|
|
# MENU ANIMATION
|
|
|
|
def toggle_play_pause(self, event=None):
|
|
"""Toggle play/pause for the animation."""
|
|
if self.is_playing:
|
|
self.stop_animation()
|
|
else:
|
|
self.play_animation()
|
|
|
|
def play_animation(self):
|
|
"""Play the GIF animation."""
|
|
self.is_playing = True
|
|
self.play_button.config(text="Stop")
|
|
self.play_next_frame()
|
|
|
|
def stop_animation(self):
|
|
"""Stop the GIF animation."""
|
|
self.is_playing = False
|
|
self.play_button.config(text="Play")
|
|
|
|
def change_preview_resolution(self):
|
|
"""Change the preview resolution based on user input."""
|
|
MAX_WIDTH = 2560
|
|
MAX_HEIGHT = 1600
|
|
|
|
resolution = simpledialog.askstring("Change Preview Resolution", "Enter new resolution (e.g., 800x600):")
|
|
if resolution:
|
|
try:
|
|
width, height = map(int, resolution.split('x'))
|
|
if width > 0 and height > 0:
|
|
if width <= MAX_WIDTH and height <= MAX_HEIGHT:
|
|
self.preview_width = width
|
|
self.preview_height = height
|
|
self.show_frame()
|
|
else:
|
|
messagebox.showerror("Invalid Resolution", f"Resolution exceeds the maximum allowed size of {MAX_WIDTH}x{MAX_HEIGHT}.")
|
|
else:
|
|
messagebox.showerror("Invalid Resolution", "Width and height must be positive integers.")
|
|
except ValueError:
|
|
messagebox.showerror("Invalid Format", "Please enter the resolution in the format '800x600'.")
|
|
|
|
def toggle_transparent_frames_preview(self, event=None):
|
|
"""Toggle transparent preview for frames with checked checkboxes."""
|
|
if self.is_preview_mode:
|
|
self.exit_preview_mode()
|
|
else:
|
|
self.enter_preview_mode()
|
|
|
|
def enter_preview_mode(self):
|
|
"""Enter the transparent preview mode."""
|
|
self.is_preview_mode = True
|
|
|
|
if not self.frames:
|
|
messagebox.showinfo("Preview Mode", "No frames available for preview.")
|
|
self.is_preview_mode = False
|
|
return
|
|
|
|
checked_indices = [i for i, var in enumerate(self.checkbox_vars) if var.get() == 1]
|
|
|
|
if not checked_indices:
|
|
messagebox.showinfo("Preview Mode", "No frames are checked for preview.")
|
|
self.is_preview_mode = False
|
|
return
|
|
|
|
if checked_indices[0] >= len(self.frames):
|
|
messagebox.showerror("Error", "Checked frame index is out of range.")
|
|
self.is_preview_mode = False
|
|
return
|
|
|
|
composite_frame = Image.new("RGBA", self.frames[0].size, (255, 255, 255, 0))
|
|
transparency_factor = 0.5
|
|
|
|
for idx, i in enumerate(checked_indices):
|
|
if i >= len(self.frames):
|
|
continue
|
|
|
|
frame = self.frames[i].copy()
|
|
if idx != 0:
|
|
frame = frame.convert("L").convert("RGBA")
|
|
alpha = frame.split()[3]
|
|
alpha = ImageEnhance.Brightness(alpha).enhance(transparency_factor)
|
|
frame.putalpha(alpha)
|
|
composite_frame = Image.alpha_composite(composite_frame, frame)
|
|
|
|
draw = ImageDraw.Draw(composite_frame)
|
|
font_size = 20
|
|
try:
|
|
font = ImageFont.truetype("arial.ttf", font_size)
|
|
except IOError:
|
|
font = ImageFont.load_default()
|
|
text = "T"
|
|
text_position = (composite_frame.width - font_size - 20, 10)
|
|
text_color = (255, 0, 0, 255)
|
|
draw.text(text_position, text, font=font, fill=text_color)
|
|
|
|
preview = self.resize_image(composite_frame, max_width=self.preview_width, max_height=self.preview_height)
|
|
photo = ImageTk.PhotoImage(preview)
|
|
self.image_label.config(image=photo)
|
|
self.image_label.image = photo
|
|
|
|
self.master.bind_all("<Key>", self.exit_preview_mode)
|
|
|
|
def exit_preview_mode(self, event=None):
|
|
"""Exit the preview mode and return to normal mode."""
|
|
if self.is_preview_mode:
|
|
self.is_preview_mode = False
|
|
self.show_frame()
|
|
self.master.unbind_all("<Key>")
|
|
|
|
def play_next_frame(self):
|
|
"""Play the next frame in the animation."""
|
|
if self.is_playing and self.frames:
|
|
self.show_frame()
|
|
delay = self.delays[self.frame_index]
|
|
self.frame_index = (self.frame_index + 1) % len(self.frames)
|
|
self.master.after(delay, self.play_next_frame)
|
|
|
|
def toggle_draw_mode(self, event=None):
|
|
"""Toggle draw mode on and off."""
|
|
self.save_state()
|
|
|
|
if not any(var.get() for var in self.checkbox_vars):
|
|
messagebox.showwarning("Draw Mode", "No frame is selected for drawing.")
|
|
self.is_draw_mode = False
|
|
return
|
|
|
|
self.is_draw_mode = not self.is_draw_mode
|
|
|
|
if self.is_draw_mode:
|
|
if not self.checkbox_vars[self.frame_index].get():
|
|
messagebox.showwarning("Draw Mode", "Current frame must be selected for drawing.")
|
|
self.is_draw_mode = False
|
|
return
|
|
|
|
self.bind_drawing_events()
|
|
self.show_frame_with_overlay()
|
|
messagebox.showinfo("Draw Mode", "Entered Draw Mode")
|
|
else:
|
|
self.unbind_drawing_events()
|
|
self.show_frame()
|
|
messagebox.showinfo("Draw Mode", "Exited Draw Mode")
|
|
|
|
def bind_drawing_events(self):
|
|
"""Bind events for drawing mode."""
|
|
self.master.bind("<Motion>", self.draw)
|
|
self.master.bind("<Button-1>", self.start_drawing)
|
|
self.master.bind("<ButtonRelease-1>", self.stop_drawing)
|
|
self.master.bind("<Key-1>", self.set_tool_brush)
|
|
self.master.bind("<Key-2>", self.set_tool_eraser)
|
|
self.master.bind("<Key-3>", self.set_tool_color)
|
|
self.master.bind("<Key-4>", self.prompt_brush_size)
|
|
self.master.bind("<bracketleft>", self.decrease_brush_size)
|
|
self.master.bind("<bracketright>", self.increase_brush_size)
|
|
|
|
def unbind_drawing_events(self):
|
|
"""Unbind events for drawing mode."""
|
|
self.master.unbind("<Motion>")
|
|
self.master.unbind("<Button-1>")
|
|
self.master.unbind("<ButtonRelease-1>")
|
|
self.master.unbind("<Key-1>")
|
|
self.master.unbind("<Key-2>")
|
|
self.master.unbind("<Key-3>")
|
|
self.master.unbind("<Key-4>")
|
|
self.master.unbind("<bracketleft>")
|
|
self.master.unbind("<bracketright>")
|
|
|
|
def show_frame_with_overlay(self):
|
|
"""Display the current frame with 'D' overlay in the upper right corner."""
|
|
if self.frames:
|
|
if self.frame_index >= len(self.frames):
|
|
self.frame_index = len(self.frames) - 1
|
|
frame = self.frames[self.frame_index]
|
|
preview = self.resize_image(frame, max_width=self.preview_width, max_height=self.preview_height)
|
|
|
|
draw = ImageDraw.Draw(preview)
|
|
text_position = (preview.width - 20, 10)
|
|
text_color = (255, 0, 0, 255)
|
|
draw.text(text_position, "D", fill=text_color)
|
|
|
|
photo = ImageTk.PhotoImage(preview)
|
|
self.image_label.config(image=photo)
|
|
self.image_label.image = photo
|
|
self.image_label.config(text='')
|
|
self.delay_entry.delete(0, tk.END)
|
|
self.delay_entry.insert(0, str(self.delays[self.frame_index]))
|
|
self.dimension_label.config(text=f"Size: {frame.width}x{frame.height}")
|
|
total_duration = sum(self.delays)
|
|
self.total_duration_label.config(text=f"Total Duration: {total_duration} ms")
|
|
else:
|
|
self.image_label.config(image='', text="No frames to display")
|
|
self.image_label.image = None
|
|
self.delay_entry.delete(0, tk.END)
|
|
self.dimension_label.config(text="")
|
|
self.total_duration_label.config(text="")
|
|
self.update_frame_list()
|
|
|
|
def set_tool_brush(self, event=None):
|
|
"""Set the drawing tool to brush and display an infobox."""
|
|
self.set_tool('brush')
|
|
messagebox.showinfo("Tool Selected", "Selected Tool: Brush")
|
|
|
|
def set_tool_eraser(self, event=None):
|
|
"""Set the drawing tool to eraser and display an infobox."""
|
|
self.set_tool('eraser')
|
|
messagebox.showinfo("Tool Selected", "Selected Tool: Eraser")
|
|
|
|
def set_tool_color(self, event=None):
|
|
"""Open color chooser, set brush color, and display an infobox."""
|
|
self.choose_color()
|
|
messagebox.showinfo("Tool Selected", "Selected Tool: Color")
|
|
|
|
def prompt_brush_size(self, event=None):
|
|
"""Prompt the user to enter the brush size."""
|
|
size = simpledialog.askinteger("Brush Size", "Enter the brush size:", initialvalue=self.brush_size, minvalue=1)
|
|
if size:
|
|
self.brush_size = size
|
|
messagebox.showinfo("Brush Size", f"Brush size set to: {self.brush_size}")
|
|
|
|
def decrease_brush_size(self, event=None):
|
|
"""Decrease the brush size and display the new size in an infobox."""
|
|
self.change_brush_size(-1)
|
|
messagebox.showinfo("Brush Size", f"Brush size changed to: {self.brush_size}")
|
|
|
|
def increase_brush_size(self, event=None):
|
|
"""Increase the brush size and display the new size in an infobox."""
|
|
self.change_brush_size(1)
|
|
messagebox.showinfo("Brush Size", f"Brush size changed to: {self.brush_size}")
|
|
|
|
def set_tool(self, tool):
|
|
"""Set the current drawing tool."""
|
|
self.tool = tool
|
|
|
|
def choose_color(self):
|
|
"""Open a color chooser dialog to select the brush color."""
|
|
color = colorchooser.askcolor()[1]
|
|
if color:
|
|
self.brush_color = color
|
|
|
|
def change_brush_size(self, delta):
|
|
"""Change the brush size."""
|
|
new_size = self.brush_size + delta
|
|
if new_size > 0:
|
|
self.brush_size = new_size
|
|
|
|
def start_drawing(self, event):
|
|
"""Start drawing on the canvas."""
|
|
self.is_drawing = True
|
|
self.last_x, self.last_y = self.scale_coordinates(event.x, event.y)
|
|
|
|
def stop_drawing(self, event):
|
|
"""Stop drawing on the canvas."""
|
|
self.is_drawing = False
|
|
|
|
def draw(self, event):
|
|
"""Draw on the canvas."""
|
|
if self.is_draw_mode and self.is_drawing:
|
|
x, y = self.scale_coordinates(event.x, event.y)
|
|
if self.checkbox_vars[self.frame_index].get() == 1:
|
|
frame = self.frames[self.frame_index].copy()
|
|
draw = ImageDraw.Draw(frame)
|
|
if self.tool == 'brush':
|
|
self.draw_brush(draw, self.last_x, self.last_y, x, y)
|
|
elif self.tool == 'eraser':
|
|
draw.line([self.last_x, self.last_y, x, y], fill=(255, 255, 255, 0), width=self.brush_size)
|
|
self.frames[self.frame_index] = frame
|
|
self.last_x, self.last_y = x, y
|
|
self.show_frame_with_overlay()
|
|
|
|
def draw_brush(self, draw, x1, y1, x2, y2):
|
|
"""Draw a smooth, round brush stroke."""
|
|
distance = ((x2 - x1)**2 + (y2 - y1)**2)**0.5
|
|
num_steps = int(distance / self.brush_size) + 1
|
|
for i in range(num_steps):
|
|
x = x1 + i * (x2 - x1) / num_steps
|
|
y = y1 + i * (y2 - y1) / num_steps
|
|
draw.ellipse([x - self.brush_size / 2, y - self.brush_size / 2, x + self.brush_size / 2, y + self.brush_size / 2], fill=self.brush_color, outline=self.brush_color)
|
|
|
|
def scale_coordinates(self, x, y):
|
|
"""Scale the coordinates based on the current preview resolution."""
|
|
original_width, original_height = self.frames[self.frame_index].size
|
|
preview_width, preview_height = self.image_label.winfo_width(), self.image_label.winfo_height()
|
|
scale_x = original_width / preview_width
|
|
scale_y = original_height / preview_height
|
|
return int(x * scale_x), int(y * scale_y)
|
|
|
|
# MENU HELP
|
|
|
|
def show_about(self):
|
|
"""Display the About dialog."""
|
|
messagebox.showinfo("About GIFCraft", "GIFCraft - GIF Editor\nVersion 1.0\n© 2024 by Seehrum")
|
|
|
|
def set_delay(self, event=None):
|
|
"""Set the delay for the selected frames."""
|
|
try:
|
|
delay = int(self.delay_entry.get())
|
|
self.save_state()
|
|
for i, var in enumerate(self.checkbox_vars):
|
|
if var.get() == 1:
|
|
self.delays[i] = delay
|
|
self.update_frame_list()
|
|
except ValueError:
|
|
messagebox.showerror("Invalid Input", "Please enter a valid integer for delay.")
|
|
|
|
def focus_delay_entry(self, event=None):
|
|
"""Set focus to the delay entry field and scroll to the current frame."""
|
|
self.delay_entry.focus_set()
|
|
self.focus_current_frame()
|
|
|
|
def focus_current_frame(self):
|
|
"""Ensure the current frame is visible in the frame list."""
|
|
if self.frame_list.winfo_children():
|
|
frame_widgets = self.frame_list.winfo_children()
|
|
current_frame_widget = frame_widgets[self.frame_index]
|
|
self.canvas.yview_moveto(current_frame_widget.winfo_y() / self.canvas.bbox("all")[3])
|
|
|
|
def setup_frame_list(self):
|
|
"""Set up the frame list with scrollbar."""
|
|
self.frame_list_frame = Frame(self.master)
|
|
self.frame_list_frame.pack(side=tk.LEFT, fill=tk.Y)
|
|
|
|
self.scrollbar = Scrollbar(self.frame_list_frame, orient="vertical")
|
|
self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
|
|
self.canvas = Canvas(self.frame_list_frame, yscrollcommand=self.scrollbar.set)
|
|
self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
self.scrollbar.config(command=self.canvas.yview)
|
|
|
|
self.frame_list = Frame(self.canvas)
|
|
self.canvas.create_window((0, 0), window=self.frame_list, anchor='nw')
|
|
|
|
self.frame_list.bind("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
|
|
|
|
self.update_frame_list()
|
|
|
|
def setup_control_frame(self):
|
|
"""Set up the control frame with image display."""
|
|
self.control_frame_canvas = tk.Canvas(self.master)
|
|
self.control_frame_canvas.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
|
|
|
|
self.control_frame_scrollbar = Scrollbar(self.control_frame_canvas, orient="vertical", command=self.control_frame_canvas.yview)
|
|
self.control_frame_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
|
|
self.control_frame = tk.Frame(self.control_frame_canvas)
|
|
self.control_frame_canvas.create_window((0, 0), window=self.control_frame, anchor='nw')
|
|
|
|
self.control_frame_canvas.config(yscrollcommand=self.control_frame_scrollbar.set)
|
|
self.control_frame.bind("<Configure>", lambda e: self.control_frame_canvas.config(scrollregion=self.control_frame_canvas.bbox("all")))
|
|
|
|
self.image_display_frame = tk.Frame(self.control_frame)
|
|
self.image_display_frame.grid(row=0, column=0, padx=20, pady=20, sticky='n')
|
|
|
|
self.image_label = tk.Label(self.image_display_frame, bd=0, relief="flat")
|
|
self.image_label.pack()
|
|
|
|
self.dimension_label = tk.Label(self.image_display_frame, text="", font=("Arial", 8), fg="grey")
|
|
self.dimension_label.pack(pady=5)
|
|
|
|
self.total_duration_label = tk.Label(self.image_display_frame, text="", font=("Arial", 8), fg="grey")
|
|
self.total_duration_label.pack(pady=5)
|
|
|
|
self.control_inputs_frame = tk.Frame(self.control_frame)
|
|
self.control_inputs_frame.grid(row=1, column=0, padx=20, pady=10, sticky='n')
|
|
|
|
self.delay_label = tk.Label(self.control_inputs_frame, text="Frame Delay (ms):")
|
|
self.delay_label.grid(row=0, column=0, pady=5, sticky=tk.E)
|
|
|
|
vcmd = (self.master.register(self.validate_delay), '%P')
|
|
self.delay_entry = tk.Entry(self.control_inputs_frame, validate='key', validatecommand=vcmd)
|
|
self.delay_entry.grid(row=0, column=1, pady=5, padx=5, sticky=tk.W)
|
|
|
|
self.delay_button = tk.Button(self.control_inputs_frame, text="Set Frame Delay", command=self.set_delay)
|
|
self.delay_button.grid(row=1, column=0, columnspan=2, pady=5)
|
|
|
|
self.play_button = tk.Button(self.control_inputs_frame, text="Play", command=self.toggle_play_pause)
|
|
self.play_button.grid(row=2, column=0, columnspan=2, pady=5)
|
|
|
|
self.control_frame.update_idletasks()
|
|
self.control_frame_canvas.config(scrollregion=self.control_frame.bbox("all"))
|
|
|
|
def validate_delay(self, new_value):
|
|
"""Validate that the delay entry contains only digits."""
|
|
if new_value.isdigit() or new_value == "":
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def bind_keyboard_events(self):
|
|
"""Bind keyboard events for navigating frames."""
|
|
self.delay_entry.bind("<Return>", self.set_delay)
|
|
self.master.bind("<Control-n>", self.new_file)
|
|
self.master.bind("<Control-N>", self.new_file)
|
|
self.master.bind("<Control-o>", self.load_file)
|
|
self.master.bind("<Control-O>", self.load_file)
|
|
self.master.bind("<Left>", self.previous_frame)
|
|
self.master.bind("<Control-Left>", self.go_to_beginning)
|
|
self.master.bind("<Right>", self.next_frame)
|
|
self.master.bind("<Control-Right>", self.go_to_end)
|
|
self.master.bind("<Up>", self.move_frame_up)
|
|
self.master.bind("<Down>", self.move_frame_down)
|
|
self.master.bind("<Delete>", self.delete_frames)
|
|
self.master.bind("<Control-Delete>", self.delete_unchecked_frames)
|
|
self.master.bind("<space>", self.toggle_play_pause)
|
|
self.master.bind("<Control-i>", self.add_image)
|
|
self.master.bind("<Control-I>", self.add_image)
|
|
self.master.bind("<Control-c>", self.copy_frames)
|
|
self.master.bind("<Control-C>", self.copy_frames)
|
|
self.master.bind("<Control-v>", self.paste_frames)
|
|
self.master.bind("<Control-V>", self.paste_frames)
|
|
self.master.bind("<Control-g>", self.go_to_frame)
|
|
self.master.bind("<Control-G>", self.go_to_frame)
|
|
self.master.bind("<Control-z>", self.undo)
|
|
self.master.bind("<Control-Z>", self.undo)
|
|
self.master.bind("<Control-y>", self.redo)
|
|
self.master.bind("<Control-Y>", self.redo)
|
|
self.master.bind("<Control-s>", self.save)
|
|
self.master.bind("<Control-S>", self.save_as)
|
|
self.master.bind("m", self.merge_frames)
|
|
self.master.bind("M", self.merge_frames)
|
|
self.master.bind("x", self.toggle_checkbox)
|
|
self.master.bind("X", self.toggle_checkbox)
|
|
self.master.bind("w", self.toggle_draw_mode)
|
|
self.master.bind("W", self.toggle_draw_mode)
|
|
self.master.bind("a", self.toggle_check_all)
|
|
self.master.bind("A", self.toggle_check_all)
|
|
self.master.bind("t", self.toggle_transparent_frames_preview)
|
|
self.master.bind("T", self.toggle_transparent_frames_preview)
|
|
self.master.bind("f", self.focus_delay_entry)
|
|
self.master.bind("F", self.focus_delay_entry)
|
|
|
|
def toggle_checkbox(self, event=None):
|
|
"""Toggle the checkbox of the current frame."""
|
|
if self.checkbox_vars:
|
|
current_var = self.checkbox_vars[self.frame_index]
|
|
current_var.set(0 if current_var.get() else 1)
|
|
|
|
def resize_to_base_size(self, image):
|
|
"""Resize the image to the base size of the first frame and center it."""
|
|
if hasattr(self, 'base_size'):
|
|
base_width, base_height = self.base_size
|
|
new_image = Image.new("RGBA", self.base_size, (0, 0, 0, 0))
|
|
image = image.resize(self.base_size, Image.Resampling.LANCZOS)
|
|
new_image.paste(image, ((base_width - image.width) // 2, (base_height - image.height) // 2))
|
|
return new_image
|
|
return image
|
|
|
|
def update_frame_list(self):
|
|
"""Update the frame list with the current frames and their delays."""
|
|
for widget in self.frame_list.winfo_children():
|
|
widget.destroy()
|
|
|
|
if not self.frames:
|
|
tk.Label(self.frame_list, text="No frames available").pack()
|
|
self.canvas.config(scrollregion=self.canvas.bbox("all"))
|
|
return
|
|
|
|
for i, (frame, delay, var) in enumerate(zip(self.frames, self.delays, self.checkbox_vars)):
|
|
frame_container = Frame(self.frame_list, bg='gray' if i == self.frame_index else self.frame_list.cget('bg'))
|
|
frame_container.pack(fill=tk.X)
|
|
|
|
checkbox = Checkbutton(frame_container, variable=var, bg=frame_container.cget('bg'))
|
|
checkbox.pack(side=tk.LEFT)
|
|
|
|
frame_label_text = f"Frame {i + 1}: {delay} ms"
|
|
if i == self.frame_index:
|
|
frame_label_text = f"→ {frame_label_text}"
|
|
|
|
label = tk.Label(frame_container, text=frame_label_text, bg=frame_container.cget('bg'))
|
|
label.pack(side=tk.LEFT, fill=tk.X)
|
|
|
|
self.canvas.config(scrollregion=self.canvas.bbox("all"))
|
|
|
|
def set_current_frame(self, index):
|
|
"""Set the current frame to the one corresponding to the clicked checkbox."""
|
|
self.frame_index = index
|
|
self.show_frame()
|
|
|
|
def show_frame(self):
|
|
"""Display the current frame."""
|
|
if self.frames:
|
|
if self.frame_index >= len(self.frames):
|
|
self.frame_index = len(self.frames) - 1
|
|
frame = self.frames[self.frame_index]
|
|
preview = self.resize_image(frame, max_width=self.preview_width, max_height=self.preview_height)
|
|
photo = ImageTk.PhotoImage(preview)
|
|
self.image_label.config(image=photo, bd=2, relief="solid")
|
|
self.image_label.image = photo
|
|
self.image_label.config(text='')
|
|
self.delay_entry.delete(0, tk.END)
|
|
self.delay_entry.insert(0, str(self.delays[self.frame_index]))
|
|
self.dimension_label.config(text=f"Size: {frame.width}x{frame.height}")
|
|
total_duration = sum(self.delays)
|
|
self.total_duration_label.config(text=f"Total Duration: {total_duration} ms")
|
|
else:
|
|
self.image_label.config(image='', text="No frames to display", bd=0, relief="flat")
|
|
self.image_label.image = None
|
|
self.delay_entry.delete(0, tk.END)
|
|
self.dimension_label.config(text="")
|
|
self.total_duration_label.config(text="")
|
|
self.update_frame_list()
|
|
|
|
def save_state(self):
|
|
"""Save the current state for undo functionality."""
|
|
self.history.append((self.frames.copy(), self.delays.copy(), [var.get() for var in self.checkbox_vars], self.frame_index, self.current_file))
|
|
self.redo_stack.clear()
|
|
|
|
def resize_image(self, image, max_width, max_height):
|
|
"""Resize image while maintaining aspect ratio."""
|
|
ratio = min(max_width / image.width, max_height / image.height)
|
|
new_width = int(image.width * ratio)
|
|
new_height = int(image.height * ratio)
|
|
return image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
|
|
|
|
def main():
|
|
"""Main function to initialize the GIF editor."""
|
|
root = tk.Tk()
|
|
app = GIFEditor(master=root)
|
|
try:
|
|
root.mainloop()
|
|
except KeyboardInterrupt:
|
|
print("Program interrupted with Ctrl+C")
|
|
root.destroy()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|