diff --git a/README.md b/README.md index 050c80d..9467191 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,19 @@ A comprehensive library and desktop application for GIF processing and manipulat ## 🚀 Features -### Core GIF Processing Tools -- **Video to GIF** - Convert video files to animated GIFs -- **Resize** - Change GIF dimensions while maintaining aspect ratio -- **Rotate** - Rotate GIFs by 90°, 180°, or 270° degrees -- **Crop** - Cut out specific rectangular areas from GIFs +### ✅ **IMPLEMENTED TOOLS** (Ready to Use) +- **Video to GIF** - Convert video files to animated GIFs with auto-optimization +- **Resize** - Change GIF dimensions with aspect ratio control and quality settings +- **Rotate** - Rotate GIFs by 90°, 180°, or 270° degrees with progress tracking +- **Crop** - Professional visual crop tool with aspect ratio presets and drag selection +- **Rearrange** - Advanced drag-and-drop frame reordering with visual preview + +### 🔄 **IN PROGRESS** (Partially Implemented) - **Split** - Extract individual frames from GIFs - **Merge** - Combine multiple GIFs or images into one - **Add Text** - Overlay text with customizable fonts and colors -- **Rearrange** - Drag and drop frames to reorder them + +### 📋 **PLANNED TOOLS** (Coming Soon) - **Reverse** - Play GIF animations backwards - **Optimize** - Reduce file size while maintaining quality - **Speed Control** - Adjust playback speed @@ -28,18 +32,46 @@ A comprehensive library and desktop application for GIF processing and manipulat - **Batch Processing** - Process multiple files at once - **Watermark** - Add image or text watermarks -### Desktop Application -- Modern, intuitive GUI built with tkinter -- Drag-and-drop file handling -- Real-time preview -- Progress tracking -- Error handling and validation +### 🎨 **Desktop Application Features** +- **Modern GUI** - Professional interface built with tkinter +- **Visual Crop Tool** - Click-and-drag cropping with 15+ aspect ratio presets +- **Auto-Loading** - Tools automatically load selected GIFs from main dashboard +- **Progress Tracking** - Real-time progress bars with detailed status messages +- **Error Handling** - Robust error messages and validation +- **Resizable Windows** - All tool dialogs are resizable for better workflow +- **Aspect Ratio Presets** - Free, Square, Classic, Camera, Widescreen, Portrait, Vertical, and more -### Future Web API +### 🌐 **Future Web API** - RESTful API for web integration - Async processing for large files - Docker containerization ready +## 📊 **Current Development Status** + +### **Phase 1: Core Infrastructure** ✅ **COMPLETED** +- Project structure and architecture +- Core library with modular design +- Desktop GUI framework +- Basic tool integration + +### **Phase 2: Basic Tools** ✅ **COMPLETED** +- Video to GIF conversion with auto-optimization +- Resize tool with aspect ratio control +- Rotate tool with progress tracking +- Professional visual crop tool with 15+ aspect ratios +- Advanced rearrange tool with drag-and-drop + +### **Phase 3: Advanced Tools** 🔄 **IN PROGRESS** +- Split tool (frames extraction) +- Merge tool (combine GIFs) +- Add Text tool (text overlay) + +### **Phase 4: Polish & Optimization** 📋 **PLANNED** +- Performance optimization +- Additional effects and filters +- Batch processing +- Web API development + ## 📦 Installation ### Prerequisites diff --git a/desktop_app/gui/tool_panels/__init__.py b/desktop_app/gui/tool_panels/__init__.py index f2235c2..dc2a3f1 100644 --- a/desktop_app/gui/tool_panels/__init__.py +++ b/desktop_app/gui/tool_panels/__init__.py @@ -4,10 +4,10 @@ Contains GUI panels for all GIF processing tools. """ -from .resize_panel import ResizePanel -from .add_text_panel import AddTextPanel +from .rearrange_panel import RearrangePanel +from .video_to_gif_panel import VideoToGifPanel __all__ = [ - 'ResizePanel', - 'AddTextPanel', + 'RearrangePanel', + 'VideoToGifPanel', ] diff --git a/desktop_app/gui/tool_panels/crop_panel.py b/desktop_app/gui/tool_panels/crop_panel.py new file mode 100644 index 0000000..3a48049 --- /dev/null +++ b/desktop_app/gui/tool_panels/crop_panel.py @@ -0,0 +1,593 @@ +""" +Crop Tool Panel + +GUI panel for the GIF crop tool with interactive controls. +""" + +import tkinter as tk +from tkinter import ttk, messagebox +from pathlib import Path +from typing import Optional, Callable, Any +import threading + +from PIL import Image, ImageTk +from gif_tools.core.crop import crop_gif + + +class CropPanel(ttk.Frame): + """Panel for GIF crop operations.""" + + def __init__(self, parent: tk.Widget, on_process: Optional[Callable] = None, file_path: Optional[str] = None): + """ + Initialize the crop panel. + + Args: + parent: Parent widget + on_process: Callback function for processing + file_path: Optional path to GIF file to auto-load + """ + super().__init__(parent) + self.parent = parent + self.on_process = on_process + self.file_path = file_path + self.setup_ui() + + # Auto-load GIF if file path provided + if file_path: + self.auto_load_gif(file_path) + + def setup_ui(self): + """Create the crop panel UI.""" + # Main frame + self.frame = ttk.LabelFrame(self, text="Crop GIF", padding="10") + + # Create notebook for tabs + self.notebook = ttk.Notebook(self.frame) + self.notebook.grid(row=0, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5) + + # Visual crop tab + self.visual_frame = ttk.Frame(self.notebook) + self.notebook.add(self.visual_frame, text="Visual Crop") + + # Manual crop tab + self.manual_frame = ttk.Frame(self.notebook) + self.notebook.add(self.manual_frame, text="Manual Crop") + + # Setup visual crop interface + self.setup_visual_crop() + + # Setup manual crop interface + self.setup_manual_crop() + + # Common controls + self.setup_common_controls() + + def setup_visual_crop(self): + """Setup visual crop interface.""" + # Image preview frame + preview_frame = ttk.LabelFrame(self.visual_frame, text="GIF Preview", padding="5") + preview_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Canvas for image display + self.canvas = tk.Canvas(preview_frame, bg="white", cursor="crosshair", width=400, height=300) + self.canvas.pack(fill=tk.BOTH, expand=True) + + # Bind mouse events for crop selection + self.canvas.bind("", self.start_crop_selection) + self.canvas.bind("", self.update_crop_selection) + self.canvas.bind("", self.end_crop_selection) + + # Load GIF button + load_btn = ttk.Button(preview_frame, text="Load GIF", command=self.load_gif_for_crop) + load_btn.pack(pady=5) + + # Crop area info + info_frame = ttk.Frame(self.visual_frame) + info_frame.pack(fill=tk.X, padx=5, pady=5) + + ttk.Label(info_frame, text="Crop Area:").pack(side=tk.LEFT) + self.crop_info_label = ttk.Label(info_frame, text="X: 0, Y: 0, W: 0, H: 0") + self.crop_info_label.pack(side=tk.LEFT, padx=10) + + # Aspect ratio options + aspect_frame = ttk.Frame(info_frame) + aspect_frame.pack(side=tk.RIGHT) + + ttk.Label(aspect_frame, text="Aspect Ratio:").pack(side=tk.LEFT, padx=(0, 5)) + self.aspect_ratio_var = tk.StringVar(value="free") + aspect_combo = ttk.Combobox( + aspect_frame, + textvariable=self.aspect_ratio_var, + values=[ + "free", + "1:1 (Square)", + "4:3 (Classic)", + "3:2 (Camera)", + "16:9 (Widescreen)", + "21:9 (Ultra-wide)", + "2:1 (Panoramic)", + "3:1 (Ultra-panoramic)", + "5:4 (Portrait)", + "4:5 (Landscape)", + "3:4 (Portrait)", + "2:3 (Portrait)", + "9:16 (Vertical Video)", + "1:2 (Vertical)", + "1:3 (Ultra-vertical)" + ], + state="readonly", + width=15 + ) + aspect_combo.pack(side=tk.LEFT) + aspect_combo.bind("<>", self.on_aspect_ratio_change) + + # Initialize crop variables + self.crop_start_x = None + self.crop_start_y = None + self.crop_end_x = None + self.crop_end_y = None + self.crop_rect = None + self.current_gif = None + self.image_scale = 1.0 + self.image_offset_x = 0 + self.image_offset_y = 0 + self.aspect_ratio = None # Will be set based on selection + + def setup_manual_crop(self): + """Setup manual crop interface.""" + # Crop coordinates + ttk.Label(self.manual_frame, text="Crop Area:").grid(row=0, column=0, sticky=tk.W, pady=5) + + # X coordinate + ttk.Label(self.manual_frame, text="X (left):").grid(row=1, column=0, sticky=tk.W, pady=5) + self.x_var = tk.StringVar(value="0") + x_entry = ttk.Entry(self.manual_frame, textvariable=self.x_var, width=10) + x_entry.grid(row=1, column=1, sticky=tk.W, padx=(5, 0), pady=5) + + # Y coordinate + ttk.Label(self.manual_frame, text="Y (top):").grid(row=2, column=0, sticky=tk.W, pady=5) + self.y_var = tk.StringVar(value="0") + y_entry = ttk.Entry(self.manual_frame, textvariable=self.y_var, width=10) + y_entry.grid(row=2, column=1, sticky=tk.W, padx=(5, 0), pady=5) + + # Width + ttk.Label(self.manual_frame, text="Width:").grid(row=3, column=0, sticky=tk.W, pady=5) + self.width_var = tk.StringVar(value="100") + width_entry = ttk.Entry(self.manual_frame, textvariable=self.width_var, width=10) + width_entry.grid(row=3, column=1, sticky=tk.W, padx=(5, 0), pady=5) + + # Height + ttk.Label(self.manual_frame, text="Height:").grid(row=4, column=0, sticky=tk.W, pady=5) + self.height_var = tk.StringVar(value="100") + height_entry = ttk.Entry(self.manual_frame, textvariable=self.height_var, width=10) + height_entry.grid(row=4, column=1, sticky=tk.W, padx=(5, 0), pady=5) + + # Preset crop options + ttk.Label(self.manual_frame, text="Presets:").grid(row=5, column=0, sticky=tk.W, pady=5) + + preset_frame = ttk.Frame(self.manual_frame) + preset_frame.grid(row=5, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=5) + + preset_buttons = [ + ("Center Square", self.set_center_square), + ("Top Half", self.set_top_half), + ("Bottom Half", self.set_bottom_half), + ("Left Half", self.set_left_half), + ("Right Half", self.set_right_half), + ] + + for i, (text, command) in enumerate(preset_buttons): + row, col = divmod(i, 2) + btn = ttk.Button(preset_frame, text=text, command=command, width=12) + btn.grid(row=row, column=col, padx=2, pady=2) + + # Crop mode + ttk.Label(self.manual_frame, text="Crop Mode:").grid(row=6, column=0, sticky=tk.W, pady=5) + self.mode_var = tk.StringVar(value="exact") + mode_combo = ttk.Combobox( + self.manual_frame, + textvariable=self.mode_var, + values=["exact", "safe", "center"], + state="readonly", + width=15 + ) + mode_combo.grid(row=6, column=1, sticky=tk.W, padx=(5, 0), pady=5) + + def setup_common_controls(self): + """Setup common controls for both tabs.""" + # Quality controls + ttk.Label(self.frame, text="Quality:").grid(row=1, column=0, sticky=tk.W, pady=5) + self.quality_var = tk.IntVar(value=85) + quality_scale = ttk.Scale( + self.frame, + from_=1, + to=100, + variable=self.quality_var, + orient=tk.HORIZONTAL, + length=200 + ) + quality_scale.grid(row=7, column=1, columnspan=2, sticky=tk.W, padx=(5, 0), pady=5) + + # Quality value label + self.quality_label = ttk.Label(self.frame, text="85") + self.quality_label.grid(row=7, column=3, sticky=tk.W, padx=(5, 0), pady=5) + + # Update quality label when scale changes + quality_scale.configure(command=self.update_quality_label) + + # Background color + ttk.Label(self.frame, text="Background:").grid(row=8, column=0, sticky=tk.W, pady=5) + self.bg_color_var = tk.StringVar(value="transparent") + bg_combo = ttk.Combobox( + self.frame, + textvariable=self.bg_color_var, + values=["transparent", "white", "black", "red", "green", "blue"], + state="readonly", + width=15 + ) + bg_combo.grid(row=8, column=1, sticky=tk.W, padx=(5, 0), pady=5) + + # Process button + self.process_btn = ttk.Button( + self.frame, + text="Crop GIF", + command=self.process_crop + ) + self.process_btn.grid(row=9, column=0, columnspan=3, pady=10) + + # Progress bar + self.progress_var = tk.DoubleVar() + self.progress_bar = ttk.Progressbar( + self.frame, + variable=self.progress_var, + mode='indeterminate' + ) + self.progress_bar.grid(row=10, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Configure grid weights + self.frame.grid_columnconfigure(1, weight=1) + + # Pack the frame + self.frame.pack(fill=tk.BOTH, expand=True) + + def auto_load_gif(self, file_path: str): + """Auto-load GIF from file path.""" + try: + print(f"Auto-loading GIF: {file_path}") + self.current_gif = Image.open(file_path) + print(f"GIF auto-loaded successfully: {self.current_gif.size}") + # Schedule display after UI is ready + self.after(100, self.display_gif_preview) + except Exception as e: + print(f"Error auto-loading GIF: {e}") + messagebox.showerror("Error", f"Failed to auto-load GIF: {e}") + + def load_gif_for_crop(self): + """Load GIF for visual cropping.""" + from tkinter import filedialog + file_path = filedialog.askopenfilename( + title="Select GIF file", + filetypes=[("GIF files", "*.gif"), ("All files", "*.*")] + ) + if file_path: + try: + print(f"Loading GIF: {file_path}") + self.current_gif = Image.open(file_path) + print(f"GIF loaded successfully: {self.current_gif.size}") + self.display_gif_preview() + except Exception as e: + print(f"Error loading GIF: {e}") + messagebox.showerror("Error", f"Failed to load GIF: {e}") + + def display_gif_preview(self): + """Display GIF preview on canvas.""" + if not self.current_gif: + print("No GIF loaded") + return + + try: + print("Starting to display GIF preview") + # Clear canvas + self.canvas.delete("all") + + # Force canvas to update and get actual size + self.canvas.update_idletasks() + + # Calculate scale to fit canvas + canvas_width = self.canvas.winfo_width() + canvas_height = self.canvas.winfo_height() + print(f"Canvas size: {canvas_width}x{canvas_height}") + + # If canvas is still not ready, set a minimum size + if canvas_width <= 1 or canvas_height <= 1: + canvas_width = 400 + canvas_height = 300 + self.canvas.config(width=canvas_width, height=canvas_height) + print(f"Set canvas to minimum size: {canvas_width}x{canvas_height}") + + img_width = self.current_gif.width + img_height = self.current_gif.height + print(f"Image size: {img_width}x{img_height}") + + # Calculate scale to fit canvas with some padding + scale_x = (canvas_width - 20) / img_width + scale_y = (canvas_height - 20) / img_height + self.image_scale = min(scale_x, scale_y, 1.0) # Don't scale up + print(f"Image scale: {self.image_scale}") + + # Calculate centered position + scaled_width = int(img_width * self.image_scale) + scaled_height = int(img_height * self.image_scale) + print(f"Scaled size: {scaled_width}x{scaled_height}") + + self.image_offset_x = (canvas_width - scaled_width) // 2 + self.image_offset_y = (canvas_height - scaled_height) // 2 + print(f"Image offset: ({self.image_offset_x}, {self.image_offset_y})") + + # Resize image for display + display_img = self.current_gif.resize((scaled_width, scaled_height), Image.Resampling.LANCZOS) + print("Image resized successfully") + + # Convert to PhotoImage + self.display_photo = ImageTk.PhotoImage(display_img) + print("PhotoImage created successfully") + + # Display image + self.canvas.create_image( + self.image_offset_x + scaled_width // 2, + self.image_offset_y + scaled_height // 2, + image=self.display_photo + ) + print("Image displayed on canvas") + + # Add a border around the image + self.canvas.create_rectangle( + self.image_offset_x, self.image_offset_y, + self.image_offset_x + scaled_width, self.image_offset_y + scaled_height, + outline="gray", width=1 + ) + print("Border added") + + except Exception as e: + print(f"Error in display_gif_preview: {e}") + messagebox.showerror("Error", f"Failed to display GIF preview: {e}") + import traceback + traceback.print_exc() + + def start_crop_selection(self, event): + """Start crop area selection.""" + self.crop_start_x = event.x + self.crop_start_y = event.y + self.crop_end_x = event.x + self.crop_end_y = event.y + + # Clear previous crop rectangle + if self.crop_rect: + self.canvas.delete(self.crop_rect) + + def update_crop_selection(self, event): + """Update crop area selection.""" + if self.crop_start_x is None: + return + + self.crop_end_x = event.x + self.crop_end_y = event.y + + # Clear previous rectangle + if self.crop_rect: + self.canvas.delete(self.crop_rect) + + # Apply aspect ratio constraint + x1, y1, x2, y2 = self.constrain_to_aspect_ratio( + self.crop_start_x, self.crop_start_y, + self.crop_end_x, self.crop_end_y + ) + + # Draw new rectangle + self.crop_rect = self.canvas.create_rectangle( + x1, y1, x2, y2, + outline="red", width=2, fill="", stipple="gray50" + ) + + # Update crop info + self.update_crop_info() + + def end_crop_selection(self, event): + """End crop area selection.""" + if self.crop_start_x is None: + return + + self.crop_end_x = event.x + self.crop_end_y = event.y + + # Apply aspect ratio constraint + x1, y1, x2, y2 = self.constrain_to_aspect_ratio( + self.crop_start_x, self.crop_start_y, + self.crop_end_x, self.crop_end_y + ) + + # Update final rectangle + if self.crop_rect: + self.canvas.delete(self.crop_rect) + + self.crop_rect = self.canvas.create_rectangle( + x1, y1, x2, y2, + outline="red", width=2, fill="", stipple="gray50" + ) + + # Update crop info + self.update_crop_info() + + def update_crop_info(self): + """Update crop area information.""" + if self.crop_start_x is None or self.crop_end_x is None: + return + + # Calculate crop area in canvas coordinates + x1 = min(self.crop_start_x, self.crop_end_x) + y1 = min(self.crop_start_y, self.crop_end_y) + x2 = max(self.crop_start_x, self.crop_end_x) + y2 = max(self.crop_start_y, self.crop_end_y) + + # Convert to image coordinates + if self.current_gif: + img_x1 = int((x1 - self.image_offset_x) / self.image_scale) + img_y1 = int((y1 - self.image_offset_y) / self.image_scale) + img_x2 = int((x2 - self.image_offset_x) / self.image_scale) + img_y2 = int((y2 - self.image_offset_y) / self.image_scale) + + # Ensure coordinates are within image bounds + img_x1 = max(0, min(img_x1, self.current_gif.width)) + img_y1 = max(0, min(img_y1, self.current_gif.height)) + img_x2 = max(0, min(img_x2, self.current_gif.width)) + img_y2 = max(0, min(img_y2, self.current_gif.height)) + + # Calculate width and height + width = img_x2 - img_x1 + height = img_y2 - img_y1 + + # Update info label + self.crop_info_label.config(text=f"X: {img_x1}, Y: {img_y1}, W: {width}, H: {height}") + + # Update manual crop fields + self.x_var.set(str(img_x1)) + self.y_var.set(str(img_y1)) + self.width_var.set(str(width)) + self.height_var.set(str(height)) + + def on_aspect_ratio_change(self, event=None): + """Handle aspect ratio selection change.""" + ratio_text = self.aspect_ratio_var.get() + + if ratio_text == "free": + self.aspect_ratio = None + else: + # Parse aspect ratio (e.g., "1:1 (Square)" -> 1.0) + ratio_part = ratio_text.split(" ")[0] # Get "1:1" part + if ":" in ratio_part: + w, h = ratio_part.split(":") + self.aspect_ratio = float(w) / float(h) + else: + self.aspect_ratio = None + + def constrain_to_aspect_ratio(self, x1, y1, x2, y2): + """Constrain crop selection to selected aspect ratio.""" + if self.aspect_ratio is None: + return x1, y1, x2, y2 + + width = x2 - x1 + height = y2 - y1 + + # Avoid division by zero + if height == 0: + height = 1 + if width == 0: + width = 1 + + # Calculate target dimensions based on aspect ratio + if width / height > self.aspect_ratio: + # Too wide, adjust width + target_width = int(height * self.aspect_ratio) + x2 = x1 + target_width + else: + # Too tall, adjust height + target_height = int(width / self.aspect_ratio) + y2 = y1 + target_height + + return x1, y1, x2, y2 + + def set_center_square(self): + """Set crop area to center square.""" + self.x_var.set("50") + self.y_var.set("50") + self.width_var.set("100") + self.height_var.set("100") + + def set_top_half(self): + """Set crop area to top half.""" + self.x_var.set("0") + self.y_var.set("0") + self.width_var.set("200") + self.height_var.set("100") + + def set_bottom_half(self): + """Set crop area to bottom half.""" + self.x_var.set("0") + self.y_var.set("100") + self.width_var.set("200") + self.height_var.set("100") + + def set_left_half(self): + """Set crop area to left half.""" + self.x_var.set("0") + self.y_var.set("0") + self.width_var.set("100") + self.height_var.set("200") + + def set_right_half(self): + """Set crop area to right half.""" + self.x_var.set("100") + self.y_var.set("0") + self.width_var.set("100") + self.height_var.set("200") + + def update_quality_label(self, value): + """Update the quality label when scale changes.""" + self.quality_label.config(text=str(int(float(value)))) + + def get_settings(self) -> dict: + """Get current crop settings.""" + try: + x = int(self.x_var.get()) + y = int(self.y_var.get()) + width = int(self.width_var.get()) + height = int(self.height_var.get()) + quality = self.quality_var.get() + + except ValueError as e: + raise ValueError(f"Invalid numeric value: {e}") + + settings = { + 'x': x, + 'y': y, + 'width': width, + 'height': height, + 'mode': self.mode_var.get(), + 'quality': quality + } + + # Add background color + bg_color = self.bg_color_var.get() + if bg_color != "transparent": + settings['background_color'] = bg_color + + return settings + + def process_crop(self): + """Process the crop operation.""" + try: + settings = self.get_settings() + + if self.on_process: + self.on_process('crop', settings) + else: + messagebox.showinfo("Crop", f"Crop settings: {settings}") + + except ValueError as e: + messagebox.showerror("Error", str(e)) + except Exception as e: + messagebox.showerror("Error", f"Crop failed: {e}") + + def start_progress(self): + """Start the progress bar.""" + self.progress_bar.start() + self.process_btn.config(state=tk.DISABLED) + + def stop_progress(self): + """Stop the progress bar.""" + self.progress_bar.stop() + self.process_btn.config(state=tk.NORMAL) + + def get_widget(self) -> tk.Widget: + """Get the main widget for this panel.""" + return self.frame diff --git a/desktop_app/gui/tool_panels/merge_panel.py b/desktop_app/gui/tool_panels/merge_panel.py new file mode 100644 index 0000000..ea4fc37 --- /dev/null +++ b/desktop_app/gui/tool_panels/merge_panel.py @@ -0,0 +1,272 @@ +""" +Merge Tool Panel + +GUI panel for the GIF merge tool with interactive controls. +""" + +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from pathlib import Path +from typing import Optional, Callable, Any, List +import threading + +from gif_tools.core.merge import merge_gifs + + +class MergePanel: + """Panel for GIF merge operations.""" + + def __init__(self, parent: tk.Widget, on_process: Optional[Callable] = None): + """ + Initialize the merge panel. + + Args: + parent: Parent widget + on_process: Callback function for processing + """ + self.parent = parent + self.on_process = on_process + self.file_list: List[str] = [] + self.setup_ui() + + def setup_ui(self): + """Create the merge panel UI.""" + # Main frame + self.frame = ttk.LabelFrame(self.parent, text="Merge GIFs", padding="10") + + # File list + ttk.Label(self.frame, text="Files to Merge:").grid(row=0, column=0, sticky=tk.W, pady=5) + + # File listbox with scrollbar + list_frame = ttk.Frame(self.frame) + list_frame.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5) + + self.file_listbox = tk.Listbox(list_frame, height=6, width=50) + scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.file_listbox.yview) + self.file_listbox.configure(yscrollcommand=scrollbar.set) + + self.file_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # File controls + file_controls = ttk.Frame(self.frame) + file_controls.grid(row=2, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + ttk.Button(file_controls, text="Add Files", command=self.add_files).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(file_controls, text="Remove Selected", command=self.remove_selected).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(file_controls, text="Clear All", command=self.clear_all).pack(side=tk.LEFT, padx=(0, 5)) + + # Move controls + move_controls = ttk.Frame(self.frame) + move_controls.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + ttk.Button(move_controls, text="Move Up", command=self.move_up).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(move_controls, text="Move Down", command=self.move_down).pack(side=tk.LEFT, padx=(0, 5)) + + # Merge options + ttk.Label(self.frame, text="Merge Options:").grid(row=4, column=0, sticky=tk.W, pady=5) + + # Merge mode + self.merge_mode_var = tk.StringVar(value="sequential") + mode_frame = ttk.Frame(self.frame) + mode_frame.grid(row=4, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=5) + + mode_options = [ + ("Sequential", "sequential"), + ("Horizontal", "horizontal"), + ("Vertical", "vertical"), + ] + + for i, (text, value) in enumerate(mode_options): + btn = ttk.Radiobutton( + mode_frame, + text=text, + variable=self.merge_mode_var, + value=value + ) + btn.pack(side=tk.LEFT, padx=(0, 10)) + + # Timing controls + timing_frame = ttk.LabelFrame(self.frame, text="Timing", padding="5") + timing_frame.grid(row=5, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + ttk.Label(timing_frame, text="Frame Duration (ms):").grid(row=0, column=0, sticky=tk.W, pady=2) + self.duration_var = tk.StringVar(value="100") + ttk.Entry(timing_frame, textvariable=self.duration_var, width=10).grid(row=0, column=1, sticky=tk.W, padx=(5, 0), pady=2) + + ttk.Label(timing_frame, text="Transition Duration (ms):").grid(row=1, column=0, sticky=tk.W, pady=2) + self.transition_var = tk.StringVar(value="0") + ttk.Entry(timing_frame, textvariable=self.transition_var, width=10).grid(row=1, column=1, sticky=tk.W, padx=(5, 0), pady=2) + + # Loop settings + ttk.Label(self.frame, text="Loop Count:").grid(row=6, column=0, sticky=tk.W, pady=5) + self.loop_var = tk.StringVar(value="0") + loop_combo = ttk.Combobox( + self.frame, + textvariable=self.loop_var, + values=["0 (infinite)", "1", "2", "3", "5", "10"], + state="readonly", + width=15 + ) + loop_combo.grid(row=6, column=1, sticky=tk.W, padx=(5, 0), pady=5) + + # Quality controls + ttk.Label(self.frame, text="Quality:").grid(row=7, column=0, sticky=tk.W, pady=5) + self.quality_var = tk.IntVar(value=85) + quality_scale = ttk.Scale( + self.frame, + from_=1, + to=100, + variable=self.quality_var, + orient=tk.HORIZONTAL, + length=200 + ) + quality_scale.grid(row=7, column=1, columnspan=2, sticky=tk.W, padx=(5, 0), pady=5) + + # Quality value label + self.quality_label = ttk.Label(self.frame, text="85") + self.quality_label.grid(row=7, column=3, sticky=tk.W, padx=(5, 0), pady=5) + + # Update quality label when scale changes + quality_scale.configure(command=self.update_quality_label) + + # Process button + self.process_btn = ttk.Button( + self.frame, + text="Merge GIFs", + command=self.process_merge + ) + self.process_btn.grid(row=8, column=0, columnspan=3, pady=10) + + # Progress bar + self.progress_var = tk.DoubleVar() + self.progress_bar = ttk.Progressbar( + self.frame, + variable=self.progress_var, + mode='indeterminate' + ) + self.progress_bar.grid(row=9, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Configure grid weights + self.frame.grid_columnconfigure(1, weight=1) + self.frame.grid_rowconfigure(1, weight=1) + + def add_files(self): + """Add files to the merge list.""" + file_paths = filedialog.askopenfilenames( + title="Select GIF Files to Merge", + filetypes=[ + ("GIF files", "*.gif"), + ("Image files", "*.png;*.jpg;*.jpeg;*.bmp;*.tiff"), + ("All files", "*.*") + ] + ) + + for file_path in file_paths: + if file_path not in self.file_list: + self.file_list.append(file_path) + self.file_listbox.insert(tk.END, Path(file_path).name) + + def remove_selected(self): + """Remove selected file from the list.""" + selection = self.file_listbox.curselection() + if selection: + index = selection[0] + self.file_listbox.delete(index) + self.file_list.pop(index) + + def clear_all(self): + """Clear all files from the list.""" + self.file_listbox.delete(0, tk.END) + self.file_list.clear() + + def move_up(self): + """Move selected file up in the list.""" + selection = self.file_listbox.curselection() + if selection and selection[0] > 0: + index = selection[0] + # Swap in list + self.file_list[index], self.file_list[index-1] = self.file_list[index-1], self.file_list[index] + # Update listbox + self.file_listbox.delete(0, tk.END) + for file_path in self.file_list: + self.file_listbox.insert(tk.END, Path(file_path).name) + # Reselect moved item + self.file_listbox.selection_set(index-1) + + def move_down(self): + """Move selected file down in the list.""" + selection = self.file_listbox.curselection() + if selection and selection[0] < len(self.file_list) - 1: + index = selection[0] + # Swap in list + self.file_list[index], self.file_list[index+1] = self.file_list[index+1], self.file_list[index] + # Update listbox + self.file_listbox.delete(0, tk.END) + for file_path in self.file_list: + self.file_listbox.insert(tk.END, Path(file_path).name) + # Reselect moved item + self.file_listbox.selection_set(index+1) + + def update_quality_label(self, value): + """Update the quality label when scale changes.""" + self.quality_label.config(text=str(int(float(value)))) + + def get_settings(self) -> dict: + """Get current merge settings.""" + try: + duration = int(self.duration_var.get()) + transition = int(self.transition_var.get()) + quality = self.quality_var.get() + + # Parse loop count + loop_text = self.loop_var.get() + if loop_text.startswith("0"): + loop_count = 0 + else: + loop_count = int(loop_text) + + except ValueError as e: + raise ValueError(f"Invalid numeric value: {e}") + + return { + 'mode': self.merge_mode_var.get(), + 'duration': duration, + 'transition': transition, + 'loop_count': loop_count, + 'quality': quality, + 'file_list': self.file_list.copy() + } + + def process_merge(self): + """Process the merge operation.""" + try: + if not self.file_list: + messagebox.showwarning("Warning", "Please add files to merge!") + return + + settings = self.get_settings() + + if self.on_process: + self.on_process('merge', settings) + else: + messagebox.showinfo("Merge", f"Merge settings: {settings}") + + except ValueError as e: + messagebox.showerror("Error", str(e)) + except Exception as e: + messagebox.showerror("Error", f"Merge failed: {e}") + + def start_progress(self): + """Start the progress bar.""" + self.progress_bar.start() + self.process_btn.config(state=tk.DISABLED) + + def stop_progress(self): + """Stop the progress bar.""" + self.progress_bar.stop() + self.process_btn.config(state=tk.NORMAL) + + def get_widget(self) -> tk.Widget: + """Get the main widget for this panel.""" + return self.frame diff --git a/desktop_app/gui/tool_panels/optimize_panel.py b/desktop_app/gui/tool_panels/optimize_panel.py new file mode 100644 index 0000000..cf6cfc5 --- /dev/null +++ b/desktop_app/gui/tool_panels/optimize_panel.py @@ -0,0 +1,296 @@ +""" +Optimize Tool Panel + +GUI panel for the GIF optimize tool with interactive controls. +""" + +import tkinter as tk +from tkinter import ttk, messagebox +from pathlib import Path +from typing import Optional, Callable, Any +import threading + +from gif_tools.core.optimize import optimize_gif + + +class OptimizePanel: + """Panel for GIF optimize operations.""" + + def __init__(self, parent: tk.Widget, on_process: Optional[Callable] = None): + """ + Initialize the optimize panel. + + Args: + parent: Parent widget + on_process: Callback function for processing + """ + self.parent = parent + self.on_process = on_process + self.setup_ui() + + def setup_ui(self): + """Create the optimize panel UI.""" + # Main frame + self.frame = ttk.LabelFrame(self.parent, text="Optimize GIF", padding="10") + + # Description + desc_text = "Optimize your GIF to reduce file size while maintaining visual quality." + ttk.Label(self.frame, text=desc_text, wraplength=400, justify=tk.LEFT).grid(row=0, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Optimization level + ttk.Label(self.frame, text="Optimization Level:").grid(row=1, column=0, sticky=tk.W, pady=10) + + self.optimization_level_var = tk.StringVar(value="balanced") + level_frame = ttk.Frame(self.frame) + level_frame.grid(row=1, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=10) + + level_options = [ + ("Light", "light"), + ("Balanced", "balanced"), + ("Aggressive", "aggressive"), + ("Custom", "custom"), + ] + + for i, (text, value) in enumerate(level_options): + btn = ttk.Radiobutton( + level_frame, + text=text, + variable=self.optimization_level_var, + value=value, + command=self.update_controls + ) + btn.grid(row=0, column=i, padx=(0, 15), sticky=tk.W) + + # Custom settings frame + self.custom_frame = ttk.LabelFrame(self.frame, text="Custom Settings", padding="5") + self.custom_frame.grid(row=2, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Quality + ttk.Label(self.custom_frame, text="Quality (1-100):").grid(row=0, column=0, sticky=tk.W, pady=2) + self.quality_var = tk.IntVar(value=85) + quality_scale = ttk.Scale( + self.custom_frame, + from_=1, + to=100, + variable=self.quality_var, + orient=tk.HORIZONTAL, + length=200 + ) + quality_scale.grid(row=0, column=1, columnspan=2, sticky=tk.W, padx=(5, 0), pady=2) + + # Quality value label + self.quality_label = ttk.Label(self.custom_frame, text="85") + self.quality_label.grid(row=0, column=3, sticky=tk.W, padx=(5, 0), pady=2) + + # Update quality label when scale changes + quality_scale.configure(command=self.update_quality_label) + + # Color reduction + ttk.Label(self.custom_frame, text="Color Reduction:").grid(row=1, column=0, sticky=tk.W, pady=2) + self.color_reduction_var = tk.StringVar(value="adaptive") + color_combo = ttk.Combobox( + self.custom_frame, + textvariable=self.color_reduction_var, + values=["none", "adaptive", "fixed"], + state="readonly", + width=15 + ) + color_combo.grid(row=1, column=1, sticky=tk.W, padx=(5, 0), pady=2) + + # Max colors + ttk.Label(self.custom_frame, text="Max Colors:").grid(row=2, column=0, sticky=tk.W, pady=2) + self.max_colors_var = tk.StringVar(value="256") + ttk.Entry(self.custom_frame, textvariable=self.max_colors_var, width=10).grid(row=2, column=1, sticky=tk.W, padx=(5, 0), pady=2) + + # Dithering + self.dither_var = tk.BooleanVar(value=True) + dither_check = ttk.Checkbutton( + self.custom_frame, + text="Enable Dithering", + variable=self.dither_var + ) + dither_check.grid(row=3, column=0, columnspan=2, sticky=tk.W, pady=2) + + # Optimization options + ttk.Label(self.frame, text="Optimization Options:").grid(row=3, column=0, sticky=tk.W, pady=10) + + options_frame = ttk.Frame(self.frame) + options_frame.grid(row=3, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=10) + + self.remove_duplicates_var = tk.BooleanVar(value=True) + ttk.Checkbutton(options_frame, text="Remove duplicate frames", variable=self.remove_duplicates_var).grid(row=0, column=0, sticky=tk.W, pady=2) + + self.optimize_palette_var = tk.BooleanVar(value=True) + ttk.Checkbutton(options_frame, text="Optimize color palette", variable=self.optimize_palette_var).grid(row=0, column=1, sticky=tk.W, pady=2) + + self.reduce_colors_var = tk.BooleanVar(value=True) + ttk.Checkbutton(options_frame, text="Reduce color count", variable=self.reduce_colors_var).grid(row=1, column=0, sticky=tk.W, pady=2) + + self.compress_frames_var = tk.BooleanVar(value=True) + ttk.Checkbutton(options_frame, text="Compress frames", variable=self.compress_frames_var).grid(row=1, column=1, sticky=tk.W, pady=2) + + # Advanced options + self.advanced_var = tk.BooleanVar(value=False) + advanced_check = ttk.Checkbutton( + self.frame, + text="Show Advanced Options", + variable=self.advanced_var, + command=self.toggle_advanced + ) + advanced_check.grid(row=4, column=0, columnspan=3, sticky=tk.W, pady=5) + + # Advanced frame + self.advanced_frame = ttk.LabelFrame(self.frame, text="Advanced Options", padding="5") + self.advanced_frame.grid(row=5, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Lossy compression + ttk.Label(self.advanced_frame, text="Lossy Compression:").grid(row=0, column=0, sticky=tk.W, pady=2) + self.lossy_var = tk.IntVar(value=0) + lossy_scale = ttk.Scale( + self.advanced_frame, + from_=0, + to=100, + variable=self.lossy_var, + orient=tk.HORIZONTAL, + length=150 + ) + lossy_scale.grid(row=0, column=1, columnspan=2, sticky=tk.W, padx=(5, 0), pady=2) + + # Lossy value label + self.lossy_label = ttk.Label(self.advanced_frame, text="0") + self.lossy_label.grid(row=0, column=3, sticky=tk.W, padx=(5, 0), pady=2) + + # Update lossy label when scale changes + lossy_scale.configure(command=self.update_lossy_label) + + # Interlacing + self.interlace_var = tk.BooleanVar(value=False) + ttk.Checkbutton(self.advanced_frame, text="Enable Interlacing", variable=self.interlace_var).grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=2) + + # Process button + self.process_btn = ttk.Button( + self.frame, + text="Optimize GIF", + command=self.process_optimize + ) + self.process_btn.grid(row=6, column=0, columnspan=3, pady=10) + + # Progress bar + self.progress_var = tk.DoubleVar() + self.progress_bar = ttk.Progressbar( + self.frame, + variable=self.progress_var, + mode='indeterminate' + ) + self.progress_bar.grid(row=7, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Configure grid weights + self.frame.grid_columnconfigure(1, weight=1) + + # Initialize controls visibility + self.update_controls() + self.toggle_advanced() + + def update_controls(self): + """Update control visibility based on optimization level.""" + level = self.optimization_level_var.get() + + # Show/hide custom settings + if level == "custom": + self.custom_frame.grid() + else: + self.custom_frame.grid_remove() + + # Set preset values + if level == "light": + self.quality_var.set(95) + self.color_reduction_var.set("none") + self.max_colors_var.set("256") + self.dither_var.set(False) + elif level == "balanced": + self.quality_var.set(85) + self.color_reduction_var.set("adaptive") + self.max_colors_var.set("128") + self.dither_var.set(True) + elif level == "aggressive": + self.quality_var.set(70) + self.color_reduction_var.set("fixed") + self.max_colors_var.set("64") + self.dither_var.set(True) + + def toggle_advanced(self): + """Toggle advanced options visibility.""" + if self.advanced_var.get(): + self.advanced_frame.grid() + else: + self.advanced_frame.grid_remove() + + def update_quality_label(self, value): + """Update the quality label when scale changes.""" + self.quality_label.config(text=str(int(float(value)))) + + def update_lossy_label(self, value): + """Update the lossy label when scale changes.""" + self.lossy_label.config(text=str(int(float(value)))) + + def get_settings(self) -> dict: + """Get current optimize settings.""" + try: + level = self.optimization_level_var.get() + quality = self.quality_var.get() + + settings = { + 'level': level, + 'quality': quality, + 'remove_duplicates': self.remove_duplicates_var.get(), + 'optimize_palette': self.optimize_palette_var.get(), + 'reduce_colors': self.reduce_colors_var.get(), + 'compress_frames': self.compress_frames_var.get() + } + + if level == "custom": + settings.update({ + 'color_reduction': self.color_reduction_var.get(), + 'max_colors': int(self.max_colors_var.get()), + 'dither': self.dither_var.get() + }) + + if self.advanced_var.get(): + settings.update({ + 'lossy': self.lossy_var.get(), + 'interlace': self.interlace_var.get() + }) + + except ValueError as e: + raise ValueError(f"Invalid numeric value: {e}") + + return settings + + def process_optimize(self): + """Process the optimize operation.""" + try: + settings = self.get_settings() + + if self.on_process: + self.on_process('optimize', settings) + else: + messagebox.showinfo("Optimize", f"Optimize settings: {settings}") + + except ValueError as e: + messagebox.showerror("Error", str(e)) + except Exception as e: + messagebox.showerror("Error", f"Optimize failed: {e}") + + def start_progress(self): + """Start the progress bar.""" + self.progress_bar.start() + self.process_btn.config(state=tk.DISABLED) + + def stop_progress(self): + """Stop the progress bar.""" + self.progress_bar.stop() + self.process_btn.config(state=tk.NORMAL) + + def get_widget(self) -> tk.Widget: + """Get the main widget for this panel.""" + return self.frame diff --git a/desktop_app/gui/tool_panels/rearrange_panel.py b/desktop_app/gui/tool_panels/rearrange_panel.py new file mode 100644 index 0000000..861e7a5 --- /dev/null +++ b/desktop_app/gui/tool_panels/rearrange_panel.py @@ -0,0 +1,584 @@ +""" +Rearrange Tool Panel + +GUI panel for the GIF rearrange tool with frame preview and drag-and-drop functionality. +""" + +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from pathlib import Path +from typing import Optional, Callable, Any, List +import threading +from PIL import Image, ImageTk + +from gif_tools.core.rearrange import rearrange_gif_frames + + +class RearrangePanel: + """Panel for GIF rearrange operations with frame preview and drag-and-drop.""" + + def __init__(self, parent: tk.Widget, on_process: Optional[Callable] = None): + """ + Initialize the rearrange panel. + + Args: + parent: Parent widget + on_process: Callback function for processing + """ + self.parent = parent + self.on_process = on_process + self.frames: List[Image.Image] = [] + self.frame_order: List[int] = [] + self.frame_thumbnails: List[ImageTk.PhotoImage] = [] + self.selected_frames: List[int] = [] + self.drag_start_index: Optional[int] = None + self.setup_ui() + + def setup_ui(self): + """Create the rearrange panel UI.""" + # Main frame + self.frame = ttk.LabelFrame(self.parent, text="Rearrange GIF Frames", padding="10") + + # Instructions + instructions = "Load a GIF to see its frames. Drag frames to reorder them. Select multiple frames to move them together." + ttk.Label(self.frame, text=instructions, wraplength=600, justify=tk.LEFT).grid(row=0, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Load GIF button + load_frame = ttk.Frame(self.frame) + load_frame.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=10) + + ttk.Button(load_frame, text="Load GIF", command=self.load_gif).pack(side=tk.LEFT, padx=(0, 10)) + self.file_label = ttk.Label(load_frame, text="No file loaded", foreground="gray") + self.file_label.pack(side=tk.LEFT) + + # Frame preview area + preview_frame = ttk.LabelFrame(self.frame, text="Frame Preview", padding="5") + preview_frame.grid(row=2, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=10) + + # Create canvas with scrollbar for frame preview + canvas_frame = ttk.Frame(preview_frame) + canvas_frame.pack(fill=tk.BOTH, expand=True) + + self.canvas = tk.Canvas(canvas_frame, height=300, bg="white") + self.scrollbar = ttk.Scrollbar(canvas_frame, orient=tk.VERTICAL, command=self.canvas.yview) + self.canvas.configure(yscrollcommand=self.scrollbar.set) + + self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Frame container + self.frame_container = ttk.Frame(self.canvas) + self.canvas_window = self.canvas.create_window(0, 0, anchor=tk.NW, window=self.frame_container) + + # Bind canvas resize to update scroll region + self.canvas.bind('', self.on_canvas_configure) + + # Bind events for drag and drop + self.canvas.bind("", self.on_canvas_click) + self.canvas.bind("", self.on_canvas_drag) + self.canvas.bind("", self.on_canvas_release) + + # Bind mouse wheel for scrolling + self.canvas.bind("", self.on_mousewheel) + self.canvas.bind("", self.on_mousewheel) # Linux scroll up + self.canvas.bind("", self.on_mousewheel) # Linux scroll down + + # Quick selection range + range_frame = ttk.LabelFrame(self.frame, text="Quick Selection Range", padding="5") + range_frame.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + ttk.Label(range_frame, text="From:").pack(side=tk.LEFT) + self.start_range_var = tk.StringVar(value="1") + start_entry = ttk.Entry(range_frame, textvariable=self.start_range_var, width=8) + start_entry.pack(side=tk.LEFT, padx=(5, 10)) + + ttk.Label(range_frame, text="To:").pack(side=tk.LEFT) + self.end_range_var = tk.StringVar(value="100") + end_entry = ttk.Entry(range_frame, textvariable=self.end_range_var, width=8) + end_entry.pack(side=tk.LEFT, padx=(5, 10)) + + ttk.Button(range_frame, text="Select Range", command=self.select_range).pack(side=tk.LEFT, padx=(10, 0)) + + # Drop zone for placing selected frames + drop_frame = ttk.LabelFrame(self.frame, text="Drop Zone - Place Selected Frames", padding="5") + drop_frame.grid(row=4, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Drop zone options + options_frame = ttk.Frame(drop_frame) + options_frame.pack(fill=tk.X, pady=5) + + # Drop at start + self.drop_at_start_btn = ttk.Button(options_frame, text="Drop at Start", + command=lambda: self.drop_frames_at_position("start")) + self.drop_at_start_btn.pack(side=tk.LEFT, padx=(0, 5)) + + # Drop at end + self.drop_at_end_btn = ttk.Button(options_frame, text="Drop at End", + command=lambda: self.drop_frames_at_position("end")) + self.drop_at_end_btn.pack(side=tk.LEFT, padx=(0, 5)) + + # Drop at specific position + position_frame = ttk.Frame(options_frame) + position_frame.pack(side=tk.LEFT, padx=(10, 0)) + + ttk.Label(position_frame, text="Drop at Frame #:").pack(side=tk.LEFT) + self.drop_position_var = tk.StringVar(value="1") + self.drop_position_entry = ttk.Entry(position_frame, textvariable=self.drop_position_var, width=8) + self.drop_position_entry.pack(side=tk.LEFT, padx=(5, 5)) + + self.drop_at_position_btn = ttk.Button(position_frame, text="Drop Here", + command=lambda: self.drop_frames_at_position("specific")) + self.drop_at_position_btn.pack(side=tk.LEFT) + + # Status label + self.drop_status_label = ttk.Label(drop_frame, text="Select frames and choose where to place them", + foreground="gray", font=("Arial", 9, "italic")) + self.drop_status_label.pack(pady=(5, 0)) + + # Control buttons + control_frame = ttk.Frame(self.frame) + control_frame.grid(row=5, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=10) + + ttk.Button(control_frame, text="Select All", command=self.select_all).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(control_frame, text="Clear Selection", command=self.clear_selection).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(control_frame, text="Reset Order", command=self.reset_order).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(control_frame, text="Duplicate Selected", command=self.duplicate_selected).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(control_frame, text="Remove Selected", command=self.remove_selected).pack(side=tk.LEFT, padx=(0, 5)) + + # Quality controls + quality_frame = ttk.Frame(self.frame) + quality_frame.grid(row=6, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=10) + + ttk.Label(quality_frame, text="Quality:").pack(side=tk.LEFT) + self.quality_var = tk.IntVar(value=85) + quality_scale = ttk.Scale( + quality_frame, + from_=1, + to=100, + variable=self.quality_var, + orient=tk.HORIZONTAL, + length=200 + ) + quality_scale.pack(side=tk.LEFT, padx=(5, 0)) + + self.quality_label = ttk.Label(quality_frame, text="85") + self.quality_label.pack(side=tk.LEFT, padx=(5, 0)) + + quality_scale.configure(command=self.update_quality_label) + + # Process button + self.process_btn = ttk.Button( + self.frame, + text="Rearrange GIF", + command=self.process_rearrange, + state=tk.DISABLED + ) + self.process_btn.grid(row=7, column=0, columnspan=3, pady=10) + + # Progress bar + self.progress_var = tk.DoubleVar() + self.progress_bar = ttk.Progressbar( + self.frame, + variable=self.progress_var, + mode='indeterminate' + ) + self.progress_bar.grid(row=8, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Configure grid weights + self.frame.grid_columnconfigure(0, weight=1) + self.frame.grid_rowconfigure(2, weight=1) + + def load_gif(self, file_path=None): + """Load a GIF file and extract its frames.""" + if file_path is None: + file_path = filedialog.askopenfilename( + title="Select GIF File", + filetypes=[("GIF files", "*.gif"), ("All files", "*.*")] + ) + + if not file_path: + return + + try: + # Load GIF and extract frames + gif = Image.open(file_path) + self.frames = [] + self.frame_order = [] + + # Extract all frames + frame_index = 0 + while True: + try: + gif.seek(frame_index) + frame = gif.copy() + self.frames.append(frame) + self.frame_order.append(frame_index) + frame_index += 1 + except EOFError: + break + + if not self.frames: + messagebox.showerror("Error", "Could not extract frames from GIF!") + return + + # Create thumbnails + self.create_thumbnails() + + # Update UI + self.file_label.config(text=f"Loaded: {Path(file_path).name} ({len(self.frames)} frames)") + self.process_btn.config(state=tk.NORMAL) + + # Display frames + self.display_frames() + + except Exception as e: + messagebox.showerror("Error", f"Failed to load GIF: {e}") + + def create_thumbnails(self): + """Create thumbnails for all frames.""" + self.frame_thumbnails = [] + + for frame in self.frames: + # Resize frame to thumbnail size + thumbnail = frame.copy() + thumbnail.thumbnail((80, 80), Image.Resampling.LANCZOS) + + # Convert to PhotoImage + photo = ImageTk.PhotoImage(thumbnail) + self.frame_thumbnails.append(photo) + + def update_canvas_scroll(self): + """Update the canvas scroll region.""" + self.frame_container.update_idletasks() + self.canvas.configure(scrollregion=self.canvas.bbox("all")) + + def on_canvas_configure(self, event): + """Handle canvas resize to update scroll region.""" + # Update the canvas window size + self.canvas.itemconfig(self.canvas_window, width=event.width) + self.canvas.configure(scrollregion=self.canvas.bbox("all")) + + # Redraw frames with new column count if we have frames loaded + if self.frames: + self.display_frames() + + def on_mousewheel(self, event): + """Handle mouse wheel scrolling.""" + # Windows and MacOS + if event.delta: + delta = int(-1 * (event.delta / 120)) + # Linux + elif event.num == 4: + delta = -1 + elif event.num == 5: + delta = 1 + else: + return + + self.canvas.yview_scroll(delta, "units") + + def drop_frames_at_position(self, position_type): + """Drop selected frames at specified position.""" + if not self.selected_frames: + self.drop_status_label.config(text="Please select frames first!", foreground="red") + return + + frames_to_move = [self.frame_order[i] for i in self.selected_frames] + + # Remove selected frames from their current positions + for frame_idx in frames_to_move: + self.frame_order.remove(frame_idx) + + if position_type == "start": + # Insert at the beginning + for frame_idx in reversed(frames_to_move): + self.frame_order.insert(0, frame_idx) + self.drop_status_label.config(text=f"Moved {len(frames_to_move)} frames to the start!", foreground="green") + + elif position_type == "end": + # Add to the end + self.frame_order.extend(frames_to_move) + self.drop_status_label.config(text=f"Moved {len(frames_to_move)} frames to the end!", foreground="green") + + elif position_type == "specific": + try: + # Get frame number from user input (convert to 0-based index) + frame_num = int(self.drop_position_var.get()) + if frame_num < 1 or frame_num > len(self.frame_order) + 1: + self.drop_status_label.config(text="Invalid frame number! Use 1 to " + str(len(self.frame_order) + 1), foreground="red") + return + + # Insert at specified position (convert to 0-based index) + insert_pos = frame_num - 1 + for frame_idx in reversed(frames_to_move): + self.frame_order.insert(insert_pos, frame_idx) + + self.drop_status_label.config(text=f"Moved {len(frames_to_move)} frames to position {frame_num}!", foreground="green") + + except ValueError: + self.drop_status_label.config(text="Please enter a valid frame number!", foreground="red") + return + + # Update display + self.display_frames() + self.selected_frames = [] + + # Reset status after 3 seconds + self.parent.after(3000, lambda: self.drop_status_label.config(text="Select frames and choose where to place them", foreground="gray")) + + def display_frames(self): + """Display all frames in the canvas.""" + # Clear existing frames + for widget in self.frame_container.winfo_children(): + widget.destroy() + + # Calculate dynamic columns based on canvas width + canvas_width = self.canvas.winfo_width() + if canvas_width <= 0: + canvas_width = 800 # Default width if not yet rendered + + # Calculate frames per row dynamically + frame_width = 90 # Approximate frame width including padding + frames_per_row = max(1, canvas_width // frame_width) + + # Create frame labels in vertical layout + for i, (frame_idx, thumbnail) in enumerate(zip(self.frame_order, self.frame_thumbnails)): + row = i // frames_per_row + col = i % frames_per_row + + frame_widget = ttk.Frame(self.frame_container, relief=tk.RAISED, borderwidth=1) + frame_widget.grid(row=row, column=col, padx=2, pady=2) + + # Frame number label with grid index and frame number + frame_label = ttk.Label(frame_widget, text=f"#{i+1} Frame {frame_idx}") + frame_label.pack() + + # Thumbnail label + thumb_label = ttk.Label(frame_widget, image=thumbnail) + thumb_label.pack() + + # Store reference to frame widget + frame_widget.frame_index = frame_idx + frame_widget.grid_index = i + + # Bind click events + frame_widget.bind("", lambda e, idx=i: self.on_frame_click(e, idx)) + frame_label.bind("", lambda e, idx=i: self.on_frame_click(e, idx)) + thumb_label.bind("", lambda e, idx=i: self.on_frame_click(e, idx)) + + self.update_canvas_scroll() + + def on_frame_click(self, event, grid_index): + """Handle frame click for selection.""" + if tk.EventType.ButtonPress: + if event.state & 0x4: # Ctrl key held + # Toggle selection + if grid_index in self.selected_frames: + self.selected_frames.remove(grid_index) + else: + self.selected_frames.append(grid_index) + else: + # Select only this frame + self.selected_frames = [grid_index] + + self.update_frame_display() + + def on_canvas_click(self, event): + """Handle canvas click.""" + # Find which frame was clicked + item = self.canvas.find_closest(event.x, event.y)[0] + if item == self.canvas_window: + return + + # Get the frame widget + x = self.canvas.canvasx(event.x) + y = self.canvas.canvasy(event.y) + + # Calculate dynamic frames per row + canvas_width = self.canvas.winfo_width() + if canvas_width <= 0: + canvas_width = 800 + frame_width = 90 + frames_per_row = max(1, canvas_width // frame_width) + frame_height = 120 # Approximate frame height + + col = int(x // frame_width) + row = int(y // frame_height) + + if 0 <= col < frames_per_row and 0 <= row: + frame_index = row * frames_per_row + col + if frame_index < len(self.frame_order): + self.drag_start_index = frame_index + + def on_canvas_drag(self, event): + """Handle canvas drag.""" + if self.drag_start_index is not None: + # Visual feedback during drag + pass + + def on_canvas_release(self, event): + """Handle canvas release (drop).""" + if self.drag_start_index is not None: + # Find drop position with dynamic columns + x = self.canvas.canvasx(event.x) + y = self.canvas.canvasy(event.y) + + # Calculate dynamic frames per row + canvas_width = self.canvas.winfo_width() + if canvas_width <= 0: + canvas_width = 800 + frame_width = 90 + frames_per_row = max(1, canvas_width // frame_width) + frame_height = 120 + + col = int(x // frame_width) + row = int(y // frame_height) + + if 0 <= col < frames_per_row and 0 <= row: + drop_index = row * frames_per_row + col + drop_index = min(drop_index, len(self.frame_order)) + + # Move frame(s) + if self.selected_frames: + # Move selected frames + frames_to_move = [self.frame_order[i] for i in self.selected_frames] + for frame_idx in frames_to_move: + self.frame_order.remove(frame_idx) + + # Insert at new position + insert_pos = min(drop_index, len(self.frame_order)) + for frame_idx in reversed(frames_to_move): + self.frame_order.insert(insert_pos, frame_idx) + else: + # Move single frame + frame_to_move = self.frame_order[self.drag_start_index] + self.frame_order.pop(self.drag_start_index) + + insert_pos = min(drop_index, len(self.frame_order)) + self.frame_order.insert(insert_pos, frame_to_move) + + # Update display + self.display_frames() + self.selected_frames = [] + + self.drag_start_index = None + + def update_frame_display(self): + """Update the visual display of frames.""" + # Update frame colors based on selection + for i, widget in enumerate(self.frame_container.winfo_children()): + if hasattr(widget, 'grid_index'): + if widget.grid_index in self.selected_frames: + widget.configure(relief=tk.SUNKEN, borderwidth=2) + else: + widget.configure(relief=tk.RAISED, borderwidth=1) + + def select_all(self): + """Select all frames.""" + self.selected_frames = list(range(len(self.frame_order))) + self.update_frame_display() + + def clear_selection(self): + """Clear frame selection.""" + self.selected_frames = [] + self.update_frame_display() + + def select_range(self): + """Select frames in the specified range.""" + try: + start = int(self.start_range_var.get()) - 1 # Convert to 0-based index + end = int(self.end_range_var.get()) - 1 # Convert to 0-based index + + if start < 0 or end >= len(self.frame_order) or start > end: + messagebox.showerror("Error", "Invalid range! Please check your start and end values.") + return + + # Select frames in range + self.selected_frames = list(range(start, end + 1)) + self.update_frame_display() + + except ValueError: + messagebox.showerror("Error", "Please enter valid numbers for start and end range!") + + def reset_order(self): + """Reset frame order to original.""" + self.frame_order = list(range(len(self.frames))) + self.display_frames() + + def duplicate_selected(self): + """Duplicate selected frames.""" + if not self.selected_frames: + messagebox.showwarning("Warning", "Please select frames to duplicate!") + return + + # Add duplicates after selected frames + new_frames = [] + for frame_idx in self.selected_frames: + new_frames.append(self.frame_order[frame_idx]) + + # Insert duplicates + insert_pos = max(self.selected_frames) + 1 + for frame_idx in reversed(new_frames): + self.frame_order.insert(insert_pos, frame_idx) + + self.display_frames() + + def remove_selected(self): + """Remove selected frames.""" + if not self.selected_frames: + messagebox.showwarning("Warning", "Please select frames to remove!") + return + + if len(self.frame_order) - len(self.selected_frames) < 1: + messagebox.showerror("Error", "Cannot remove all frames!") + return + + # Remove selected frames + frames_to_remove = [self.frame_order[i] for i in self.selected_frames] + for frame_idx in frames_to_remove: + self.frame_order.remove(frame_idx) + + self.selected_frames = [] + self.display_frames() + + def update_quality_label(self, value): + """Update the quality label when scale changes.""" + self.quality_label.config(text=str(int(float(value)))) + + def get_settings(self) -> dict: + """Get current rearrange settings.""" + return { + 'frame_order': self.frame_order.copy(), + 'quality': self.quality_var.get() + } + + def process_rearrange(self): + """Process the rearrange operation.""" + try: + if not self.frames: + messagebox.showwarning("Warning", "Please load a GIF first!") + return + + settings = self.get_settings() + + if self.on_process: + self.on_process('rearrange', settings) + else: + messagebox.showinfo("Rearrange", f"Rearrange settings: {settings}") + + except Exception as e: + messagebox.showerror("Error", f"Rearrange failed: {e}") + + def start_progress(self): + """Start the progress bar.""" + self.progress_bar.start() + self.process_btn.config(state=tk.DISABLED) + + def stop_progress(self): + """Stop the progress bar.""" + self.progress_bar.stop() + self.process_btn.config(state=tk.NORMAL) + + def get_widget(self) -> tk.Widget: + """Get the main widget for this panel.""" + return self.frame diff --git a/desktop_app/gui/tool_panels/resize_panel.py b/desktop_app/gui/tool_panels/resize_panel.py index 1d6d56d..870df43 100644 --- a/desktop_app/gui/tool_panels/resize_panel.py +++ b/desktop_app/gui/tool_panels/resize_panel.py @@ -13,7 +13,7 @@ from gif_tools.core.resize import resize_gif -class ResizePanel: +class ResizePanel(ttk.Frame): """Panel for GIF resize operations.""" def __init__(self, parent: tk.Widget, on_process: Optional[Callable] = None): @@ -24,6 +24,7 @@ def __init__(self, parent: tk.Widget, on_process: Optional[Callable] = None): parent: Parent widget on_process: Callback function for processing """ + super().__init__(parent) self.parent = parent self.on_process = on_process self.setup_ui() @@ -31,7 +32,7 @@ def __init__(self, parent: tk.Widget, on_process: Optional[Callable] = None): def setup_ui(self): """Create the resize panel UI.""" # Main frame - self.frame = ttk.LabelFrame(self.parent, text="Resize GIF", padding="10") + self.frame = ttk.LabelFrame(self, text="Resize GIF", padding="10") # Width control ttk.Label(self.frame, text="Width:").grid(row=0, column=0, sticky=tk.W, pady=5) @@ -105,6 +106,9 @@ def setup_ui(self): # Configure grid weights self.frame.grid_columnconfigure(1, weight=1) + + # Pack the frame + self.frame.pack(fill=tk.BOTH, expand=True) def update_quality_label(self, value): """Update the quality label when scale changes.""" diff --git a/desktop_app/gui/tool_panels/reverse_panel.py b/desktop_app/gui/tool_panels/reverse_panel.py new file mode 100644 index 0000000..df0cda3 --- /dev/null +++ b/desktop_app/gui/tool_panels/reverse_panel.py @@ -0,0 +1,216 @@ +""" +Reverse Tool Panel + +GUI panel for the GIF reverse tool with interactive controls. +""" + +import tkinter as tk +from tkinter import ttk, messagebox +from pathlib import Path +from typing import Optional, Callable, Any +import threading + +from gif_tools.core.reverse import reverse_gif + + +class ReversePanel: + """Panel for GIF reverse operations.""" + + def __init__(self, parent: tk.Widget, on_process: Optional[Callable] = None): + """ + Initialize the reverse panel. + + Args: + parent: Parent widget + on_process: Callback function for processing + """ + self.parent = parent + self.on_process = on_process + self.setup_ui() + + def setup_ui(self): + """Create the reverse panel UI.""" + # Main frame + self.frame = ttk.LabelFrame(self.parent, text="Reverse GIF", padding="10") + + # Description + desc_text = "This tool will reverse the order of frames in your GIF, making it play backwards." + ttk.Label(self.frame, text=desc_text, wraplength=400, justify=tk.LEFT).grid(row=0, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Reverse options + ttk.Label(self.frame, text="Reverse Options:").grid(row=1, column=0, sticky=tk.W, pady=10) + + # Reverse mode + self.reverse_mode_var = tk.StringVar(value="simple") + mode_frame = ttk.Frame(self.frame) + mode_frame.grid(row=1, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=10) + + mode_options = [ + ("Simple Reverse", "simple"), + ("Ping-Pong", "ping_pong"), + ("Custom Pattern", "custom"), + ] + + for i, (text, value) in enumerate(mode_options): + btn = ttk.Radiobutton( + mode_frame, + text=text, + variable=self.reverse_mode_var, + value=value, + command=self.update_controls + ) + btn.grid(row=0, column=i, padx=(0, 15), sticky=tk.W) + + # Ping-pong controls + self.ping_pong_frame = ttk.LabelFrame(self.frame, text="Ping-Pong Settings", padding="5") + self.ping_pong_frame.grid(row=2, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + ttk.Label(self.ping_pong_frame, text="Forward Cycles:").grid(row=0, column=0, sticky=tk.W, pady=2) + self.forward_cycles_var = tk.StringVar(value="1") + ttk.Entry(self.ping_pong_frame, textvariable=self.forward_cycles_var, width=10).grid(row=0, column=1, sticky=tk.W, padx=(5, 0), pady=2) + + ttk.Label(self.ping_pong_frame, text="Reverse Cycles:").grid(row=0, column=2, sticky=tk.W, pady=2) + self.reverse_cycles_var = tk.StringVar(value="1") + ttk.Entry(self.ping_pong_frame, textvariable=self.reverse_cycles_var, width=10).grid(row=0, column=3, sticky=tk.W, padx=(5, 0), pady=2) + + # Custom pattern controls + self.custom_frame = ttk.LabelFrame(self.frame, text="Custom Pattern", padding="5") + self.custom_frame.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + ttk.Label(self.custom_frame, text="Pattern (e.g., 0,1,2,1,0):").grid(row=0, column=0, sticky=tk.W, pady=2) + self.pattern_var = tk.StringVar(value="0,1,2,1,0") + ttk.Entry(self.custom_frame, textvariable=self.pattern_var, width=30).grid(row=0, column=1, columnspan=2, sticky=tk.W, padx=(5, 0), pady=2) + + # Quality controls + ttk.Label(self.frame, text="Quality:").grid(row=4, column=0, sticky=tk.W, pady=10) + self.quality_var = tk.IntVar(value=85) + quality_scale = ttk.Scale( + self.frame, + from_=1, + to=100, + variable=self.quality_var, + orient=tk.HORIZONTAL, + length=200 + ) + quality_scale.grid(row=4, column=1, columnspan=2, sticky=tk.W, padx=(5, 0), pady=10) + + # Quality value label + self.quality_label = ttk.Label(self.frame, text="85") + self.quality_label.grid(row=4, column=3, sticky=tk.W, padx=(5, 0), pady=10) + + # Update quality label when scale changes + quality_scale.configure(command=self.update_quality_label) + + # Preserve timing + self.preserve_timing_var = tk.BooleanVar(value=True) + timing_check = ttk.Checkbutton( + self.frame, + text="Preserve original frame timing", + variable=self.preserve_timing_var + ) + timing_check.grid(row=5, column=0, columnspan=3, sticky=tk.W, pady=5) + + # Process button + self.process_btn = ttk.Button( + self.frame, + text="Reverse GIF", + command=self.process_reverse + ) + self.process_btn.grid(row=6, column=0, columnspan=3, pady=10) + + # Progress bar + self.progress_var = tk.DoubleVar() + self.progress_bar = ttk.Progressbar( + self.frame, + variable=self.progress_var, + mode='indeterminate' + ) + self.progress_bar.grid(row=7, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Configure grid weights + self.frame.grid_columnconfigure(1, weight=1) + + # Initialize controls visibility + self.update_controls() + + def update_controls(self): + """Update control visibility based on reverse mode.""" + mode = self.reverse_mode_var.get() + + # Show/hide ping-pong controls + if mode == "ping_pong": + self.ping_pong_frame.grid() + else: + self.ping_pong_frame.grid_remove() + + # Show/hide custom pattern controls + if mode == "custom": + self.custom_frame.grid() + else: + self.custom_frame.grid_remove() + + def update_quality_label(self, value): + """Update the quality label when scale changes.""" + self.quality_label.config(text=str(int(float(value)))) + + def get_settings(self) -> dict: + """Get current reverse settings.""" + try: + mode = self.reverse_mode_var.get() + quality = self.quality_var.get() + + settings = { + 'mode': mode, + 'quality': quality, + 'preserve_timing': self.preserve_timing_var.get() + } + + if mode == "ping_pong": + forward_cycles = int(self.forward_cycles_var.get()) + reverse_cycles = int(self.reverse_cycles_var.get()) + settings.update({ + 'forward_cycles': forward_cycles, + 'reverse_cycles': reverse_cycles + }) + elif mode == "custom": + pattern_text = self.pattern_var.get() + # Parse pattern (e.g., "0,1,2,1,0" -> [0,1,2,1,0]) + try: + pattern = [int(x.strip()) for x in pattern_text.split(',')] + settings['pattern'] = pattern + except ValueError: + raise ValueError("Invalid pattern format. Use comma-separated numbers (e.g., 0,1,2,1,0)") + + except ValueError as e: + raise ValueError(f"Invalid numeric value: {e}") + + return settings + + def process_reverse(self): + """Process the reverse operation.""" + try: + settings = self.get_settings() + + if self.on_process: + self.on_process('reverse', settings) + else: + messagebox.showinfo("Reverse", f"Reverse settings: {settings}") + + except ValueError as e: + messagebox.showerror("Error", str(e)) + except Exception as e: + messagebox.showerror("Error", f"Reverse failed: {e}") + + def start_progress(self): + """Start the progress bar.""" + self.progress_bar.start() + self.process_btn.config(state=tk.DISABLED) + + def stop_progress(self): + """Stop the progress bar.""" + self.progress_bar.stop() + self.process_btn.config(state=tk.NORMAL) + + def get_widget(self) -> tk.Widget: + """Get the main widget for this panel.""" + return self.frame diff --git a/desktop_app/gui/tool_panels/rotate_panel.py b/desktop_app/gui/tool_panels/rotate_panel.py new file mode 100644 index 0000000..b11f37e --- /dev/null +++ b/desktop_app/gui/tool_panels/rotate_panel.py @@ -0,0 +1,208 @@ +""" +Rotate Tool Panel + +GUI panel for the GIF rotate tool with interactive controls. +""" + +import tkinter as tk +from tkinter import ttk, messagebox +from pathlib import Path +from typing import Optional, Callable, Any +import threading + +from gif_tools.core.rotate import rotate_gif + + +class RotatePanel(ttk.Frame): + """Panel for GIF rotate operations.""" + + def __init__(self, parent: tk.Widget, on_process: Optional[Callable] = None): + """ + Initialize the rotate panel. + + Args: + parent: Parent widget + on_process: Callback function for processing + """ + super().__init__(parent) + self.parent = parent + self.on_process = on_process + self.setup_ui() + + def setup_ui(self): + """Create the rotate panel UI.""" + # Main frame + self.frame = ttk.LabelFrame(self, text="Rotate GIF", padding="10") + + # Rotation angle + ttk.Label(self.frame, text="Rotation Angle:").grid(row=0, column=0, sticky=tk.W, pady=5) + + # Angle selection frame + angle_frame = ttk.Frame(self.frame) + angle_frame.grid(row=0, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=5) + + self.angle_var = tk.StringVar(value="90") + angle_buttons = [ + ("90°", "90"), + ("180°", "180"), + ("270°", "270"), + ] + + for i, (text, value) in enumerate(angle_buttons): + btn = ttk.Radiobutton( + angle_frame, + text=text, + variable=self.angle_var, + value=value + ) + btn.pack(side=tk.LEFT, padx=(0, 10)) + + # Custom angle entry + ttk.Label(self.frame, text="Custom Angle:").grid(row=1, column=0, sticky=tk.W, pady=5) + self.custom_angle_var = tk.StringVar(value="90") + custom_entry = ttk.Entry(self.frame, textvariable=self.custom_angle_var, width=10) + custom_entry.grid(row=1, column=1, sticky=tk.W, padx=(5, 0), pady=5) + + # Flip options + ttk.Label(self.frame, text="Flip Options:").grid(row=2, column=0, sticky=tk.W, pady=5) + + flip_frame = ttk.Frame(self.frame) + flip_frame.grid(row=2, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=5) + + self.flip_horizontal_var = tk.BooleanVar(value=False) + self.flip_vertical_var = tk.BooleanVar(value=False) + + ttk.Checkbutton(flip_frame, text="Horizontal", variable=self.flip_horizontal_var).pack(side=tk.LEFT, padx=(0, 10)) + ttk.Checkbutton(flip_frame, text="Vertical", variable=self.flip_vertical_var).pack(side=tk.LEFT) + + # Quality controls + ttk.Label(self.frame, text="Quality:").grid(row=3, column=0, sticky=tk.W, pady=5) + self.quality_var = tk.IntVar(value=85) + quality_scale = ttk.Scale( + self.frame, + from_=1, + to=100, + variable=self.quality_var, + orient=tk.HORIZONTAL, + length=200 + ) + quality_scale.grid(row=3, column=1, columnspan=2, sticky=tk.W, padx=(5, 0), pady=5) + + # Quality value label + self.quality_label = ttk.Label(self.frame, text="85") + self.quality_label.grid(row=3, column=3, sticky=tk.W, padx=(5, 0), pady=5) + + # Update quality label when scale changes + quality_scale.configure(command=self.update_quality_label) + + # Background color + ttk.Label(self.frame, text="Background:").grid(row=4, column=0, sticky=tk.W, pady=5) + self.bg_color_var = tk.StringVar(value="transparent") + bg_combo = ttk.Combobox( + self.frame, + textvariable=self.bg_color_var, + values=["transparent", "white", "black", "red", "green", "blue"], + state="readonly", + width=15 + ) + bg_combo.grid(row=4, column=1, sticky=tk.W, padx=(5, 0), pady=5) + + # Expand to fit + self.expand_var = tk.BooleanVar(value=True) + expand_check = ttk.Checkbutton( + self.frame, + text="Expand to fit rotated content", + variable=self.expand_var + ) + expand_check.grid(row=5, column=0, columnspan=3, sticky=tk.W, pady=5) + + # Process button + self.process_btn = ttk.Button( + self.frame, + text="Rotate GIF", + command=self.process_rotate + ) + self.process_btn.grid(row=6, column=0, columnspan=3, pady=10) + + # Progress bar + self.progress_var = tk.DoubleVar() + self.progress_bar = ttk.Progressbar( + self.frame, + variable=self.progress_var, + mode='indeterminate' + ) + self.progress_bar.grid(row=7, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Configure grid weights + self.frame.grid_columnconfigure(1, weight=1) + + # Pack the frame + self.frame.pack(fill=tk.BOTH, expand=True) + + def update_quality_label(self, value): + """Update the quality label when scale changes.""" + self.quality_label.config(text=str(int(float(value)))) + + def get_settings(self) -> dict: + """Get current rotate settings.""" + try: + # Get rotation angle + if self.angle_var.get() in ["90", "180", "270"]: + angle = int(self.angle_var.get()) + else: + angle = int(self.custom_angle_var.get()) + + quality = self.quality_var.get() + + except ValueError as e: + raise ValueError(f"Invalid angle value: {e}") + + settings = { + 'angle': angle, + 'quality': quality, + 'expand': self.expand_var.get() + } + + # Add flip settings + if self.flip_horizontal_var.get() or self.flip_vertical_var.get(): + settings['flip'] = [] + if self.flip_horizontal_var.get(): + settings['flip'].append('horizontal') + if self.flip_vertical_var.get(): + settings['flip'].append('vertical') + + # Add background color + bg_color = self.bg_color_var.get() + if bg_color != "transparent": + settings['background_color'] = bg_color + + return settings + + def process_rotate(self): + """Process the rotate operation.""" + try: + settings = self.get_settings() + + if self.on_process: + self.on_process('rotate', settings) + else: + messagebox.showinfo("Rotate", f"Rotate settings: {settings}") + + except ValueError as e: + messagebox.showerror("Error", str(e)) + except Exception as e: + messagebox.showerror("Error", f"Rotate failed: {e}") + + def start_progress(self): + """Start the progress bar.""" + self.progress_bar.start() + self.process_btn.config(state=tk.DISABLED) + + def stop_progress(self): + """Stop the progress bar.""" + self.progress_bar.stop() + self.process_btn.config(state=tk.NORMAL) + + def get_widget(self) -> tk.Widget: + """Get the main widget for this panel.""" + return self.frame diff --git a/desktop_app/gui/tool_panels/split_panel.py b/desktop_app/gui/tool_panels/split_panel.py new file mode 100644 index 0000000..1b3bb08 --- /dev/null +++ b/desktop_app/gui/tool_panels/split_panel.py @@ -0,0 +1,242 @@ +""" +Split Tool Panel + +GUI panel for the GIF split tool with interactive controls. +""" + +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from pathlib import Path +from typing import Optional, Callable, Any +import threading + +from gif_tools.core.split import split_gif + + +class SplitPanel: + """Panel for GIF split operations.""" + + def __init__(self, parent: tk.Widget, on_process: Optional[Callable] = None): + """ + Initialize the split panel. + + Args: + parent: Parent widget + on_process: Callback function for processing + """ + self.parent = parent + self.on_process = on_process + self.setup_ui() + + def setup_ui(self): + """Create the split panel UI.""" + # Main frame + self.frame = ttk.LabelFrame(self.parent, text="Split GIF into Frames", padding="10") + + # Split options + ttk.Label(self.frame, text="Split Options:").grid(row=0, column=0, sticky=tk.W, pady=5) + + # Split mode + self.split_mode_var = tk.StringVar(value="all") + mode_frame = ttk.Frame(self.frame) + mode_frame.grid(row=0, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=5) + + mode_options = [ + ("All Frames", "all"), + ("Range", "range"), + ("Every Nth", "every_nth"), + ("Key Frames", "key_frames"), + ] + + for i, (text, value) in enumerate(mode_options): + btn = ttk.Radiobutton( + mode_frame, + text=text, + variable=self.split_mode_var, + value=value, + command=self.update_controls + ) + btn.grid(row=0, column=i, padx=(0, 10), sticky=tk.W) + + # Range controls + self.range_frame = ttk.LabelFrame(self.frame, text="Frame Range", padding="5") + self.range_frame.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + ttk.Label(self.range_frame, text="Start Frame:").grid(row=0, column=0, sticky=tk.W, pady=2) + self.start_frame_var = tk.StringVar(value="0") + ttk.Entry(self.range_frame, textvariable=self.start_frame_var, width=10).grid(row=0, column=1, sticky=tk.W, padx=(5, 0), pady=2) + + ttk.Label(self.range_frame, text="End Frame:").grid(row=0, column=2, sticky=tk.W, pady=2) + self.end_frame_var = tk.StringVar(value="10") + ttk.Entry(self.range_frame, textvariable=self.end_frame_var, width=10).grid(row=0, column=3, sticky=tk.W, padx=(5, 0), pady=2) + + # Every Nth controls + self.nth_frame = ttk.LabelFrame(self.frame, text="Every Nth Frame", padding="5") + self.nth_frame.grid(row=2, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + ttk.Label(self.nth_frame, text="N (every Nth frame):").grid(row=0, column=0, sticky=tk.W, pady=2) + self.nth_var = tk.StringVar(value="2") + ttk.Entry(self.nth_frame, textvariable=self.nth_var, width=10).grid(row=0, column=1, sticky=tk.W, padx=(5, 0), pady=2) + + # Key frames controls + self.key_frame = ttk.LabelFrame(self.frame, text="Key Frames", padding="5") + self.key_frame.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + self.key_method_var = tk.StringVar(value="first_last_middle") + key_combo = ttk.Combobox( + self.key_frame, + textvariable=self.key_method_var, + values=["first_last_middle", "first_last", "middle_only", "custom"], + state="readonly", + width=20 + ) + key_combo.grid(row=0, column=0, sticky=tk.W, pady=2) + + # Output format + ttk.Label(self.frame, text="Output Format:").grid(row=4, column=0, sticky=tk.W, pady=5) + self.output_format_var = tk.StringVar(value="png") + format_combo = ttk.Combobox( + self.frame, + textvariable=self.output_format_var, + values=["png", "jpg", "bmp", "tiff"], + state="readonly", + width=15 + ) + format_combo.grid(row=4, column=1, sticky=tk.W, padx=(5, 0), pady=5) + + # Quality controls + ttk.Label(self.frame, text="Quality:").grid(row=5, column=0, sticky=tk.W, pady=5) + self.quality_var = tk.IntVar(value=95) + quality_scale = ttk.Scale( + self.frame, + from_=1, + to=100, + variable=self.quality_var, + orient=tk.HORIZONTAL, + length=200 + ) + quality_scale.grid(row=5, column=1, columnspan=2, sticky=tk.W, padx=(5, 0), pady=5) + + # Quality value label + self.quality_label = ttk.Label(self.frame, text="95") + self.quality_label.grid(row=5, column=3, sticky=tk.W, padx=(5, 0), pady=5) + + # Update quality label when scale changes + quality_scale.configure(command=self.update_quality_label) + + # Naming pattern + ttk.Label(self.frame, text="Naming Pattern:").grid(row=6, column=0, sticky=tk.W, pady=5) + self.naming_var = tk.StringVar(value="frame_{:04d}") + ttk.Entry(self.frame, textvariable=self.naming_var, width=20).grid(row=6, column=1, columnspan=2, sticky=tk.W, padx=(5, 0), pady=5) + + # Process button + self.process_btn = ttk.Button( + self.frame, + text="Split GIF", + command=self.process_split + ) + self.process_btn.grid(row=7, column=0, columnspan=3, pady=10) + + # Progress bar + self.progress_var = tk.DoubleVar() + self.progress_bar = ttk.Progressbar( + self.frame, + variable=self.progress_var, + mode='indeterminate' + ) + self.progress_bar.grid(row=8, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Configure grid weights + self.frame.grid_columnconfigure(1, weight=1) + + # Initialize controls visibility + self.update_controls() + + def update_controls(self): + """Update control visibility based on split mode.""" + mode = self.split_mode_var.get() + + # Show/hide range controls + if mode == "range": + self.range_frame.grid() + else: + self.range_frame.grid_remove() + + # Show/hide nth controls + if mode == "every_nth": + self.nth_frame.grid() + else: + self.nth_frame.grid_remove() + + # Show/hide key frames controls + if mode == "key_frames": + self.key_frame.grid() + else: + self.key_frame.grid_remove() + + def update_quality_label(self, value): + """Update the quality label when scale changes.""" + self.quality_label.config(text=str(int(float(value)))) + + def get_settings(self) -> dict: + """Get current split settings.""" + try: + mode = self.split_mode_var.get() + quality = self.quality_var.get() + output_format = self.output_format_var.get() + naming_pattern = self.naming_var.get() + + settings = { + 'mode': mode, + 'quality': quality, + 'output_format': output_format, + 'naming_pattern': naming_pattern + } + + if mode == "range": + start_frame = int(self.start_frame_var.get()) + end_frame = int(self.end_frame_var.get()) + settings.update({ + 'start_frame': start_frame, + 'end_frame': end_frame + }) + elif mode == "every_nth": + nth = int(self.nth_var.get()) + settings['nth'] = nth + elif mode == "key_frames": + method = self.key_method_var.get() + settings['key_method'] = method + + except ValueError as e: + raise ValueError(f"Invalid numeric value: {e}") + + return settings + + def process_split(self): + """Process the split operation.""" + try: + settings = self.get_settings() + + if self.on_process: + self.on_process('split', settings) + else: + messagebox.showinfo("Split", f"Split settings: {settings}") + + except ValueError as e: + messagebox.showerror("Error", str(e)) + except Exception as e: + messagebox.showerror("Error", f"Split failed: {e}") + + def start_progress(self): + """Start the progress bar.""" + self.progress_bar.start() + self.process_btn.config(state=tk.DISABLED) + + def stop_progress(self): + """Stop the progress bar.""" + self.progress_bar.stop() + self.process_btn.config(state=tk.NORMAL) + + def get_widget(self) -> tk.Widget: + """Get the main widget for this panel.""" + return self.frame diff --git a/desktop_app/gui/tool_panels/video_to_gif_panel.py b/desktop_app/gui/tool_panels/video_to_gif_panel.py new file mode 100644 index 0000000..56d8793 --- /dev/null +++ b/desktop_app/gui/tool_panels/video_to_gif_panel.py @@ -0,0 +1,362 @@ +""" +Video to GIF tool panel. + +This module provides a GUI panel for converting video files to animated GIFs +with customizable settings for quality, frame rate, duration, and resolution. +""" + +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +from pathlib import Path +from typing import Callable, Optional + +from gif_tools.core import convert_video_to_gif + + +class VideoToGifPanel: + """Video to GIF conversion panel.""" + + def __init__(self, parent: tk.Widget, on_process: Optional[Callable] = None): + """Initialize the video to GIF panel. + + Args: + parent: Parent widget + on_process: Callback function for processing + """ + self.parent = parent + self.on_process = on_process + + # Video file path + self.video_path: Optional[Path] = None + + # Create main frame + self.frame = ttk.Frame(parent) + self._create_widgets() + + def get_widget(self) -> tk.Widget: + """Get the main widget.""" + return self.frame + + def _create_widgets(self): + """Create the panel widgets.""" + # Title + title_label = ttk.Label(self.frame, text="Video to GIF Converter", + font=("Arial", 14, "bold")) + title_label.grid(row=0, column=0, columnspan=3, pady=(0, 10)) + + # Instructions + instructions = ttk.Label(self.frame, + text="Select a video file and configure settings to convert it to an animated GIF.", + font=("Arial", 9), foreground="gray") + instructions.grid(row=1, column=0, columnspan=3, pady=(0, 15)) + + # Video file selection + file_frame = ttk.LabelFrame(self.frame, text="Video File", padding="10") + file_frame.grid(row=2, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10)) + + self.file_var = tk.StringVar() + file_entry = ttk.Entry(file_frame, textvariable=self.file_var, state="readonly", width=50) + file_entry.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), padx=(0, 10)) + + ttk.Button(file_frame, text="Browse", command=self.browse_video).grid(row=0, column=2) + + # Video info + self.info_label = ttk.Label(file_frame, text="No video selected", + font=("Arial", 9), foreground="gray") + self.info_label.grid(row=1, column=0, columnspan=3, pady=(5, 0)) + + # Settings + settings_frame = ttk.LabelFrame(self.frame, text="Conversion Settings", padding="10") + settings_frame.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10)) + + # FPS setting + ttk.Label(settings_frame, text="FPS:").grid(row=0, column=0, sticky=tk.W, pady=2) + self.fps_var = tk.IntVar(value=10) + fps_spinbox = ttk.Spinbox(settings_frame, from_=1, to=30, textvariable=self.fps_var, width=10) + fps_spinbox.grid(row=0, column=1, sticky=tk.W, padx=(5, 20), pady=2) + + # Duration setting + ttk.Label(settings_frame, text="Duration (seconds):").grid(row=0, column=2, sticky=tk.W, pady=2) + self.duration_var = tk.StringVar(value="") + duration_entry = ttk.Entry(settings_frame, textvariable=self.duration_var, width=10) + duration_entry.grid(row=0, column=3, sticky=tk.W, padx=(5, 0), pady=2) + + ttk.Label(settings_frame, text="(Leave empty for full video)", + font=("Arial", 8), foreground="gray").grid(row=1, column=2, columnspan=2, sticky=tk.W, padx=(5, 0)) + + # Start time setting + ttk.Label(settings_frame, text="Start time (seconds):").grid(row=2, column=0, sticky=tk.W, pady=2) + self.start_time_var = tk.DoubleVar(value=0.0) + start_time_spinbox = ttk.Spinbox(settings_frame, from_=0.0, to=3600.0, increment=0.1, + textvariable=self.start_time_var, width=10) + start_time_spinbox.grid(row=2, column=1, sticky=tk.W, padx=(5, 20), pady=2) + + # Quality setting + ttk.Label(settings_frame, text="Quality:").grid(row=2, column=2, sticky=tk.W, pady=2) + self.quality_var = tk.IntVar(value=85) + quality_scale = ttk.Scale(settings_frame, from_=1, to=100, variable=self.quality_var, + orient=tk.HORIZONTAL, length=150) + quality_scale.grid(row=2, column=3, sticky=tk.W, padx=(5, 0), pady=2) + + self.quality_label = ttk.Label(settings_frame, text="85") + self.quality_label.grid(row=3, column=3, sticky=tk.W, padx=(5, 0)) + + quality_scale.configure(command=self.update_quality_label) + + # Resolution settings + resolution_frame = ttk.Frame(settings_frame) + resolution_frame.grid(row=4, column=0, columnspan=4, sticky=(tk.W, tk.E), pady=(10, 0)) + + ttk.Label(resolution_frame, text="Resolution:").grid(row=0, column=0, sticky=tk.W, pady=2) + + # Width + ttk.Label(resolution_frame, text="Width:").grid(row=0, column=1, sticky=tk.W, padx=(20, 5), pady=2) + self.width_var = tk.StringVar(value="") + width_entry = ttk.Entry(resolution_frame, textvariable=self.width_var, width=8) + width_entry.grid(row=0, column=2, sticky=tk.W, pady=2) + + # Height + ttk.Label(resolution_frame, text="Height:").grid(row=0, column=3, sticky=tk.W, padx=(20, 5), pady=2) + self.height_var = tk.StringVar(value="") + height_entry = ttk.Entry(resolution_frame, textvariable=self.height_var, width=8) + height_entry.grid(row=0, column=4, sticky=tk.W, pady=2) + + ttk.Label(resolution_frame, text="(Leave empty to keep original)", + font=("Arial", 8), foreground="gray").grid(row=1, column=0, columnspan=5, sticky=tk.W, pady=(2, 0)) + + # Advanced settings + advanced_frame = ttk.LabelFrame(self.frame, text="Advanced Settings", padding="10") + advanced_frame.grid(row=4, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10)) + + # Loop count + ttk.Label(advanced_frame, text="Loop count:").grid(row=0, column=0, sticky=tk.W, pady=2) + self.loop_var = tk.IntVar(value=0) + loop_spinbox = ttk.Spinbox(advanced_frame, from_=0, to=100, textvariable=self.loop_var, width=10) + loop_spinbox.grid(row=0, column=1, sticky=tk.W, padx=(5, 20), pady=2) + + ttk.Label(advanced_frame, text="(0 = infinite loop)", + font=("Arial", 8), foreground="gray").grid(row=1, column=0, columnspan=2, sticky=tk.W, padx=(5, 0)) + + # Auto-optimize for large files + self.auto_optimize_var = tk.BooleanVar(value=True) + auto_optimize_check = ttk.Checkbutton(advanced_frame, text="Auto-optimize for large files", + variable=self.auto_optimize_var) + auto_optimize_check.grid(row=2, column=0, columnspan=2, sticky=tk.W, pady=2) + + ttk.Label(advanced_frame, text="(Automatically reduces resolution/quality for files >100MB)", + font=("Arial", 8), foreground="gray").grid(row=3, column=0, columnspan=2, sticky=tk.W, padx=(5, 0)) + + # Optimize checkbox + self.optimize_var = tk.BooleanVar(value=True) + optimize_check = ttk.Checkbutton(advanced_frame, text="Optimize GIF", + variable=self.optimize_var) + optimize_check.grid(row=4, column=0, sticky=tk.W, pady=2) + + # Process button + self.process_btn = ttk.Button( + self.frame, + text="Convert to GIF", + command=self.process_conversion, + state=tk.DISABLED + ) + self.process_btn.grid(row=5, column=0, columnspan=3, pady=10) + + # Progress bar + self.progress_var = tk.DoubleVar() + self.progress_bar = ttk.Progressbar( + self.frame, + variable=self.progress_var, + mode='indeterminate' + ) + self.progress_bar.grid(row=6, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + # Configure grid weights + self.frame.grid_columnconfigure(0, weight=1) + file_frame.grid_columnconfigure(0, weight=1) + settings_frame.grid_columnconfigure(3, weight=1) + + def browse_video(self): + """Browse for video file.""" + file_path = filedialog.askopenfilename( + title="Select Video File", + filetypes=[ + ("Video files", "*.mp4;*.avi;*.mov;*.mkv;*.wmv;*.flv;*.webm"), + ("MP4 files", "*.mp4"), + ("AVI files", "*.avi"), + ("MOV files", "*.mov"), + ("All files", "*.*") + ] + ) + + if file_path: + self.video_path = Path(file_path) + self.file_var.set(str(self.video_path)) + self.info_label.config(text=f"Selected: {self.video_path.name}") + self.process_btn.config(state=tk.NORMAL) + + # Try to get video info + try: + from moviepy import VideoFileClip + with VideoFileClip(str(self.video_path)) as clip: + duration = clip.duration + fps = clip.fps + size = clip.size + self.info_label.config( + text=f"Selected: {self.video_path.name} | " + f"Duration: {duration:.1f}s | FPS: {fps:.1f} | Size: {size[0]}x{size[1]}" + ) + except Exception as e: + self.info_label.config(text=f"Selected: {self.video_path.name} (Could not read video info)") + + def update_quality_label(self, value): + """Update the quality label when scale changes.""" + self.quality_label.config(text=str(int(float(value)))) + + def _calculate_auto_optimization(self, video_path: Path) -> dict: + """Calculate optimal settings for large video files.""" + try: + from moviepy import VideoFileClip + with VideoFileClip(str(video_path)) as clip: + duration = clip.duration + fps = clip.fps + size = clip.size + + # Calculate file size in MB + file_size_mb = video_path.stat().st_size / (1024 * 1024) + + # If file is larger than 100MB, apply auto-optimization + if file_size_mb > 100 and self.auto_optimize_var.get(): + # Calculate optimal settings + target_size_ratio = min(0.5, 100 / file_size_mb) # Target 50% reduction or 100MB + + # Reduce resolution + new_width = max(320, int(size[0] * (target_size_ratio ** 0.5))) + new_height = max(240, int(size[1] * (target_size_ratio ** 0.5))) + + # Reduce FPS for very large files + optimal_fps = min(self.fps_var.get(), max(5, int(fps * 0.7))) + + # Reduce quality + optimal_quality = max(60, int(self.quality_var.get() * 0.8)) + + # Reduce duration for extremely large files + optimal_duration = None + if file_size_mb > 500: # For files > 500MB + optimal_duration = min(30, duration * 0.3) # Max 30 seconds or 30% of original + elif file_size_mb > 200: # For files > 200MB + optimal_duration = min(60, duration * 0.5) # Max 60 seconds or 50% of original + + return { + 'width': new_width, + 'height': new_height, + 'fps': optimal_fps, + 'quality': optimal_quality, + 'duration': optimal_duration, + 'auto_applied': True, + 'original_size_mb': file_size_mb + } + + return {'auto_applied': False, 'original_size_mb': file_size_mb} + + except Exception as e: + return {'auto_applied': False, 'error': str(e)} + + def get_settings(self) -> dict: + """Get current conversion settings.""" + settings = { + 'fps': self.fps_var.get(), + 'start_time': self.start_time_var.get(), + 'quality': self.quality_var.get(), + 'loop_count': self.loop_var.get(), + 'optimize': self.optimize_var.get(), + 'auto_optimize': self.auto_optimize_var.get() + } + + # Duration + duration_text = self.duration_var.get().strip() + if duration_text: + try: + settings['duration'] = float(duration_text) + except ValueError: + settings['duration'] = None + else: + settings['duration'] = None + + # Resolution + width_text = self.width_var.get().strip() + height_text = self.height_var.get().strip() + + if width_text: + try: + settings['width'] = int(width_text) + except ValueError: + settings['width'] = None + else: + settings['width'] = None + + if height_text: + try: + settings['height'] = int(height_text) + except ValueError: + settings['height'] = None + else: + settings['height'] = None + + return settings + + def process_conversion(self): + """Process the video to GIF conversion.""" + try: + if not self.video_path: + messagebox.showwarning("Warning", "Please select a video file first!") + return + + settings = self.get_settings() + + # Apply auto-optimization if enabled + auto_optimization = self._calculate_auto_optimization(self.video_path) + + if auto_optimization.get('auto_applied', False): + # Show optimization dialog + original_size = auto_optimization['original_size_mb'] + new_width = auto_optimization['width'] + new_height = auto_optimization['height'] + new_fps = auto_optimization['fps'] + new_quality = auto_optimization['quality'] + new_duration = auto_optimization.get('duration') + + duration_text = f"Duration: {new_duration:.1f}s" if new_duration else "Duration: Full video" + + result = messagebox.askyesno( + "Auto-Optimization Applied", + f"Your video file is {original_size:.1f}MB (larger than 100MB limit).\n\n" + f"Auto-optimization will be applied:\n" + f"• Resolution: {new_width}x{new_height}\n" + f"• FPS: {new_fps}\n" + f"• Quality: {new_quality}\n" + f"• {duration_text}\n\n" + f"Do you want to continue with these optimized settings?", + icon='question' + ) + + if not result: + return + + # Apply auto-optimization settings + settings.update({ + 'width': new_width, + 'height': new_height, + 'fps': new_fps, + 'quality': new_quality + }) + + if new_duration: + settings['duration'] = new_duration + + if self.on_process: + self.on_process('video_to_gif', settings, str(self.video_path)) + else: + messagebox.showinfo("Video to GIF", f"Conversion settings: {settings}") + + except Exception as e: + messagebox.showerror("Error", f"Conversion failed: {e}") \ No newline at end of file diff --git a/desktop_app/main.py b/desktop_app/main.py index e06ce36..f8f1d06 100644 --- a/desktop_app/main.py +++ b/desktop_app/main.py @@ -31,7 +31,11 @@ change_gif_speed, apply_gif_filter, # Additional tools extract_gif_frames, set_gif_loop_count, convert_gif_format, - process_gif_batch, add_watermark_to_gif + process_gif_batch +) +from desktop_app.gui.tool_panels import ( + RearrangePanel, + VideoToGifPanel ) from gif_tools.utils import validate_animated_file, get_supported_extensions @@ -133,6 +137,10 @@ def create_toolbar(self): # Process button self.process_btn = ttk.Button(toolbar, text="Process", command=self.process_file, state=tk.DISABLED) self.process_btn.pack(side=tk.LEFT, padx=(0, 5)) + + # Stop button + self.stop_btn = ttk.Button(toolbar, text="Stop", command=self.stop_processing, state=tk.DISABLED) + self.stop_btn.pack(side=tk.LEFT, padx=(0, 5)) def create_main_content(self): """Create the main content area with notebook for tools.""" @@ -300,6 +308,7 @@ def start_background_processing(self): """Start the background processing thread.""" def process_worker(): while True: + task = None try: task = self.processing_queue.get(timeout=1) if task is None: @@ -324,11 +333,12 @@ def process_worker(): self.result_queue.put({ 'success': False, 'error': str(e), - 'task_id': task.get('task_id') + 'task_id': task.get('task_id') if task else None }) finally: self.is_processing = False - self.processing_queue.task_done() + if task is not None: + self.processing_queue.task_done() self.processing_thread = threading.Thread(target=process_worker, daemon=True) self.processing_thread.start() @@ -355,14 +365,178 @@ def handle_success(self, result): """Handle successful processing result.""" self.status_var.set("Processing completed successfully!") self.progress_var.set(100) + self.set_buttons_state(True) # Re-enable buttons messagebox.showinfo("Success", "File processed successfully!") def handle_error(self, result): """Handle processing error.""" self.status_var.set("Processing failed!") self.progress_var.set(0) + self.set_buttons_state(True) # Re-enable buttons messagebox.showerror("Error", f"Processing failed: {result['error']}") + def set_buttons_state(self, enabled: bool): + """Enable or disable buttons during processing.""" + state = "normal" if enabled else "disabled" + + # Disable/enable process and stop buttons + if hasattr(self, 'process_btn'): + self.process_btn.config(state=state) + if hasattr(self, 'stop_btn'): + # Stop button is enabled when processing, disabled when not + self.stop_btn.config(state="normal" if not enabled else "disabled") + + # Disable/enable all buttons in the notebook tabs + try: + # Get all frames in the notebook + for tab_id in self.notebook.tabs(): + frame = self.notebook.nametowidget(tab_id) + # Find all buttons in this frame and its children + self._disable_buttons_in_widget(frame, state) + except Exception: + # If there's an error accessing notebook, continue + pass + + def _disable_buttons_in_widget(self, widget, state): + """Recursively disable/enable buttons in a widget and its children.""" + try: + if isinstance(widget, ttk.Button): + widget.config(state=state) + elif hasattr(widget, 'winfo_children'): + for child in widget.winfo_children(): + self._disable_buttons_in_widget(child, state) + except Exception: + # If there's an error with a widget, continue + pass + + def stop_processing(self): + """Stop current processing operation.""" + if self.is_processing: + self.is_processing = False + self.status_var.set("Stopping processing...") + # Clear the processing queue + while not self.processing_queue.empty(): + try: + self.processing_queue.get_nowait() + except queue.Empty: + break + self.set_buttons_state(True) + self.status_var.set("Processing stopped.") + self.progress_var.set(0) + + def process_tool(self, tool_name: str, settings: dict, input_file: Optional[str] = None): + """Process a tool operation.""" + if not self.current_file and not input_file: + messagebox.showwarning("Warning", "No file loaded!") + return + + input_path = input_file or self.current_file + if not input_path: + messagebox.showwarning("Warning", "No input file specified!") + return + + # Get output path + if not self.output_dir_var.get(): + messagebox.showwarning("Warning", "Please select an output directory!") + return + + output_dir = Path(self.output_dir_var.get()) + output_dir.mkdir(parents=True, exist_ok=True) + + # Generate output filename + input_file_path = Path(input_path) + + # Special handling for video to GIF - output should be .gif + if tool_name == 'video_to_gif': + output_filename = f"{input_file_path.stem}_{tool_name}.gif" + else: + output_filename = f"{input_file_path.stem}_{tool_name}{input_file_path.suffix}" + + output_path = output_dir / output_filename + + # Add to processing queue + task = { + 'function': self._execute_tool, + 'args': (tool_name, str(input_path), str(output_path), settings), + 'kwargs': {}, + 'task_id': f"{tool_name}_{int(time.time())}" + } + + self.processing_queue.put(task) + self.status_var.set(f"Processing {tool_name}...") + self.progress_var.set(0) + + # Disable buttons during processing + self.set_buttons_state(False) + + def _execute_tool(self, tool_name: str, input_path: str, output_path: str, settings: dict): + """Execute a specific tool.""" + try: + # Create progress callback + def progress_callback(progress: int, message: str): + self.root.after(0, lambda: self._update_progress(progress, message)) + + if tool_name == 'rearrange': + return rearrange_gif_frames(input_path, output_path, + frame_order=settings['frame_order'], + quality=settings.get('quality', 85), + progress_callback=progress_callback) + elif tool_name == 'video_to_gif': + return convert_video_to_gif( + video_path=input_path, + output_path=output_path, + fps=settings.get('fps', 10), + duration=settings.get('duration'), + start_time=settings.get('start_time', 0.0), + quality=settings.get('quality', 85), + width=settings.get('width'), + height=settings.get('height'), + optimize=settings.get('optimize', True), + loop_count=settings.get('loop_count', 0), + progress_callback=progress_callback + ) + elif tool_name == 'resize': + return resize_gif( + input_path=input_path, + output_path=output_path, + width=settings.get('width'), + height=settings.get('height'), + size=settings.get('size'), + maintain_aspect_ratio=settings.get('maintain_aspect_ratio', True), + resample=settings.get('resample', 1), # LANCZOS + quality=settings.get('quality', 85), + progress_callback=progress_callback + ) + elif tool_name == 'rotate': + return rotate_gif( + input_path=input_path, + output_path=output_path, + angle=settings.get('angle', 90), + quality=settings.get('quality', 85), + progress_callback=progress_callback + ) + elif tool_name == 'crop': + return crop_gif( + input_path=input_path, + output_path=output_path, + x=settings.get('x', 0), + y=settings.get('y', 0), + width=settings.get('width', 100), + height=settings.get('height', 100), + quality=settings.get('quality', 85), + progress_callback=progress_callback + ) + else: + raise ValueError(f"Unknown tool: {tool_name}") + + except Exception as e: + raise Exception(f"Tool execution failed: {e}") + + def _update_progress(self, progress: int, message: str): + """Update progress bar and status message.""" + self.progress_var.set(progress) + self.status_var.set(message) + # File operations def open_file(self): """Open a GIF file.""" @@ -409,22 +583,64 @@ def browse_output_dir(self): self.output_dir_var.set(dir_path) self.output_dir = Path(dir_path) - # Tool dialog methods (placeholders for now) + # Tool dialog methods def open_video_to_gif_dialog(self): """Open video to GIF conversion dialog.""" - messagebox.showinfo("Video to GIF", "Video to GIF tool - Coming soon!") + self._open_tool_dialog("Video to GIF Converter", VideoToGifPanel) def open_resize_dialog(self): """Open resize dialog.""" - messagebox.showinfo("Resize", "Resize tool - Coming soon!") + from desktop_app.gui.tool_panels.resize_panel import ResizePanel + + dialog = tk.Toplevel(self.root) + dialog.title("GIF Resize Tool") + dialog.geometry("600x500") + dialog.resizable(True, True) + dialog.minsize(600, 500) + + # Center the dialog + dialog.transient(self.root) + dialog.grab_set() + + # Create resize panel + resize_panel = ResizePanel(dialog, self.process_tool) + resize_panel.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) def open_rotate_dialog(self): """Open rotate dialog.""" - messagebox.showinfo("Rotate", "Rotate tool - Coming soon!") + from desktop_app.gui.tool_panels.rotate_panel import RotatePanel + + dialog = tk.Toplevel(self.root) + dialog.title("GIF Rotate Tool") + dialog.geometry("600x500") + dialog.resizable(True, True) + dialog.minsize(600, 500) + + # Center the dialog + dialog.transient(self.root) + dialog.grab_set() + + # Create rotate panel + rotate_panel = RotatePanel(dialog, self.process_tool) + rotate_panel.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) def open_crop_dialog(self): """Open crop dialog.""" - messagebox.showinfo("Crop", "Crop tool - Coming soon!") + from desktop_app.gui.tool_panels.crop_panel import CropPanel + + dialog = tk.Toplevel(self.root) + dialog.title("GIF Crop Tool") + dialog.geometry("800x700") + dialog.resizable(True, True) + dialog.minsize(800, 700) + + # Center the dialog + dialog.transient(self.root) + dialog.grab_set() + + # Create crop panel with current file + crop_panel = CropPanel(dialog, self.process_tool, self.current_file) + crop_panel.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) def open_split_dialog(self): """Open split dialog.""" @@ -434,14 +650,6 @@ def open_merge_dialog(self): """Open merge dialog.""" messagebox.showinfo("Merge", "Merge tool - Coming soon!") - def open_add_text_dialog(self): - """Open add text dialog.""" - messagebox.showinfo("Add Text", "Add Text tool - Coming soon!") - - def open_rearrange_dialog(self): - """Open rearrange dialog.""" - messagebox.showinfo("Rearrange", "Rearrange tool - Coming soon!") - def open_reverse_dialog(self): """Open reverse dialog.""" messagebox.showinfo("Reverse", "Reverse tool - Coming soon!") @@ -450,6 +658,41 @@ def open_optimize_dialog(self): """Open optimize dialog.""" messagebox.showinfo("Optimize", "Optimize tool - Coming soon!") + def _open_tool_dialog(self, title: str, panel_class): + """Open a tool dialog with the specified panel.""" + # Create dialog window + dialog = tk.Toplevel(self.root) + dialog.title(title) + dialog.geometry("800x700") + dialog.resizable(True, True) + dialog.minsize(600, 500) # Set minimum size + + # Create panel + panel = panel_class(dialog, on_process=self.process_tool) + panel.get_widget().pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # If it's the rearrange panel and we have a current file, load it + if hasattr(panel, 'load_gif') and self.current_file: + panel.load_gif(self.current_file) + + # Center the dialog + dialog.transient(self.root) + dialog.grab_set() + + # Center on parent + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() // 2) - (dialog.winfo_width() // 2) + y = (dialog.winfo_screenheight() // 2) - (dialog.winfo_height() // 2) + dialog.geometry(f"+{x}+{y}") + + def open_add_text_dialog(self): + """Open add text dialog.""" + messagebox.showinfo("Add Text", "Add Text tool - Coming soon!") + + def open_rearrange_dialog(self): + """Open rearrange dialog.""" + self._open_tool_dialog("Rearrange GIF Frames", RearrangePanel) + def open_speed_dialog(self): """Open speed control dialog.""" messagebox.showinfo("Speed Control", "Speed Control tool - Coming soon!") diff --git a/gif_tools/core/crop.py b/gif_tools/core/crop.py index ab261d4..7a64cfa 100644 --- a/gif_tools/core/crop.py +++ b/gif_tools/core/crop.py @@ -35,7 +35,8 @@ def crop(self, y: int, width: int, height: int, - quality: int = 85) -> Path: + quality: int = 85, + progress_callback: Optional[callable] = None) -> Path: """ Crop GIF by specified coordinates. @@ -59,19 +60,35 @@ def crop(self, output_path = validate_output_path(output_path) try: + # Progress update: Loading GIF + if progress_callback: + progress_callback(0, "Loading GIF...") + # Load GIF to get dimensions with Image.open(input_path) as gif: # Validate crop coordinates validate_crop_coordinates(x, y, width, height, gif.width, gif.height) + # Progress update: Cropping GIF + if progress_callback: + progress_callback(20, f"Cropping GIF to {width}x{height}...") + # Crop GIF - cropped_gif = self._crop_gif(gif, x, y, width, height) + cropped_gif = self._crop_gif(gif, x, y, width, height, progress_callback) + + # Progress update: Saving GIF + if progress_callback: + progress_callback(80, "Saving cropped GIF...") # Save cropped GIF self.image_processor.save_image( cropped_gif, output_path, quality=quality, optimize=True ) + # Progress update: Complete + if progress_callback: + progress_callback(100, "Crop complete!") + return output_path except Exception as e: @@ -266,7 +283,7 @@ def get_crop_info(self, input_path: Union[str, Path]) -> Dict[str, Any]: raise ValidationError(f"Failed to get crop info: {e}") def _crop_gif(self, gif: Image.Image, x: int, y: int, - width: int, height: int) -> Image.Image: + width: int, height: int, progress_callback: Optional[callable] = None) -> Image.Image: """ Crop animated GIF. @@ -293,6 +310,11 @@ def _crop_gif(self, gif: Image.Image, x: int, y: int, frame_count = getattr(gif, 'n_frames', 1) if hasattr(gif, 'n_frames') else 1 for frame_idx in range(frame_count): + # Progress update: Processing frames + if progress_callback: + progress = 20 + int((frame_idx / frame_count) * 50) # 20-70% + progress_callback(progress, f"Cropping frame {frame_idx+1}/{frame_count}...") + gif.seek(frame_idx) # Crop frame @@ -303,6 +325,10 @@ def _crop_gif(self, gif: Image.Image, x: int, y: int, duration = gif.info.get('duration', 100) # Default 100ms durations.append(duration) + # Progress update: Creating cropped GIF + if progress_callback: + progress_callback(70, "Creating cropped GIF...") + # Create new GIF if frames: new_gif = frames[0].copy() @@ -429,7 +455,8 @@ def crop_gif(input_path: Union[str, Path], y: int, width: int, height: int, - quality: int = 85) -> Path: + quality: int = 85, + progress_callback: Optional[callable] = None) -> Path: """ Crop GIF by specified coordinates. @@ -446,7 +473,7 @@ def crop_gif(input_path: Union[str, Path], Path to output GIF file """ cropper = GifCropper() - return cropper.crop(input_path, output_path, x, y, width, height, quality) + return cropper.crop(input_path, output_path, x, y, width, height, quality, progress_callback) def crop_gif_center(input_path: Union[str, Path], diff --git a/gif_tools/core/rearrange.py b/gif_tools/core/rearrange.py index 5351f87..50ae33c 100644 --- a/gif_tools/core/rearrange.py +++ b/gif_tools/core/rearrange.py @@ -30,7 +30,8 @@ def rearrange_frames(self, input_path: Union[str, Path], output_path: Union[str, Path], frame_order: List[int], - quality: int = 85) -> Path: + quality: int = 85, + progress_callback: Optional[callable] = None) -> Path: """ Rearrange frames in GIF according to specified order. @@ -54,8 +55,16 @@ def rearrange_frames(self, raise ValidationError("Frame order cannot be empty") try: + # Progress update: Loading GIF + if progress_callback: + progress_callback(0, "Loading GIF...") + # Load GIF with Image.open(input_path) as gif: + # Progress update: Analyzing GIF + if progress_callback: + progress_callback(10, "Analyzing GIF...") + # Check if animated if not getattr(gif, 'is_animated', False): raise ValidationError("Cannot rearrange frames in non-animated GIF") @@ -73,14 +82,26 @@ def rearrange_frames(self, if len(set(frame_order)) != len(frame_order): raise ValidationError("Frame order must contain unique indices") + # Progress update: Rearranging frames + if progress_callback: + progress_callback(20, "Rearranging frames...") + # Rearrange frames - rearranged_gif = self._rearrange_frames(gif, frame_order) + rearranged_gif = self._rearrange_frames(gif, frame_order, progress_callback) + + # Progress update: Saving GIF + if progress_callback: + progress_callback(80, "Saving rearranged GIF...") # Save rearranged GIF self.image_processor.save_image( - rearranged_gif, output_path, quality=quality, optimize=True + rearranged_gif, output_path, quality=quality, optimize=False ) + # Progress update: Complete + if progress_callback: + progress_callback(100, "Rearrangement complete!") + return output_path except Exception as e: @@ -135,7 +156,7 @@ def move_frame(self, # Save rearranged GIF self.image_processor.save_image( - rearranged_gif, output_path, quality=quality, optimize=True + rearranged_gif, output_path, quality=quality, optimize=False ) return output_path @@ -203,7 +224,7 @@ def move_frames(self, # Save rearranged GIF self.image_processor.save_image( - rearranged_gif, output_path, quality=quality, optimize=True + rearranged_gif, output_path, quality=quality, optimize=False ) return output_path @@ -263,7 +284,7 @@ def duplicate_frame(self, # Save rearranged GIF self.image_processor.save_image( - rearranged_gif, output_path, quality=quality, optimize=True + rearranged_gif, output_path, quality=quality, optimize=False ) return output_path @@ -320,7 +341,7 @@ def remove_frames(self, # Save rearranged GIF self.image_processor.save_image( - rearranged_gif, output_path, quality=quality, optimize=True + rearranged_gif, output_path, quality=quality, optimize=False ) return output_path @@ -385,7 +406,7 @@ def get_frame_info(self, input_path: Union[str, Path]) -> Dict[str, Any]: except Exception as e: raise ValidationError(f"Failed to get frame info: {e}") - def _rearrange_frames(self, gif: Image.Image, frame_order: List[int]) -> Image.Image: + def _rearrange_frames(self, gif: Image.Image, frame_order: List[int], progress_callback: Optional[callable] = None) -> Image.Image: """ Rearrange frames in GIF. @@ -404,28 +425,59 @@ def _rearrange_frames(self, gif: Image.Image, frame_order: List[int]) -> Image.I frame_count = getattr(gif, 'n_frames', 1) if hasattr(gif, 'n_frames') else 1 # Load frames in new order - for frame_idx in frame_order: + for i, frame_idx in enumerate(frame_order): + # Progress update: Loading frames + if progress_callback: + progress = 20 + int((i / len(frame_order)) * 50) # 20-70% + progress_callback(progress, f"Loading frame {i+1}/{len(frame_order)}...") + gif.seek(frame_idx) - frames.append(gif.copy()) + frame = gif.copy() + frames.append(frame) + + # Get frame duration - try multiple sources + duration = 100 # Default 100ms + if 'duration' in gif.info: + duration = gif.info['duration'] + elif hasattr(gif, 'info') and 'duration' in gif.info: + duration = gif.info['duration'] + elif hasattr(gif, 'duration'): + duration = gif.duration - # Get frame duration - duration = gif.info.get('duration', 100) # Default 100ms durations.append(duration) - # Create new GIF + # Create new GIF with proper frame handling if frames: + # Progress update: Creating new GIF + if progress_callback: + progress_callback(70, "Creating rearranged GIF...") + + # Create a new GIF with the rearranged frames new_gif = frames[0].copy() + + # Save with proper GIF parameters new_gif.save( 'temp_rearrange.gif', save_all=True, append_images=frames[1:], duration=durations, loop=gif.info.get('loop', 0), - optimize=True + optimize=False, # Disable optimization to prevent frame loss + disposal=2, # Restore to background + transparency=0 # No transparency ) - # Load the saved GIF - return Image.open('temp_rearrange.gif') + # Load the saved GIF and return + result_gif = Image.open('temp_rearrange.gif') + + # Clean up temp file + import os + try: + os.remove('temp_rearrange.gif') + except: + pass + + return result_gif else: return gif.copy() @@ -437,7 +489,8 @@ def _rearrange_frames(self, gif: Image.Image, frame_order: List[int]) -> Image.I def rearrange_gif_frames(input_path: Union[str, Path], output_path: Union[str, Path], frame_order: List[int], - quality: int = 85) -> Path: + quality: int = 85, + progress_callback: Optional[callable] = None) -> Path: """ Rearrange frames in GIF according to specified order. @@ -451,7 +504,7 @@ def rearrange_gif_frames(input_path: Union[str, Path], Path to output GIF file """ rearranger = GifRearranger() - return rearranger.rearrange_frames(input_path, output_path, frame_order, quality) + return rearranger.rearrange_frames(input_path, output_path, frame_order, quality, progress_callback) def move_gif_frame(input_path: Union[str, Path], diff --git a/gif_tools/core/resize.py b/gif_tools/core/resize.py index ce885bb..d83944c 100644 --- a/gif_tools/core/resize.py +++ b/gif_tools/core/resize.py @@ -36,7 +36,8 @@ def resize(self, size: Optional[Tuple[int, int]] = None, maintain_aspect_ratio: bool = True, resample: int = Image.Resampling.LANCZOS, - quality: int = 85) -> Path: + quality: int = 85, + progress_callback: Optional[callable] = None) -> Path: """ Resize GIF. @@ -67,8 +68,16 @@ def resize(self, raise ValidationError("Either width, height, or size must be specified") try: + # Progress update: Loading GIF + if progress_callback: + progress_callback(0, "Loading GIF...") + # Load GIF with Image.open(input_path) as gif: + # Progress update: Analyzing GIF + if progress_callback: + progress_callback(10, "Analyzing GIF...") + # Get original dimensions original_width, original_height = gif.size @@ -80,14 +89,26 @@ def resize(self, # Validate new dimensions validate_dimensions(new_width, new_height) + # Progress update: Resizing frames + if progress_callback: + progress_callback(20, f"Resizing from {original_width}x{original_height} to {new_width}x{new_height}...") + # Resize GIF - resized_gif = self._resize_gif(gif, new_width, new_height, resample) + resized_gif = self._resize_gif(gif, new_width, new_height, resample, progress_callback) + + # Progress update: Saving GIF + if progress_callback: + progress_callback(80, "Saving resized GIF...") # Save resized GIF self.image_processor.save_image( resized_gif, output_path, quality=quality, optimize=True ) + # Progress update: Complete + if progress_callback: + progress_callback(100, "Resize complete!") + return output_path except Exception as e: @@ -325,7 +346,7 @@ def _calculate_dimensions(self, original_width: int, original_height: int, return new_width, new_height def _resize_gif(self, gif: Image.Image, width: int, height: int, - resample: int) -> Image.Image: + resample: int, progress_callback: Optional[callable] = None) -> Image.Image: """ Resize animated GIF. @@ -351,6 +372,11 @@ def _resize_gif(self, gif: Image.Image, width: int, height: int, frame_count = getattr(gif, 'n_frames', 1) if hasattr(gif, 'n_frames') else 1 for frame_idx in range(frame_count): + # Progress update: Processing frames + if progress_callback: + progress = 20 + int((frame_idx / frame_count) * 50) # 20-70% + progress_callback(progress, f"Resizing frame {frame_idx+1}/{frame_count}...") + gif.seek(frame_idx) # Resize frame @@ -361,6 +387,10 @@ def _resize_gif(self, gif: Image.Image, width: int, height: int, duration = gif.info.get('duration', 100) # Default 100ms durations.append(duration) + # Progress update: Creating resized GIF + if progress_callback: + progress_callback(70, "Creating resized GIF...") + # Create new GIF if frames: new_gif = frames[0].copy() @@ -449,7 +479,8 @@ def resize_gif(input_path: Union[str, Path], size: Optional[Tuple[int, int]] = None, maintain_aspect_ratio: bool = True, resample: int = Image.Resampling.LANCZOS, - quality: int = 85) -> Path: + quality: int = 85, + progress_callback: Optional[callable] = None) -> Path: """ Resize GIF. @@ -469,7 +500,7 @@ def resize_gif(input_path: Union[str, Path], resizer = GifResizer() return resizer.resize( input_path, output_path, width, height, size, - maintain_aspect_ratio, resample, quality + maintain_aspect_ratio, resample, quality, progress_callback ) diff --git a/gif_tools/core/rotate.py b/gif_tools/core/rotate.py index 1b9e846..e01a629 100644 --- a/gif_tools/core/rotate.py +++ b/gif_tools/core/rotate.py @@ -32,7 +32,8 @@ def rotate(self, input_path: Union[str, Path], output_path: Union[str, Path], angle: int, - quality: int = 85) -> Path: + quality: int = 85, + progress_callback: Optional[callable] = None) -> Path: """ Rotate GIF by specified angle. @@ -54,16 +55,32 @@ def rotate(self, angle = validate_rotation_angle(angle) try: + # Progress update: Loading GIF + if progress_callback: + progress_callback(0, "Loading GIF...") + # Load GIF with Image.open(input_path) as gif: + # Progress update: Rotating GIF + if progress_callback: + progress_callback(20, f"Rotating GIF by {angle}°...") + # Rotate GIF - rotated_gif = self._rotate_gif(gif, angle) + rotated_gif = self._rotate_gif(gif, angle, progress_callback) + + # Progress update: Saving GIF + if progress_callback: + progress_callback(80, "Saving rotated GIF...") # Save rotated GIF self.image_processor.save_image( rotated_gif, output_path, quality=quality, optimize=True ) + # Progress update: Complete + if progress_callback: + progress_callback(100, "Rotation complete!") + return output_path except Exception as e: @@ -218,7 +235,7 @@ def get_rotation_info(self, input_path: Union[str, Path]) -> Dict[str, Any]: except Exception as e: raise ValidationError(f"Failed to get rotation info: {e}") - def _rotate_gif(self, gif: Image.Image, angle: int) -> Image.Image: + def _rotate_gif(self, gif: Image.Image, angle: int, progress_callback: Optional[callable] = None) -> Image.Image: """ Rotate animated GIF. @@ -242,6 +259,11 @@ def _rotate_gif(self, gif: Image.Image, angle: int) -> Image.Image: frame_count = getattr(gif, 'n_frames', 1) if hasattr(gif, 'n_frames') else 1 for frame_idx in range(frame_count): + # Progress update: Processing frames + if progress_callback: + progress = 20 + int((frame_idx / frame_count) * 50) # 20-70% + progress_callback(progress, f"Rotating frame {frame_idx+1}/{frame_count}...") + gif.seek(frame_idx) # Rotate frame @@ -252,6 +274,10 @@ def _rotate_gif(self, gif: Image.Image, angle: int) -> Image.Image: duration = gif.info.get('duration', 100) # Default 100ms durations.append(duration) + # Progress update: Creating rotated GIF + if progress_callback: + progress_callback(70, "Creating rotated GIF...") + # Create new GIF if frames: new_gif = frames[0].copy() @@ -335,7 +361,8 @@ def _flip_gif(self, gif: Image.Image, def rotate_gif(input_path: Union[str, Path], output_path: Union[str, Path], angle: int, - quality: int = 85) -> Path: + quality: int = 85, + progress_callback: Optional[callable] = None) -> Path: """ Rotate GIF by specified angle. @@ -349,7 +376,7 @@ def rotate_gif(input_path: Union[str, Path], Path to output GIF file """ rotator = GifRotator() - return rotator.rotate(input_path, output_path, angle, quality) + return rotator.rotate(input_path, output_path, angle, quality, progress_callback) def rotate_gif_clockwise(input_path: Union[str, Path], diff --git a/gif_tools/core/video_to_gif.py b/gif_tools/core/video_to_gif.py index e8e00b7..2507655 100644 --- a/gif_tools/core/video_to_gif.py +++ b/gif_tools/core/video_to_gif.py @@ -51,7 +51,8 @@ def convert(self, width: Optional[int] = None, height: Optional[int] = None, optimize: bool = True, - loop_count: int = 0) -> Path: + loop_count: int = 0, + progress_callback: Optional[callable] = None) -> Path: """ Convert video to GIF. @@ -86,36 +87,74 @@ def convert(self, raise ValidationError("Start time must be non-negative") try: + # Progress update: Starting + if progress_callback: + progress_callback(0, "Loading video...") + # Load video with VideoFileClip(str(video_path)) as video: + # Progress update: Video loaded + if progress_callback: + progress_callback(10, "Video loaded, analyzing...") + + # Get video properties (using available methods) + try: + video_duration = getattr(video, 'duration', 30.0) # Default 30 seconds + except: + video_duration = 30.0 + + try: + video_fps = getattr(video, 'fps', fps) + except: + video_fps = fps + # Validate video duration - if start_time >= video.duration: + if start_time >= video_duration: raise ValidationError( - f"Start time ({start_time}s) exceeds video duration ({video.duration}s)" + f"Start time ({start_time}s) exceeds video duration ({video_duration}s)" ) # Calculate actual duration if duration is None: - actual_duration = video.duration - start_time + actual_duration = video_duration - start_time else: - actual_duration = min(duration, video.duration - start_time) + actual_duration = min(duration, video_duration - start_time) if actual_duration <= 0: raise ValidationError("Invalid duration after start time") - # Set video segment - if start_time > 0 or actual_duration < video.duration: - video = video.subclip(start_time, start_time + actual_duration) + # Progress update: Processing video + if progress_callback: + progress_callback(20, f"Processing video segment: {actual_duration:.1f}s...") + + # Set video segment - ensure exact duration + if start_time > 0 or actual_duration < video_duration: + video = video.subclipped(start_time, start_time + actual_duration) + # Verify the clipped video duration + if hasattr(video, 'duration'): + clipped_duration = video.duration + if progress_callback: + progress_callback(25, f"Video clipped to {clipped_duration:.1f}s (target: {actual_duration:.1f}s)") # Resize if needed if width or height: + if progress_callback: + progress_callback(30, "Resizing video...") video = self._resize_video(video, width, height) + # Progress update: Converting to GIF + if progress_callback: + progress_callback(40, "Converting to GIF...") + # Convert to GIF output_path = self._convert_to_gif( - video, output_path, fps, quality, optimize, loop_count + video, output_path, fps, quality, optimize, loop_count, actual_duration, progress_callback ) + # Progress update: Complete + if progress_callback: + progress_callback(100, "Conversion complete!") + return output_path except Exception as e: @@ -177,14 +216,20 @@ def get_video_info(self, video_path: Union[str, Path]) -> Dict[str, Any]: try: with VideoFileClip(str(video_path)) as video: + # Get video properties with fallbacks + duration = getattr(video, 'duration', 30.0) + fps = getattr(video, 'fps', 10.0) + width = getattr(video, 'w', 640) + height = getattr(video, 'h', 480) + return { - 'duration': video.duration, - 'fps': video.fps, - 'size': video.size, - 'width': video.w, - 'height': video.h, - 'aspect_ratio': video.w / video.h, - 'has_audio': video.audio is not None, + 'duration': duration, + 'fps': fps, + 'size': (width, height), + 'width': width, + 'height': height, + 'aspect_ratio': width / height if height > 0 else 1.0, + 'has_audio': getattr(video, 'audio', None) is not None, 'file_size': Path(video_path).stat().st_size, 'format': getattr(video, 'filename', '').split('.')[-1].lower() if getattr(video, 'filename', None) else 'unknown' } @@ -205,17 +250,19 @@ def _resize_video(self, video: VideoFileClip, Resized video clip """ if width and height: - return video.resize((width, height)) + return video.resized((width, height)) elif width: - return video.resize(width=width) + return video.resized(width=width) elif height: - return video.resize(height=height) + return video.resized(height=height) else: return video def _convert_to_gif(self, video: VideoFileClip, output_path: Path, fps: int, quality: int, - optimize: bool, loop_count: int) -> Path: + optimize: bool, loop_count: int, + actual_duration: float, + progress_callback: Optional[callable] = None) -> Path: """ Convert video clip to GIF. @@ -226,25 +273,55 @@ def _convert_to_gif(self, video: VideoFileClip, quality: GIF quality optimize: Whether to optimize loop_count: Loop count + actual_duration: Actual duration of the video clip + progress_callback: Optional callback for progress updates Returns: Output file path """ try: - # Write GIF + # Progress update: Writing GIF + if progress_callback: + progress_callback(50, "Writing GIF file...") + + # Calculate exact number of frames for precise duration control + video_duration = getattr(video, 'duration', actual_duration) + total_frames = int(video_duration * fps) + + if progress_callback: + progress_callback(55, f"Writing {total_frames} frames at {fps} FPS...") + + # Write GIF with precise frame control video.write_gif( str(output_path), - fps=fps, - opt='OptimizeTransparency' if optimize else None, - program='ffmpeg', - verbose=False, - logger=None + fps=fps ) + # Progress update: Applying loop settings + if progress_callback: + progress_callback(80, "Applying loop settings...") + # Apply loop count if not infinite if loop_count > 0: self._apply_loop_count(output_path, loop_count) + # Progress update: Finalizing + if progress_callback: + progress_callback(90, "Finalizing...") + + # Verify the final GIF duration + try: + from PIL import Image + with Image.open(output_path) as gif: + if hasattr(gif, 'n_frames') and gif.n_frames > 1: + # Calculate GIF duration from frame count and FPS + gif_duration = gif.n_frames / fps + if progress_callback: + progress_callback(95, f"GIF created: {gif.n_frames} frames, {gif_duration:.1f}s duration") + except Exception: + # If we can't verify, just continue + pass + return output_path except Exception as e: @@ -329,7 +406,8 @@ def convert_video_to_gif(video_path: Union[str, Path], width: Optional[int] = None, height: Optional[int] = None, optimize: bool = True, - loop_count: int = 0) -> Path: + loop_count: int = 0, + progress_callback: Optional[callable] = None) -> Path: """ Convert video to GIF. @@ -351,7 +429,7 @@ def convert_video_to_gif(video_path: Union[str, Path], with VideoToGifConverter() as converter: return converter.convert( video_path, output_path, fps, duration, start_time, - quality, width, height, optimize, loop_count + quality, width, height, optimize, loop_count, progress_callback ) diff --git a/gif_tools/utils/constants.py b/gif_tools/utils/constants.py index 8907954..52e7c61 100644 --- a/gif_tools/utils/constants.py +++ b/gif_tools/utils/constants.py @@ -24,7 +24,7 @@ DEFAULT_ROTATION_ANGLES = [90, 180, 270] # File size limits (in bytes) -MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB +MAX_FILE_SIZE = 500 * 1024 * 1024 # 500MB (increased for modern video files) MAX_FRAME_COUNT = 1000 MAX_DIMENSION = 4096 diff --git a/gif_tools/utils/image_utils.py b/gif_tools/utils/image_utils.py index 507e50d..febcec5 100644 --- a/gif_tools/utils/image_utils.py +++ b/gif_tools/utils/image_utils.py @@ -90,14 +90,31 @@ def save_image(self, image: Image.Image, path = Path(file_path) path.parent.mkdir(parents=True, exist_ok=True) - save_kwargs = { - 'format': format or path.suffix[1:].upper(), - 'quality': quality, - 'optimize': optimize - } - save_kwargs.update(kwargs) - - image.save(path, **save_kwargs) + # Check if it's an animated GIF + is_animated = getattr(image, 'is_animated', False) + file_format = format or path.suffix[1:].upper() + + if is_animated and file_format.upper() == 'GIF': + # For animated GIFs, we need to save with specific parameters + save_kwargs = { + 'format': 'GIF', + 'save_all': True, + 'optimize': optimize, + 'disposal': 2, + 'transparency': 0 + } + save_kwargs.update(kwargs) + image.save(path, **save_kwargs) + else: + # For static images, use regular save + save_kwargs = { + 'format': file_format, + 'quality': quality, + 'optimize': optimize + } + save_kwargs.update(kwargs) + image.save(path, **save_kwargs) + return path except Exception as e: raise ValidationError(f"Failed to save image: {e}")