Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| NASA Solar Image Downloader - Complete GUI Application | |
| Combines downloading, viewing, video creation, and MP4 playback. | |
| """ | |
| import sys | |
| import os | |
| import threading | |
| import subprocess | |
| import shutil | |
| from pathlib import Path | |
| from datetime import datetime, timedelta | |
| # Add src to Python path | |
| sys.path.insert(0, str(Path(__file__).parent / "src")) | |
| try: | |
| import tkinter as tk | |
| from tkinter import ttk, messagebox, filedialog | |
| from PIL import Image, ImageTk | |
| import cv2 | |
| HAS_GUI = True | |
| except ImportError as e: | |
| print(f"โ GUI libraries not available: {e}") | |
| print("๐ก Install with: pip install pillow opencv-python") | |
| HAS_GUI = False | |
| sys.exit(1) | |
| from src.downloader.directory_scraper import DirectoryScraper | |
| from src.storage.storage_organizer import StorageOrganizer | |
| from src.downloader.image_fetcher import ImageFetcher, DownloadManager | |
| class NASADownloaderGUI: | |
| """Complete NASA Solar Image Downloader GUI.""" | |
| def __init__(self): | |
| """Initialize the GUI application.""" | |
| self.root = tk.Tk() | |
| self.root.title("๐ NASA Solar Image Downloader") | |
| self.root.geometry("1200x800") | |
| # Load and set background image | |
| self.setup_background_image() | |
| # Configure ttk styles for modern appearance with transparency | |
| style = ttk.Style() | |
| style.theme_use('clam') | |
| # Configure custom styles with semi-transparent backgrounds | |
| style.configure('Title.TLabel', font=('Arial', 16, 'bold'), foreground='#ecf0f1', background='#2c3e50') | |
| style.configure('Heading.TLabel', font=('Arial', 12, 'bold'), foreground='#34495e') | |
| style.configure('TNotebook', background='rgba(236, 240, 241, 0.95)', borderwidth=0) | |
| style.configure('TNotebook.Tab', padding=[20, 10], font=('Arial', 10, 'bold')) | |
| style.configure('TButton', font=('Arial', 9, 'bold'), padding=(10, 5)) | |
| # Maximize the window | |
| self.root.state('zoomed') # Windows | |
| try: | |
| self.root.attributes('-zoomed', True) # Linux | |
| except: | |
| pass | |
| # Image filter settings (initialize before components) | |
| self.resolution_var = tk.StringVar(value="1024") | |
| self.solar_filter_var = tk.StringVar(value="0211") | |
| # Initialize filter data and buttons dictionary | |
| self.filter_data = { | |
| "0193": {"name": "193 ร ", "desc": "Coronal loops", "color": "#ff6b6b"}, | |
| "0304": {"name": "304 ร ", "desc": "Chromosphere", "color": "#4ecdc4"}, | |
| "0171": {"name": "171 ร ", "desc": "Quiet corona", "color": "#45b7d1"}, | |
| "0211": {"name": "211 ร ", "desc": "Active regions", "color": "#f9ca24"}, | |
| "0131": {"name": "131 ร ", "desc": "Flaring regions", "color": "#f0932b"}, | |
| "0335": {"name": "335 ร ", "desc": "Active cores", "color": "#eb4d4b"}, | |
| "0094": {"name": "94 ร ", "desc": "Hot plasma", "color": "#6c5ce7"}, | |
| "1600": {"name": "1600 ร ", "desc": "Transition region", "color": "#a29bfe"}, | |
| "1700": {"name": "1700 ร ", "desc": "Temperature min", "color": "#fd79a8"}, | |
| "094335193": {"name": "094+335+193", "desc": "Composite: Hot plasma + Active cores + Coronal loops", "color": "#8e44ad"}, | |
| "304211171": {"name": "304+211+171", "desc": "Composite: Chromosphere + Active regions + Quiet corona", "color": "#e67e22"}, | |
| "211193171": {"name": "211+193+171", "desc": "Composite: Active regions + Coronal loops + Quiet corona", "color": "#27ae60"} | |
| } | |
| self.filter_buttons = {} | |
| self._filter_initialized = False | |
| # Initialize components | |
| self.storage = StorageOrganizer("data", | |
| resolution=self.resolution_var.get(), | |
| solar_filter=self.solar_filter_var.get()) | |
| self.scraper = DirectoryScraper(rate_limit_delay=1.0, | |
| resolution=self.resolution_var.get(), | |
| solar_filter=self.solar_filter_var.get()) | |
| self.fetcher = ImageFetcher(rate_limit_delay=1.0) | |
| self.download_manager = DownloadManager(self.fetcher, self.storage) | |
| # GUI state | |
| self.current_images = [] | |
| self.current_image_index = 0 | |
| self.is_playing = False | |
| self.play_thread = None | |
| self.download_thread = None | |
| # Video playback state | |
| self.video_cap = None | |
| self.video_playing = False | |
| self.video_thread = None | |
| self.selected_video_path = None | |
| self.fullscreen_mode = False | |
| self.fullscreen_window = None | |
| self.setup_ui() | |
| self.refresh_available_dates() | |
| def setup_background_image(self): | |
| """Set up the background image for the GUI.""" | |
| try: | |
| # Load the background image | |
| background_path = Path("background_solar.jpg") | |
| if background_path.exists(): | |
| # Load and resize the image to fit the screen | |
| pil_image = Image.open(background_path) | |
| # Get screen dimensions | |
| screen_width = self.root.winfo_screenwidth() | |
| screen_height = self.root.winfo_screenheight() | |
| # Resize image to cover the screen while maintaining aspect ratio | |
| pil_image = pil_image.resize((screen_width, screen_height), Image.Resampling.LANCZOS) | |
| # Apply a stronger semi-transparent overlay to make text more readable | |
| overlay = Image.new('RGBA', (screen_width, screen_height), (0, 0, 0, 150)) # Darker overlay | |
| pil_image = pil_image.convert('RGBA') | |
| pil_image = Image.alpha_composite(pil_image, overlay) | |
| # Convert to PhotoImage | |
| self.background_image = ImageTk.PhotoImage(pil_image) | |
| # Create a label to hold the background image | |
| self.background_label = tk.Label(self.root, image=self.background_image) | |
| self.background_label.place(x=0, y=0, relwidth=1, relheight=1) | |
| # Make sure the background stays behind other widgets | |
| self.background_label.lower() | |
| print("โ Background image loaded successfully") | |
| else: | |
| print("โ ๏ธ Background image not found, using default styling") | |
| self.root.configure(bg="#2c3e50") | |
| except Exception as e: | |
| print(f"โ Error loading background image: {e}") | |
| self.root.configure(bg="#2c3e50") | |
| def setup_ui(self): | |
| """Set up the user interface.""" | |
| # Create notebook for tabs with modern styling and transparency | |
| self.notebook = ttk.Notebook(self.root) | |
| self.notebook.pack(fill=tk.BOTH, expand=True, padx=15, pady=15) | |
| # Configure notebook styling with transparency | |
| style = ttk.Style() | |
| # Create all tab frames first to ensure consistent sizing | |
| self.download_frame = tk.Frame(self.notebook, bg='#1a1a1a', highlightthickness=0) | |
| self.viewer_frame = tk.Frame(self.notebook, bg='#1a1a1a', highlightthickness=0) | |
| self.video_frame = tk.Frame(self.notebook, bg='#1a1a1a', highlightthickness=0) | |
| self.settings_frame = tk.Frame(self.notebook, bg='#1a1a1a', highlightthickness=0) | |
| # Configure frames with semi-transparent dark background | |
| for frame in [self.download_frame, self.viewer_frame, self.video_frame, self.settings_frame]: | |
| frame.configure(bg='#1a1a1a') # Dark semi-transparent background | |
| # Add tabs to notebook | |
| self.notebook.add(self.download_frame, text="๐ฅ Download Images") | |
| self.notebook.add(self.viewer_frame, text="๐๏ธ View Images") | |
| self.notebook.add(self.video_frame, text="๐ฌ Videos") | |
| self.notebook.add(self.settings_frame, text="โ๏ธ Settings") | |
| # Configure all frames to have consistent sizing | |
| for frame in [self.download_frame, self.viewer_frame, self.video_frame, self.settings_frame]: | |
| frame.grid_rowconfigure(0, weight=1) | |
| frame.grid_columnconfigure(0, weight=1) | |
| # Create tab content | |
| self.create_download_tab() | |
| self.create_viewer_tab() | |
| self.create_video_tab() | |
| self.create_settings_tab() | |
| def create_download_tab(self): | |
| """Create the download tab.""" | |
| # Use the pre-created frame | |
| download_frame = self.download_frame | |
| # Create a full-width container that ignores frame padding | |
| title_container = tk.Frame(download_frame, height=150) | |
| title_container.pack(fill=tk.X, padx=0, pady=(0, 20)) | |
| title_container.pack_propagate(False) | |
| # Title with modern styling and background image | |
| title_frame = tk.Frame(title_container, height=150) | |
| title_frame.place(x=0, y=0, relwidth=1.0, relheight=1.0) | |
| # Try to load a background image | |
| try: | |
| # Use the specified background image | |
| bg_image_file = Path("src/ui_img/background.png") | |
| if bg_image_file.exists(): | |
| # Load the background image | |
| pil_bg_image = Image.open(bg_image_file) | |
| # Get the actual available width (full window width minus notebook padding) | |
| # Use a callback to update the image when the window is resized | |
| def update_banner_image(event=None): | |
| try: | |
| # Get the actual width of the title container | |
| actual_width = title_container.winfo_width() | |
| if actual_width <= 1: # Not yet rendered, use default | |
| actual_width = 1200 - 30 # Account for notebook padding | |
| # Set banner height | |
| banner_height = 150 | |
| # Resize to fill the full width | |
| resized_image = pil_bg_image.resize((actual_width, banner_height), Image.Resampling.LANCZOS) | |
| # Apply a dark overlay to make text readable | |
| overlay = Image.new('RGBA', resized_image.size, (0, 0, 0, 150)) # Semi-transparent black | |
| resized_image = resized_image.convert('RGBA') | |
| resized_image = Image.alpha_composite(resized_image, overlay) | |
| bg_photo = ImageTk.PhotoImage(resized_image) | |
| # Update or create background label | |
| if hasattr(title_frame, 'bg_label'): | |
| title_frame.bg_label.config(image=bg_photo) | |
| title_frame.bg_label.image = bg_photo # Keep reference | |
| else: | |
| title_frame.bg_label = tk.Label(title_frame, image=bg_photo) | |
| title_frame.bg_label.image = bg_photo # Keep reference | |
| title_frame.bg_label.place(x=0, y=0, relwidth=1, relheight=1) | |
| except Exception as e: | |
| print(f"Error updating banner image: {e}") | |
| # Initial image setup | |
| update_banner_image() | |
| # Bind to configure event to update when window is resized | |
| title_container.bind('<Configure>', update_banner_image) | |
| # Create title label with dark semi-transparent background | |
| title_label = tk.Label(title_frame, text="๐ NASA Solar Image Downloader", | |
| font=("Arial", 20, "bold"), fg="white", bg="#1a1a1a") | |
| title_label.place(relx=0.5, rely=0.4, anchor=tk.CENTER) | |
| # Add subtitle | |
| subtitle_label = tk.Label(title_frame, text="Explore the Sun's Dynamic Activity", | |
| font=("Arial", 12, "italic"), fg="#f39c12", bg="#1a1a1a") | |
| subtitle_label.place(relx=0.5, rely=0.65, anchor=tk.CENTER) | |
| else: | |
| # Fallback to original design if image not found | |
| title_frame.configure(bg="#3498db") | |
| title_label = tk.Label(title_frame, text="๐ NASA Solar Image Downloader", | |
| font=("Arial", 18, "bold"), fg="white", bg="#3498db") | |
| title_label.pack(expand=True) | |
| except Exception as e: | |
| print(f"Error loading banner background: {e}") | |
| # Fallback to original design | |
| title_frame.configure(bg="#3498db") | |
| title_label = tk.Label(title_frame, text="๐ NASA Solar Image Downloader", | |
| font=("Arial", 18, "bold"), fg="white", bg="#3498db") | |
| title_label.pack(expand=True) | |
| # Image filter settings for download tab (moved before date selection) | |
| download_filter_frame = ttk.LabelFrame(download_frame, text="Image Filters", padding=15) | |
| download_filter_frame.pack(fill=tk.X, padx=20, pady=10) | |
| # Resolution selection | |
| resolution_frame = ttk.Frame(download_filter_frame) | |
| resolution_frame.pack(fill=tk.X, pady=(0, 10)) | |
| ttk.Label(resolution_frame, text="Resolution:", font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=(0, 10)) | |
| resolution_combo = ttk.Combobox(resolution_frame, textvariable=self.resolution_var, | |
| values=["1024", "2048", "4096"], state="readonly", width=10) | |
| resolution_combo.pack(side=tk.LEFT, padx=(0, 20)) | |
| resolution_combo.bind('<<ComboboxSelected>>', self.on_filter_change) | |
| # Solar filter selection with visual preview | |
| filter_label = ttk.Label(download_filter_frame, text="Solar Filter:", font=("Arial", 10, "bold")) | |
| filter_label.pack(anchor=tk.W, pady=(0, 5)) | |
| # Create the visual filter selection UI | |
| self.create_filter_selection_ui(download_filter_frame) | |
| # Date selection frame with modern styling (moved after image filters) | |
| date_frame = ttk.LabelFrame(download_frame, text="Select Date Range", padding=15) | |
| date_frame.pack(fill=tk.X, padx=20, pady=10) | |
| # Quick options with modern buttons | |
| quick_frame = ttk.Frame(date_frame) | |
| quick_frame.pack(fill=tk.X, pady=(0, 15)) | |
| ttk.Button(quick_frame, text="Today", | |
| command=lambda: self.set_date_range(0)).pack(side=tk.LEFT, padx=(0, 10)) | |
| ttk.Button(quick_frame, text="Last 3 Days", | |
| command=lambda: self.set_date_range(2)).pack(side=tk.LEFT, padx=(0, 10)) | |
| ttk.Button(quick_frame, text="Last Week", | |
| command=lambda: self.set_date_range(6)).pack(side=tk.LEFT, padx=(0, 10)) | |
| # Custom date selection with better styling | |
| custom_frame = ttk.Frame(date_frame) | |
| custom_frame.pack(fill=tk.X) | |
| ttk.Label(custom_frame, text="From:", font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=(0, 8)) | |
| self.start_date_var = tk.StringVar(value=datetime.now().strftime("%Y-%m-%d")) | |
| self.start_date_entry = ttk.Entry(custom_frame, textvariable=self.start_date_var, width=12, font=("Arial", 10)) | |
| self.start_date_entry.pack(side=tk.LEFT, padx=(0, 20)) | |
| ttk.Label(custom_frame, text="To:", font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=(0, 8)) | |
| self.end_date_var = tk.StringVar(value=datetime.now().strftime("%Y-%m-%d")) | |
| self.end_date_entry = ttk.Entry(custom_frame, textvariable=self.end_date_var, width=12, font=("Arial", 10)) | |
| self.end_date_entry.pack(side=tk.LEFT, padx=(0, 20)) | |
| # Download button with modern styling | |
| self.download_btn = ttk.Button(date_frame, text="๐ Find & Download Images", | |
| command=self.start_download) | |
| self.download_btn.pack(pady=15) | |
| # Progress frame with modern styling | |
| progress_frame = ttk.LabelFrame(download_frame, text="Download Progress", padding=15) | |
| progress_frame.pack(fill=tk.X, padx=20, pady=10) | |
| self.progress_var = tk.DoubleVar() | |
| self.progress_bar = ttk.Progressbar(progress_frame, variable=self.progress_var, | |
| maximum=100, length=500) | |
| self.progress_bar.pack(pady=(0, 10)) | |
| self.status_label = ttk.Label(progress_frame, text="Ready to download", font=("Arial", 10)) | |
| self.status_label.pack() | |
| # Log frame with modern styling | |
| log_frame = ttk.LabelFrame(download_frame, text="Download Log", padding=15) | |
| log_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=10) | |
| # Create text widget with scrollbar | |
| log_text_frame = ttk.Frame(log_frame) | |
| log_text_frame.pack(fill=tk.BOTH, expand=True) | |
| self.log_text = tk.Text(log_text_frame, height=10, wrap=tk.WORD) | |
| log_scrollbar = ttk.Scrollbar(log_text_frame, orient=tk.VERTICAL, command=self.log_text.yview) | |
| self.log_text.configure(yscrollcommand=log_scrollbar.set) | |
| self.log_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) | |
| log_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) | |
| def create_viewer_tab(self): | |
| """Create the image viewer tab.""" | |
| # Use the pre-created frame | |
| viewer_frame = self.viewer_frame | |
| # Create a scrollable container for all viewer content | |
| # Create canvas and scrollbar for scrolling | |
| viewer_canvas = tk.Canvas(viewer_frame, bg="#f0f0f0") | |
| viewer_v_scrollbar = ttk.Scrollbar(viewer_frame, orient="vertical", command=viewer_canvas.yview) | |
| viewer_scrollable_frame = ttk.Frame(viewer_canvas) | |
| # Configure scrolling | |
| def configure_viewer_scroll_region(event=None): | |
| viewer_canvas.configure(scrollregion=viewer_canvas.bbox("all")) | |
| viewer_scrollable_frame.bind("<Configure>", configure_viewer_scroll_region) | |
| viewer_canvas.create_window((0, 0), window=viewer_scrollable_frame, anchor="nw") | |
| viewer_canvas.configure(yscrollcommand=viewer_v_scrollbar.set) | |
| # Make the scrollable frame expand to full canvas width | |
| def configure_viewer_canvas_width(event): | |
| # Get the canvas width and set the scrollable frame to match | |
| canvas_width = event.width | |
| if viewer_canvas.find_all(): | |
| viewer_canvas.itemconfig(viewer_canvas.find_all()[0], width=canvas_width) | |
| viewer_canvas.bind('<Configure>', configure_viewer_canvas_width) | |
| # Pack canvas and scrollbar to occupy full width | |
| viewer_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) | |
| viewer_v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) | |
| # Enable mouse wheel scrolling | |
| def _on_viewer_mousewheel(event): | |
| viewer_canvas.yview_scroll(int(-1*(event.delta/120)), "units") | |
| # Bind mouse wheel events for different platforms | |
| viewer_canvas.bind("<MouseWheel>", _on_viewer_mousewheel) # Windows | |
| viewer_canvas.bind("<Button-4>", lambda e: viewer_canvas.yview_scroll(-1, "units")) # Linux scroll up | |
| viewer_canvas.bind("<Button-5>", lambda e: viewer_canvas.yview_scroll(1, "units")) # Linux scroll down | |
| # Make canvas focusable and bind focus events | |
| viewer_canvas.focus_set() | |
| viewer_canvas.bind("<Enter>", lambda e: viewer_canvas.focus_set()) | |
| # Also bind to the scrollable frame to catch events | |
| viewer_scrollable_frame.bind("<MouseWheel>", _on_viewer_mousewheel) | |
| viewer_scrollable_frame.bind("<Button-4>", lambda e: viewer_canvas.yview_scroll(-1, "units")) | |
| viewer_scrollable_frame.bind("<Button-5>", lambda e: viewer_canvas.yview_scroll(1, "units")) | |
| # Enable middle button scrolling (same as scroll bar up/down) | |
| def _on_viewer_middle_button_click(event): | |
| viewer_canvas.yview_scroll(-3, "units") # Scroll up like scroll bar | |
| viewer_canvas.bind("<Button-2>", _on_viewer_middle_button_click) | |
| # Now use viewer_scrollable_frame instead of viewer_frame for all content | |
| # Date selection | |
| date_select_frame = ttk.LabelFrame(viewer_scrollable_frame, text="Select Date Range", padding=10) | |
| date_select_frame.pack(fill=tk.X, padx=10, pady=5) | |
| # From date selection | |
| from_frame = ttk.Frame(date_select_frame) | |
| from_frame.pack(side=tk.LEFT, padx=(0, 15)) | |
| ttk.Label(from_frame, text="From:").pack(side=tk.LEFT, padx=(0, 5)) | |
| self.viewer_from_date_var = tk.StringVar() | |
| self.viewer_from_date_combo = ttk.Combobox(from_frame, textvariable=self.viewer_from_date_var, | |
| state="readonly", width=20) | |
| self.viewer_from_date_combo.pack(side=tk.LEFT) | |
| # To date selection | |
| to_frame = ttk.Frame(date_select_frame) | |
| to_frame.pack(side=tk.LEFT, padx=(0, 15)) | |
| ttk.Label(to_frame, text="To:").pack(side=tk.LEFT, padx=(0, 5)) | |
| self.viewer_to_date_var = tk.StringVar() | |
| self.viewer_to_date_combo = ttk.Combobox(to_frame, textvariable=self.viewer_to_date_var, | |
| state="readonly", width=20) | |
| self.viewer_to_date_combo.pack(side=tk.LEFT) | |
| # Control buttons | |
| button_frame = ttk.Frame(date_select_frame) | |
| button_frame.pack(side=tk.LEFT) | |
| ttk.Button(button_frame, text="Load Images", | |
| command=self.load_images_for_viewer).pack(side=tk.LEFT, padx=(0, 5)) | |
| ttk.Button(button_frame, text="Refresh Dates", | |
| command=self.refresh_available_dates).pack(side=tk.LEFT) | |
| # Image filter settings for viewer tab | |
| viewer_filter_frame = ttk.LabelFrame(viewer_scrollable_frame, text="Image Filters", padding=15) | |
| viewer_filter_frame.pack(fill=tk.X, padx=10, pady=5) | |
| # Resolution selection | |
| resolution_frame = ttk.Frame(viewer_filter_frame) | |
| resolution_frame.pack(fill=tk.X, pady=(0, 10)) | |
| ttk.Label(resolution_frame, text="Resolution:", font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=(0, 10)) | |
| resolution_combo = ttk.Combobox(resolution_frame, textvariable=self.resolution_var, | |
| values=["1024", "2048", "4096"], state="readonly", width=10) | |
| resolution_combo.pack(side=tk.LEFT, padx=(0, 20)) | |
| resolution_combo.bind('<<ComboboxSelected>>', self.on_filter_change) | |
| # Solar filter selection with visual preview | |
| filter_label = ttk.Label(viewer_filter_frame, text="Solar Filter:", font=("Arial", 10, "bold")) | |
| filter_label.pack(anchor=tk.W, pady=(0, 5)) | |
| # Create the visual filter selection UI | |
| self.create_filter_selection_ui(viewer_filter_frame) | |
| # Image display | |
| image_display_frame = ttk.LabelFrame(viewer_scrollable_frame, text="Solar Image", padding=10) | |
| image_display_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) | |
| self.image_display_label = tk.Label(image_display_frame, text="No image loaded", | |
| background="black", foreground="white", | |
| justify=tk.CENTER, compound=tk.CENTER) | |
| self.image_display_label.pack(fill=tk.BOTH, expand=True) | |
| # Controls | |
| controls_frame = ttk.LabelFrame(viewer_scrollable_frame, text="Playback Controls", padding=10) | |
| controls_frame.pack(fill=tk.X, padx=10, pady=5) | |
| # Playback buttons | |
| btn_frame = ttk.Frame(controls_frame) | |
| btn_frame.pack(fill=tk.X, pady=(0, 10)) | |
| ttk.Button(btn_frame, text="โฎ First", command=self.first_image).pack(side=tk.LEFT, padx=(0, 5)) | |
| ttk.Button(btn_frame, text="โช Prev", command=self.prev_image).pack(side=tk.LEFT, padx=(0, 5)) | |
| self.play_btn = ttk.Button(btn_frame, text="โถ Play", command=self.toggle_play) | |
| self.play_btn.pack(side=tk.LEFT, padx=(0, 5)) | |
| ttk.Button(btn_frame, text="Next โฉ", command=self.next_image).pack(side=tk.LEFT, padx=(0, 5)) | |
| ttk.Button(btn_frame, text="Last โญ", command=self.last_image).pack(side=tk.LEFT) | |
| # Speed and progress | |
| control_bottom_frame = ttk.Frame(controls_frame) | |
| control_bottom_frame.pack(fill=tk.X) | |
| ttk.Label(control_bottom_frame, text="Speed:").pack(side=tk.LEFT, padx=(0, 5)) | |
| self.speed_var = tk.DoubleVar(value=2.0) | |
| speed_scale = ttk.Scale(control_bottom_frame, from_=0.5, to=120.0, | |
| variable=self.speed_var, orient=tk.HORIZONTAL, length=200, | |
| command=self.update_speed_display) | |
| speed_scale.pack(side=tk.LEFT, padx=(0, 10)) | |
| self.speed_display = ttk.Label(control_bottom_frame, text="2.0 FPS") | |
| self.speed_display.pack(side=tk.LEFT, padx=(0, 20)) | |
| # Image info | |
| self.image_info_label = ttk.Label(control_bottom_frame, text="No images loaded") | |
| self.image_info_label.pack(side=tk.RIGHT) | |
| # Progress bar for images | |
| self.image_progress_var = tk.DoubleVar() | |
| self.image_progress_bar = ttk.Progressbar(controls_frame, variable=self.image_progress_var, | |
| maximum=100) | |
| self.image_progress_bar.pack(fill=tk.X, pady=(10, 0)) | |
| def create_video_tab(self): | |
| """Create the video creation and playback tab.""" | |
| # Use the pre-created frame | |
| video_frame = self.video_frame | |
| # Create a scrollable container for all video content | |
| # Create canvas and scrollbar for scrolling | |
| video_canvas = tk.Canvas(video_frame, bg="#f0f0f0") | |
| video_v_scrollbar = ttk.Scrollbar(video_frame, orient="vertical", command=video_canvas.yview) | |
| video_scrollable_frame = ttk.Frame(video_canvas) | |
| # Configure scrolling | |
| def configure_video_scroll_region(event=None): | |
| video_canvas.configure(scrollregion=video_canvas.bbox("all")) | |
| video_scrollable_frame.bind("<Configure>", configure_video_scroll_region) | |
| video_canvas.create_window((0, 0), window=video_scrollable_frame, anchor="nw") | |
| video_canvas.configure(yscrollcommand=video_v_scrollbar.set) | |
| # Make the scrollable frame expand to full canvas width | |
| def configure_video_canvas_width(event): | |
| # Get the canvas width and set the scrollable frame to match | |
| canvas_width = event.width | |
| if video_canvas.find_all(): | |
| video_canvas.itemconfig(video_canvas.find_all()[0], width=canvas_width) | |
| video_canvas.bind('<Configure>', configure_video_canvas_width) | |
| # Pack canvas and scrollbar to occupy full width | |
| video_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) | |
| video_v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) | |
| # Enable mouse wheel scrolling | |
| def _on_video_mousewheel(event): | |
| video_canvas.yview_scroll(int(-1*(event.delta/120)), "units") | |
| # Bind mouse wheel events for different platforms | |
| video_canvas.bind("<MouseWheel>", _on_video_mousewheel) # Windows | |
| video_canvas.bind("<Button-4>", lambda e: video_canvas.yview_scroll(-1, "units")) # Linux scroll up | |
| video_canvas.bind("<Button-5>", lambda e: video_canvas.yview_scroll(1, "units")) # Linux scroll down | |
| # Make canvas focusable and bind focus events | |
| video_canvas.focus_set() | |
| video_canvas.bind("<Enter>", lambda e: video_canvas.focus_set()) | |
| # Also bind to the scrollable frame to catch events | |
| video_scrollable_frame.bind("<MouseWheel>", _on_video_mousewheel) | |
| video_scrollable_frame.bind("<Button-4>", lambda e: video_canvas.yview_scroll(-1, "units")) | |
| video_scrollable_frame.bind("<Button-5>", lambda e: video_canvas.yview_scroll(1, "units")) | |
| # Enable middle button scrolling (same as scroll bar up/down) | |
| def _on_video_middle_button_click(event): | |
| video_canvas.yview_scroll(-3, "units") # Scroll up like scroll bar | |
| video_canvas.bind("<Button-2>", _on_video_middle_button_click) | |
| # Now use video_scrollable_frame instead of video_frame for all content | |
| # Video creation section | |
| creation_frame = ttk.LabelFrame(video_scrollable_frame, text="Create MP4 Videos", padding=10) | |
| creation_frame.pack(fill=tk.X, padx=10, pady=5) | |
| # Date selection for video | |
| video_date_frame = ttk.LabelFrame(creation_frame, text="Select Date Range", padding=10) | |
| video_date_frame.pack(fill=tk.X, pady=(0, 10)) | |
| # Quick options with modern buttons | |
| video_quick_frame = ttk.Frame(video_date_frame) | |
| video_quick_frame.pack(fill=tk.X, pady=(0, 10)) | |
| ttk.Button(video_quick_frame, text="Today", | |
| command=lambda: self.set_video_date_range(0)).pack(side=tk.LEFT, padx=(0, 10)) | |
| ttk.Button(video_quick_frame, text="Last 3 Days", | |
| command=lambda: self.set_video_date_range(2)).pack(side=tk.LEFT, padx=(0, 10)) | |
| ttk.Button(video_quick_frame, text="Last Week", | |
| command=lambda: self.set_video_date_range(6)).pack(side=tk.LEFT, padx=(0, 10)) | |
| # Custom date selection | |
| video_custom_frame = ttk.Frame(video_date_frame) | |
| video_custom_frame.pack(fill=tk.X, pady=(0, 10)) | |
| ttk.Label(video_custom_frame, text="From:", font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=(0, 8)) | |
| self.video_start_date_var = tk.StringVar(value=datetime.now().strftime("%Y-%m-%d")) | |
| self.video_start_date_entry = ttk.Entry(video_custom_frame, textvariable=self.video_start_date_var, width=12, font=("Arial", 10)) | |
| self.video_start_date_entry.pack(side=tk.LEFT, padx=(0, 20)) | |
| ttk.Label(video_custom_frame, text="To:", font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=(0, 8)) | |
| self.video_end_date_var = tk.StringVar(value=datetime.now().strftime("%Y-%m-%d")) | |
| self.video_end_date_entry = ttk.Entry(video_custom_frame, textvariable=self.video_end_date_var, width=12, font=("Arial", 10)) | |
| self.video_end_date_entry.pack(side=tk.LEFT, padx=(0, 20)) | |
| # FPS setting | |
| ttk.Label(video_custom_frame, text="FPS:", font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=(10, 5)) | |
| self.video_fps_var = tk.IntVar(value=10) | |
| fps_spinbox = ttk.Spinbox(video_custom_frame, from_=1, to=120, width=5, | |
| textvariable=self.video_fps_var) | |
| fps_spinbox.pack(side=tk.LEFT, padx=(0, 10)) | |
| # Video creation buttons | |
| video_btn_frame = ttk.Frame(creation_frame) | |
| video_btn_frame.pack(fill=tk.X) | |
| ttk.Button(video_btn_frame, text="๐ฌ Create Video for Date Range", | |
| command=self.create_date_range_video).pack(side=tk.LEFT, padx=(0, 10)) | |
| ttk.Button(video_btn_frame, text="๐ฌ Create Combined Video (All Available)", | |
| command=self.create_all_videos).pack(side=tk.LEFT) | |
| # Video creation progress | |
| video_progress_frame = ttk.LabelFrame(creation_frame, text="Video Creation Progress", padding=10) | |
| video_progress_frame.pack(fill=tk.X, pady=(10, 0)) | |
| self.video_progress_var = tk.DoubleVar() | |
| self.video_progress_bar = ttk.Progressbar(video_progress_frame, variable=self.video_progress_var, | |
| maximum=100, length=500) | |
| self.video_progress_bar.pack(pady=(0, 10)) | |
| self.video_status_label = ttk.Label(video_progress_frame, text="Ready to create video", font=("Arial", 10)) | |
| self.video_status_label.pack() | |
| # Image filter settings for video tab | |
| video_filter_frame = ttk.LabelFrame(video_scrollable_frame, text="Image Filters", padding=15) | |
| video_filter_frame.pack(fill=tk.X, padx=10, pady=10) | |
| # Resolution selection | |
| video_resolution_frame = ttk.Frame(video_filter_frame) | |
| video_resolution_frame.pack(fill=tk.X, pady=(0, 10)) | |
| ttk.Label(video_resolution_frame, text="Resolution:", font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=(0, 10)) | |
| resolution_combo = ttk.Combobox(video_resolution_frame, textvariable=self.resolution_var, | |
| values=["1024", "2048", "4096"], state="readonly", width=10) | |
| resolution_combo.pack(side=tk.LEFT, padx=(0, 20)) | |
| resolution_combo.bind('<<ComboboxSelected>>', self.on_filter_change) | |
| # Solar filter selection with visual preview | |
| filter_label = ttk.Label(video_filter_frame, text="Solar Filter:", font=("Arial", 10, "bold")) | |
| filter_label.pack(anchor=tk.W, pady=(0, 5)) | |
| # Create the visual filter selection UI | |
| self.create_filter_selection_ui(video_filter_frame) | |
| # Video playback section | |
| playback_frame = ttk.LabelFrame(video_scrollable_frame, text="Play MP4 Videos", padding=10) | |
| playback_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) | |
| # Video file selection | |
| video_select_frame = ttk.Frame(playback_frame) | |
| video_select_frame.pack(fill=tk.X, pady=(0, 10)) | |
| ttk.Button(video_select_frame, text="Select MP4 File", | |
| command=self.select_video_file).pack(side=tk.LEFT, padx=(0, 10)) | |
| self.selected_video_label = ttk.Label(video_select_frame, text="No video selected") | |
| self.selected_video_label.pack(side=tk.LEFT) | |
| # Video display area - Fixed size 1024x1024 pixels | |
| self.video_display_frame = ttk.Frame(playback_frame, relief=tk.SUNKEN, borderwidth=2) | |
| self.video_display_frame.pack(pady=(0, 10)) | |
| self.video_display_frame.pack_propagate(False) # Prevent frame from shrinking | |
| self.video_display_frame.configure(width=1024, height=1024) # Fixed size | |
| self.video_display_label = tk.Label(self.video_display_frame, text="Select a video to play", | |
| background="black", foreground="white", | |
| justify=tk.CENTER, compound=tk.CENTER) | |
| self.video_display_label.pack(fill=tk.BOTH, expand=True) | |
| # Video controls | |
| video_controls_frame = ttk.Frame(playback_frame) | |
| video_controls_frame.pack(fill=tk.X) | |
| self.video_play_btn = ttk.Button(video_controls_frame, text="โถ Play Video", | |
| command=self.play_video, state=tk.DISABLED) | |
| self.video_play_btn.pack(side=tk.LEFT, padx=(0, 5)) | |
| ttk.Button(video_controls_frame, text="โน Stop", | |
| command=self.stop_video).pack(side=tk.LEFT, padx=(0, 5)) | |
| self.fullscreen_btn = ttk.Button(video_controls_frame, text="๐ณ Fullscreen", | |
| command=self.toggle_fullscreen, state=tk.DISABLED) | |
| self.fullscreen_btn.pack(side=tk.LEFT, padx=(0, 5)) | |
| ttk.Button(video_controls_frame, text="๐ Open Video Folder", | |
| command=self.open_video_folder).pack(side=tk.RIGHT) | |
| def create_settings_tab(self): | |
| """Create the settings tab.""" | |
| # Use the pre-created frame | |
| settings_frame = self.settings_frame | |
| # Create a full-width container that ignores notebook padding | |
| full_width_container = tk.Frame(settings_frame, bg="#f0f0f0") | |
| full_width_container.place(x=0, y=0, relwidth=1.0, relheight=1.0) | |
| # Create a scrollable container for all settings content | |
| # Create canvas and scrollbar for scrolling | |
| settings_canvas = tk.Canvas(full_width_container, bg="#f0f0f0") | |
| settings_v_scrollbar = ttk.Scrollbar(full_width_container, orient="vertical", command=settings_canvas.yview) | |
| settings_scrollable_frame = ttk.Frame(settings_canvas) | |
| # Configure scrolling | |
| def configure_scroll_region(event=None): | |
| settings_canvas.configure(scrollregion=settings_canvas.bbox("all")) | |
| settings_scrollable_frame.bind("<Configure>", configure_scroll_region) | |
| settings_canvas.create_window((0, 0), window=settings_scrollable_frame, anchor="nw") | |
| settings_canvas.configure(yscrollcommand=settings_v_scrollbar.set) | |
| # Make the scrollable frame expand to full canvas width | |
| def configure_canvas_width(event): | |
| # Get the canvas width and set the scrollable frame to match | |
| canvas_width = event.width | |
| if settings_canvas.find_all(): | |
| settings_canvas.itemconfig(settings_canvas.find_all()[0], width=canvas_width) | |
| settings_canvas.bind('<Configure>', configure_canvas_width) | |
| # Pack canvas and scrollbar to occupy full width | |
| settings_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) | |
| settings_v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) | |
| # Enable mouse wheel scrolling | |
| def _on_mousewheel(event): | |
| settings_canvas.yview_scroll(int(-1*(event.delta/120)), "units") | |
| # Bind mouse wheel events for different platforms | |
| settings_canvas.bind("<MouseWheel>", _on_mousewheel) # Windows | |
| settings_canvas.bind("<Button-4>", lambda e: settings_canvas.yview_scroll(-1, "units")) # Linux scroll up | |
| settings_canvas.bind("<Button-5>", lambda e: settings_canvas.yview_scroll(1, "units")) # Linux scroll down | |
| # Make canvas focusable and bind focus events | |
| settings_canvas.focus_set() | |
| settings_canvas.bind("<Enter>", lambda e: settings_canvas.focus_set()) | |
| # Also bind to the scrollable frame to catch events | |
| settings_scrollable_frame.bind("<MouseWheel>", _on_mousewheel) | |
| settings_scrollable_frame.bind("<Button-4>", lambda e: settings_canvas.yview_scroll(-1, "units")) | |
| settings_scrollable_frame.bind("<Button-5>", lambda e: settings_canvas.yview_scroll(1, "units")) | |
| # Enable middle button scrolling (same as scroll bar up/down) | |
| def _on_settings_middle_button_click(event): | |
| settings_canvas.yview_scroll(-3, "units") # Scroll up like scroll bar | |
| settings_canvas.bind("<Button-2>", _on_settings_middle_button_click) | |
| # Now use settings_scrollable_frame instead of settings_frame for all content | |
| # Download settings | |
| download_settings_frame = ttk.LabelFrame(settings_scrollable_frame, text="Download Settings", padding=10) | |
| download_settings_frame.pack(fill=tk.X, pady=5) | |
| ttk.Label(download_settings_frame, text="Rate Limit Delay (seconds):").pack(anchor=tk.W) | |
| self.rate_limit_var = tk.DoubleVar(value=1.0) | |
| rate_limit_scale = ttk.Scale(download_settings_frame, from_=0.5, to=5.0, | |
| variable=self.rate_limit_var, orient=tk.HORIZONTAL) | |
| rate_limit_scale.pack(fill=tk.X, pady=(0, 10)) | |
| # Image filter settings | |
| filter_settings_frame = ttk.LabelFrame(download_settings_frame, text="Image Filters", padding=10) | |
| filter_settings_frame.pack(fill=tk.X, pady=(10, 0)) | |
| # Resolution selection | |
| resolution_frame = ttk.Frame(filter_settings_frame) | |
| resolution_frame.pack(fill=tk.X, pady=(0, 10)) | |
| ttk.Label(resolution_frame, text="Resolution:", font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=(0, 10)) | |
| resolution_combo = ttk.Combobox(resolution_frame, textvariable=self.resolution_var, | |
| values=["1024", "2048", "4096"], state="readonly", width=10) | |
| resolution_combo.pack(side=tk.LEFT, padx=(0, 20)) | |
| resolution_combo.bind('<<ComboboxSelected>>', self.on_filter_change) | |
| # Solar filter selection with visual preview | |
| filter_label = ttk.Label(filter_settings_frame, text="Solar Filter:", font=("Arial", 10, "bold")) | |
| filter_label.pack(anchor=tk.W, pady=(0, 5)) | |
| # Create the visual filter selection UI | |
| self.create_filter_selection_ui(filter_settings_frame) | |
| # Custom keyword search settings | |
| keyword_settings_frame = ttk.LabelFrame(settings_scrollable_frame, text="Custom Keyword Search", padding=10) | |
| keyword_settings_frame.pack(fill=tk.X, pady=10) | |
| # Description | |
| desc_label = ttk.Label(keyword_settings_frame, | |
| text="Customize search keywords for each solar filter. Leave empty to use default filter numbers.", | |
| font=("Arial", 9), foreground="gray") | |
| desc_label.pack(anchor=tk.W, pady=(0, 10)) | |
| # Initialize custom keywords dictionary if not exists | |
| if not hasattr(self, 'custom_keywords'): | |
| self.custom_keywords = {} | |
| for filter_num in self.filter_data.keys(): | |
| self.custom_keywords[filter_num] = tk.StringVar(value=filter_num) # Default to filter number | |
| # Create keyword input fields for each filter | |
| keyword_grid_frame = ttk.Frame(keyword_settings_frame) | |
| keyword_grid_frame.pack(fill=tk.X) | |
| # Create a scrollable frame for keyword inputs | |
| keyword_canvas = tk.Canvas(keyword_grid_frame, height=200, bg="#f8f9fa") | |
| keyword_v_scrollbar = ttk.Scrollbar(keyword_grid_frame, orient="vertical", command=keyword_canvas.yview) | |
| keyword_scroll_frame = ttk.Frame(keyword_canvas) | |
| keyword_scroll_frame.bind( | |
| "<Configure>", | |
| lambda e: keyword_canvas.configure(scrollregion=keyword_canvas.bbox("all")) | |
| ) | |
| keyword_canvas.create_window((0, 0), window=keyword_scroll_frame, anchor="nw") | |
| keyword_canvas.configure(yscrollcommand=keyword_v_scrollbar.set) | |
| keyword_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) | |
| keyword_v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) | |
| # Create input fields for each filter (4 columns for better width utilization) | |
| for i, (filter_num, data) in enumerate(self.filter_data.items()): | |
| row = i // 4 # 4 columns instead of 3 | |
| col = i % 4 | |
| filter_frame = ttk.Frame(keyword_scroll_frame) | |
| filter_frame.grid(row=row, column=col, padx=5, pady=5, sticky="ew") | |
| # Filter label with color | |
| filter_label = tk.Label(filter_frame, text=f"{data['name']} ({filter_num})", | |
| font=("Arial", 9, "bold"), fg="white", bg=data["color"], | |
| padx=5, pady=2) | |
| filter_label.pack(anchor=tk.W) | |
| # Keyword input | |
| keyword_entry = ttk.Entry(filter_frame, textvariable=self.custom_keywords[filter_num], | |
| width=12, font=("Arial", 9)) # Slightly smaller width for 4 columns | |
| keyword_entry.pack(fill=tk.X, pady=(2, 0)) | |
| # Bind change event | |
| self.custom_keywords[filter_num].trace('w', self.on_keyword_change) | |
| # Configure grid weights for proper spacing (4 columns now) | |
| for col in range(4): | |
| keyword_scroll_frame.grid_columnconfigure(col, weight=1) | |
| # Reset and Apply buttons | |
| keyword_btn_frame = ttk.Frame(keyword_settings_frame) | |
| keyword_btn_frame.pack(fill=tk.X, pady=(10, 0)) | |
| ttk.Button(keyword_btn_frame, text="๐ Reset to Defaults", | |
| command=self.reset_keywords_to_default).pack(side=tk.LEFT, padx=(0, 10)) | |
| ttk.Button(keyword_btn_frame, text="โ Apply Keywords", | |
| command=self.apply_custom_keywords).pack(side=tk.LEFT) | |
| # Data directory settings | |
| data_settings_frame = ttk.LabelFrame(settings_scrollable_frame, text="Data Directory", padding=10) | |
| data_settings_frame.pack(fill=tk.X, pady=5) | |
| current_dir_frame = ttk.Frame(data_settings_frame) | |
| current_dir_frame.pack(fill=tk.X, pady=(0, 10)) | |
| ttk.Label(current_dir_frame, text="Current data directory:").pack(anchor=tk.W) | |
| self.data_dir_label = ttk.Label(current_dir_frame, text=str(self.storage.base_data_dir.absolute()), | |
| foreground="blue") | |
| self.data_dir_label.pack(anchor=tk.W, pady=(5, 0)) | |
| ttk.Button(data_settings_frame, text="๐ Open Data Folder", | |
| command=self.open_data_folder).pack(side=tk.LEFT, padx=(0, 10)) | |
| ttk.Button(data_settings_frame, text="๐งน Clean Up Corrupted Files", | |
| command=self.cleanup_files).pack(side=tk.LEFT) | |
| # System info | |
| info_frame = ttk.LabelFrame(settings_scrollable_frame, text="System Information", padding=10) | |
| info_frame.pack(fill=tk.X, pady=5) | |
| self.check_system_requirements(info_frame) | |
| # Credit text | |
| credit_frame = ttk.Frame(settings_scrollable_frame) | |
| credit_frame.pack(fill=tk.X, pady=(20, 10)) | |
| credit_label = ttk.Label(credit_frame, | |
| text="Created by Andy Kong", | |
| font=("Arial", 9, "italic"), | |
| foreground="gray") | |
| credit_label.pack(anchor=tk.CENTER) | |
| def create_filter_selection_ui(self, parent_frame): | |
| """Create the visual filter selection UI that can be reused in multiple tabs.""" | |
| # Create scrollable frame for filter selection | |
| filter_canvas = tk.Canvas(parent_frame, height=120, bg="#f0f0f0") | |
| filter_scrollbar = ttk.Scrollbar(parent_frame, orient="horizontal", command=filter_canvas.xview) | |
| filter_scroll_frame = ttk.Frame(filter_canvas) | |
| filter_scroll_frame.bind( | |
| "<Configure>", | |
| lambda e: filter_canvas.configure(scrollregion=filter_canvas.bbox("all")) | |
| ) | |
| filter_canvas.create_window((0, 0), window=filter_scroll_frame, anchor="nw") | |
| filter_canvas.configure(xscrollcommand=filter_scrollbar.set) | |
| filter_canvas.pack(fill=tk.X, pady=(0, 5)) | |
| filter_scrollbar.pack(fill=tk.X) | |
| # Create visual filter selection buttons | |
| for i, (filter_num, data) in enumerate(self.filter_data.items()): | |
| filter_btn_frame = ttk.Frame(filter_scroll_frame) | |
| filter_btn_frame.grid(row=0, column=i, padx=5, pady=5) | |
| # Try to load preview image | |
| preview_image = None | |
| ui_img_path = Path("src/ui_img") | |
| for img_file in ui_img_path.glob(f"*_{filter_num}.jpg"): | |
| try: | |
| pil_img = Image.open(img_file) | |
| pil_img.thumbnail((80, 80), Image.Resampling.LANCZOS) | |
| preview_image = ImageTk.PhotoImage(pil_img) | |
| break | |
| except: | |
| continue | |
| # Create filter button | |
| if preview_image: | |
| filter_btn = tk.Button(filter_btn_frame, image=preview_image, | |
| command=lambda f=filter_num: self.select_filter(f), | |
| relief=tk.RAISED, bd=2, bg=data["color"], | |
| activebackground=data["color"]) | |
| filter_btn.image = preview_image # Keep reference | |
| else: | |
| filter_btn = tk.Button(filter_btn_frame, text=filter_num, | |
| command=lambda f=filter_num: self.select_filter(f), | |
| relief=tk.RAISED, bd=2, bg=data["color"], | |
| activebackground=data["color"], width=8, height=4) | |
| filter_btn.pack() | |
| # Filter info | |
| info_label = ttk.Label(filter_btn_frame, text=data["name"], | |
| font=("Arial", 8, "bold"), anchor=tk.CENTER) | |
| info_label.pack() | |
| desc_label = ttk.Label(filter_btn_frame, text=data["desc"], | |
| font=("Arial", 7), anchor=tk.CENTER, foreground="gray") | |
| desc_label.pack() | |
| self.filter_buttons[filter_num] = filter_btn | |
| # Update initial selection if not already done | |
| if not self._filter_initialized: | |
| # Set the filter without triggering refresh during initialization | |
| self.solar_filter_var.set("0211") | |
| # Update button appearances | |
| for fnum, btn in self.filter_buttons.items(): | |
| if fnum == "0211": | |
| btn.config(relief=tk.SUNKEN, bd=3) | |
| else: | |
| btn.config(relief=tk.RAISED, bd=2) | |
| self._filter_initialized = True | |
| def select_filter(self, filter_num): | |
| """Select a solar filter and update the UI.""" | |
| self.solar_filter_var.set(filter_num) | |
| # Update button appearances | |
| for fnum, btn in self.filter_buttons.items(): | |
| if fnum == filter_num: | |
| btn.config(relief=tk.SUNKEN, bd=3) | |
| else: | |
| btn.config(relief=tk.RAISED, bd=2) | |
| # Trigger filter change | |
| self.on_filter_change() | |
| def on_filter_change(self, event=None): | |
| """Handle changes to resolution or solar filter settings.""" | |
| resolution = self.resolution_var.get() | |
| solar_filter = self.solar_filter_var.get() | |
| # Get custom keyword if available | |
| search_keyword = self.get_current_search_keyword() | |
| # Update scraper with new settings (using custom keyword) | |
| self.scraper.update_filters(resolution, search_keyword) | |
| # Update storage organizer with new pattern (using custom keyword) | |
| self.storage.update_file_pattern(resolution, search_keyword) | |
| # Refresh available dates to reflect new filter (only if UI is fully initialized) | |
| if hasattr(self, 'viewer_from_date_combo'): | |
| self.refresh_available_dates() | |
| def on_keyword_change(self, *args): | |
| """Handle changes to custom keyword settings.""" | |
| # This method is called when any keyword is modified | |
| # We don't need to do anything immediately, changes are applied when user clicks Apply | |
| pass | |
| def reset_keywords_to_default(self): | |
| """Reset all custom keywords to their default filter numbers.""" | |
| for filter_num in self.filter_data.keys(): | |
| self.custom_keywords[filter_num].set(filter_num) | |
| messagebox.showinfo("Keywords Reset", "All keywords have been reset to default filter numbers.") | |
| def apply_custom_keywords(self): | |
| """Apply the custom keywords to the search system.""" | |
| try: | |
| # Get current settings | |
| resolution = self.resolution_var.get() | |
| # Update the scraper and storage with custom keywords | |
| # For now, we'll use the currently selected filter's custom keyword | |
| current_filter = self.solar_filter_var.get() | |
| custom_keyword = self.custom_keywords[current_filter].get().strip() | |
| if not custom_keyword: | |
| custom_keyword = current_filter # Fallback to default | |
| # Update components with custom keyword | |
| self.scraper.update_filters(resolution, custom_keyword) | |
| self.storage.update_file_pattern(resolution, custom_keyword) | |
| # Refresh available dates | |
| if hasattr(self, 'viewer_from_date_combo'): | |
| self.refresh_available_dates() | |
| messagebox.showinfo("Keywords Applied", | |
| f"Custom keywords applied successfully!\n" | |
| f"Current search keyword: '{custom_keyword}'") | |
| except Exception as e: | |
| messagebox.showerror("Error", f"Failed to apply custom keywords: {str(e)}") | |
| def get_current_search_keyword(self): | |
| """Get the current search keyword for the selected filter.""" | |
| current_filter = self.solar_filter_var.get() | |
| if hasattr(self, 'custom_keywords') and current_filter in self.custom_keywords: | |
| custom_keyword = self.custom_keywords[current_filter].get().strip() | |
| return custom_keyword if custom_keyword else current_filter | |
| return current_filter | |
| def check_system_requirements(self, parent_frame): | |
| """Check and display system requirements.""" | |
| # Check FFmpeg | |
| try: | |
| result = subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5) | |
| ffmpeg_status = "โ Available" if result.returncode == 0 else "โ Not found" | |
| except: | |
| ffmpeg_status = "โ Not found" | |
| ttk.Label(parent_frame, text=f"FFmpeg: {ffmpeg_status}").pack(anchor=tk.W) | |
| # Check OpenCV | |
| try: | |
| import cv2 | |
| opencv_status = f"โ Available (v{cv2.__version__})" | |
| except: | |
| opencv_status = "โ Not found" | |
| ttk.Label(parent_frame, text=f"OpenCV: {opencv_status}").pack(anchor=tk.W) | |
| # Check PIL | |
| try: | |
| from PIL import Image | |
| pil_status = f"โ Available (v{Image.__version__})" | |
| except: | |
| pil_status = "โ Not found" | |
| ttk.Label(parent_frame, text=f"Pillow: {pil_status}").pack(anchor=tk.W) | |
| def log_message(self, message): | |
| """Add message to download log.""" | |
| timestamp = datetime.now().strftime("%H:%M:%S") | |
| self.log_text.insert(tk.END, f"[{timestamp}] {message}\n") | |
| self.log_text.see(tk.END) | |
| self.root.update_idletasks() | |
| def set_date_range(self, days_back): | |
| """Set date range for quick selection.""" | |
| end_date = datetime.now() | |
| start_date = end_date - timedelta(days=days_back) | |
| self.start_date_var.set(start_date.strftime("%Y-%m-%d")) | |
| self.end_date_var.set(end_date.strftime("%Y-%m-%d")) | |
| def set_video_date_range(self, days_back): | |
| """Set video date range for quick selection.""" | |
| end_date = datetime.now() | |
| start_date = end_date - timedelta(days=days_back) | |
| self.video_start_date_var.set(start_date.strftime("%Y-%m-%d")) | |
| self.video_end_date_var.set(end_date.strftime("%Y-%m-%d")) | |
| def start_download(self): | |
| """Start the download process in a background thread.""" | |
| if self.download_thread and self.download_thread.is_alive(): | |
| messagebox.showwarning("Download in Progress", "A download is already in progress!") | |
| return | |
| self.download_btn.config(state=tk.DISABLED) | |
| self.log_text.delete(1.0, tk.END) | |
| self.download_thread = threading.Thread(target=self._download_worker, daemon=True) | |
| self.download_thread.start() | |
| def _download_worker(self): | |
| """Download worker running in background thread.""" | |
| try: | |
| # Parse dates | |
| start_date = datetime.strptime(self.start_date_var.get(), "%Y-%m-%d") | |
| end_date = datetime.strptime(self.end_date_var.get(), "%Y-%m-%d") | |
| self.root.after(0, lambda: self.log_message(f"Starting download for {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")) | |
| self.root.after(0, lambda: self.status_label.config(text="Scanning directories...")) | |
| # Get available images | |
| available_images = self.scraper.get_available_images_for_date_range(start_date, end_date) | |
| self.root.after(0, lambda: self.log_message(f"Found {len(available_images)} total images")) | |
| if not available_images: | |
| self.root.after(0, lambda: self.log_message("No images found for selected date range")) | |
| self.root.after(0, lambda: self.status_label.config(text="No images found")) | |
| return | |
| # Filter new images | |
| new_images = self.scraper.filter_new_images(available_images, self.storage) | |
| self.root.after(0, lambda: self.log_message(f"Found {len(new_images)} new images to download")) | |
| if not new_images: | |
| self.root.after(0, lambda: self.log_message("All images already downloaded!")) | |
| self.root.after(0, lambda: self.status_label.config(text="All images up to date")) | |
| return | |
| # Create download tasks | |
| tasks = self.scraper.create_download_tasks(new_images, self.storage) | |
| # Download images | |
| successful = 0 | |
| failed = 0 | |
| for i, task in enumerate(tasks): | |
| progress = (i / len(tasks)) * 100 | |
| self.root.after(0, lambda p=progress: self.progress_var.set(p)) | |
| filename = task.target_path.name | |
| self.root.after(0, lambda f=filename, idx=i+1, total=len(tasks): | |
| self.status_label.config(text=f"Downloading {idx}/{total}: {f}")) | |
| success = self.download_manager.download_and_save(task) | |
| if success: | |
| successful += 1 | |
| self.root.after(0, lambda f=filename: self.log_message(f"โ Downloaded: {f}")) | |
| else: | |
| failed += 1 | |
| self.root.after(0, lambda f=filename, err=task.error_message: | |
| self.log_message(f"โ Failed: {f} - {err}")) | |
| # Final status | |
| self.root.after(0, lambda: self.progress_var.set(100)) | |
| self.root.after(0, lambda: self.log_message(f"Download complete: {successful} successful, {failed} failed")) | |
| self.root.after(0, lambda: self.status_label.config(text=f"Complete: {successful}/{len(tasks)} downloaded")) | |
| self.root.after(0, self.refresh_available_dates) | |
| except Exception as e: | |
| self.root.after(0, lambda: self.log_message(f"โ Error: {str(e)}")) | |
| self.root.after(0, lambda: self.status_label.config(text="Download failed")) | |
| finally: | |
| self.root.after(0, lambda: self.download_btn.config(state=tk.NORMAL)) | |
| def refresh_available_dates(self): | |
| """Refresh the list of available dates.""" | |
| dates = [] | |
| data_dir = self.storage.base_data_dir | |
| if data_dir.exists(): | |
| for year_dir in data_dir.iterdir(): | |
| if not year_dir.is_dir() or not year_dir.name.isdigit(): | |
| continue | |
| for month_dir in year_dir.iterdir(): | |
| if not month_dir.is_dir() or not month_dir.name.isdigit(): | |
| continue | |
| for day_dir in month_dir.iterdir(): | |
| if not day_dir.is_dir() or not day_dir.name.isdigit(): | |
| continue | |
| images = list(day_dir.glob(f"*_{self.resolution_var.get()}_{self.solar_filter_var.get()}.jpg")) | |
| if images: | |
| try: | |
| date = datetime(int(year_dir.name), int(month_dir.name), int(day_dir.name)) | |
| date_str = f"{date.strftime('%Y-%m-%d')} ({len(images)} images)" | |
| dates.append((date, date_str)) | |
| except ValueError: | |
| continue | |
| if dates: | |
| dates.sort(reverse=True) # Most recent first | |
| date_strings = [date_str for _, date_str in dates] | |
| # Update all date combo boxes (only viewer tab now has combo boxes) | |
| if hasattr(self, 'viewer_from_date_combo'): | |
| self.viewer_from_date_combo['values'] = date_strings | |
| self.viewer_to_date_combo['values'] = date_strings | |
| if date_strings: | |
| self.viewer_from_date_combo.current(0) | |
| self.viewer_to_date_combo.current(0) | |
| self.available_dates = {date_str: date for date, date_str in dates} | |
| else: | |
| if hasattr(self, 'viewer_from_date_combo'): | |
| self.viewer_from_date_combo['values'] = [] | |
| self.viewer_to_date_combo['values'] = [] | |
| self.available_dates = {} | |
| def load_images_for_viewer(self): | |
| """Load images for the viewer tab.""" | |
| from_selected = self.viewer_from_date_var.get() | |
| to_selected = self.viewer_to_date_var.get() | |
| if not from_selected or from_selected not in self.available_dates: | |
| messagebox.showerror("Error", "Please select a valid 'From' date") | |
| return | |
| if not to_selected or to_selected not in self.available_dates: | |
| messagebox.showerror("Error", "Please select a valid 'To' date") | |
| return | |
| from_date = self.available_dates[from_selected] | |
| to_date = self.available_dates[to_selected] | |
| # Ensure from_date is not after to_date | |
| if from_date > to_date: | |
| from_date, to_date = to_date, from_date | |
| # Load images from all dates in the range | |
| self.current_images = [] | |
| total_images = 0 | |
| # Get all dates in range | |
| current_date = from_date | |
| while current_date <= to_date: | |
| image_files = self.storage.list_local_images(current_date) | |
| if image_files: | |
| date_path = self.storage.get_date_path(current_date) | |
| for filename in sorted(image_files): | |
| image_path = date_path / filename | |
| self.current_images.append((image_path, filename, current_date)) | |
| total_images += 1 | |
| current_date += timedelta(days=1) | |
| if not self.current_images: | |
| messagebox.showerror("Error", f"No images found for date range {from_date.strftime('%Y-%m-%d')} to {to_date.strftime('%Y-%m-%d')}") | |
| return | |
| # Sort all images by filename (which includes timestamp) | |
| self.current_images.sort(key=lambda x: x[1]) | |
| self.current_image_index = 0 | |
| self.update_image_display() | |
| date_range_text = f"{from_date.strftime('%Y-%m-%d')}" if from_date == to_date else f"{from_date.strftime('%Y-%m-%d')} to {to_date.strftime('%Y-%m-%d')}" | |
| self.image_info_label.config(text=f"Loaded {total_images} images for {date_range_text}") | |
| def update_image_display(self): | |
| """Update the image display in viewer tab.""" | |
| if not self.current_images: | |
| return | |
| # Handle both old format (path, filename) and new format (path, filename, date) | |
| current_item = self.current_images[self.current_image_index] | |
| if len(current_item) == 3: | |
| image_path, filename, image_date = current_item | |
| else: | |
| image_path, filename = current_item | |
| image_date = None | |
| try: | |
| # Load and resize image | |
| pil_image = Image.open(image_path) | |
| # Calculate size to fit in display area | |
| display_size = (600, 600) | |
| pil_image.thumbnail(display_size, Image.Resampling.LANCZOS) | |
| # Convert to PhotoImage | |
| photo = ImageTk.PhotoImage(pil_image) | |
| # Update display | |
| self.image_display_label.config(image=photo, text="") | |
| self.image_display_label.image = photo # Keep reference | |
| # Update progress and info | |
| progress = (self.current_image_index + 1) / len(self.current_images) * 100 | |
| self.image_progress_var.set(progress) | |
| # Extract timestamp | |
| timestamp = filename.split('_')[1] if '_' in filename else "Unknown" | |
| if len(timestamp) == 6: | |
| formatted_time = f"{timestamp[:2]}:{timestamp[2:4]}:{timestamp[4:6]}" | |
| else: | |
| formatted_time = timestamp | |
| # Include date in info if available | |
| if image_date: | |
| info_text = f"Image {self.current_image_index + 1}/{len(self.current_images)} - {image_date.strftime('%Y-%m-%d')} Time: {formatted_time}" | |
| else: | |
| info_text = f"Image {self.current_image_index + 1}/{len(self.current_images)} - Time: {formatted_time}" | |
| self.image_info_label.config(text=info_text) | |
| # Update speed display | |
| speed = self.speed_var.get() | |
| self.speed_display.config(text=f"{speed:.1f} FPS") | |
| except Exception as e: | |
| self.image_display_label.config(text=f"Error loading image: {e}") | |
| def update_speed_display(self, value=None): | |
| """Update the speed display when slider changes.""" | |
| speed = self.speed_var.get() | |
| self.speed_display.config(text=f"{speed:.1f} FPS") | |
| def first_image(self): | |
| """Go to first image.""" | |
| if self.current_images: | |
| self.current_image_index = 0 | |
| self.update_image_display() | |
| def prev_image(self): | |
| """Go to previous image.""" | |
| if self.current_images and self.current_image_index > 0: | |
| self.current_image_index -= 1 | |
| self.update_image_display() | |
| def next_image(self): | |
| """Go to next image.""" | |
| if self.current_images and self.current_image_index < len(self.current_images) - 1: | |
| self.current_image_index += 1 | |
| self.update_image_display() | |
| def last_image(self): | |
| """Go to last image.""" | |
| if self.current_images: | |
| self.current_image_index = len(self.current_images) - 1 | |
| self.update_image_display() | |
| def toggle_play(self): | |
| """Toggle play/pause for image sequence.""" | |
| if self.is_playing: | |
| self.stop_play() | |
| else: | |
| self.start_play() | |
| def start_play(self): | |
| """Start playing image sequence.""" | |
| if not self.current_images: | |
| messagebox.showwarning("No Images", "Please load images first") | |
| return | |
| self.is_playing = True | |
| self.play_btn.config(text="โธ Pause") | |
| self.play_thread = threading.Thread(target=self._play_loop, daemon=True) | |
| self.play_thread.start() | |
| def stop_play(self): | |
| """Stop playing image sequence.""" | |
| self.is_playing = False | |
| self.play_btn.config(text="โถ Play") | |
| def _play_loop(self): | |
| """Play loop for image sequence.""" | |
| while self.is_playing and self.current_images: | |
| if self.current_image_index >= len(self.current_images) - 1: | |
| self.current_image_index = 0 # Loop back | |
| else: | |
| self.current_image_index += 1 | |
| self.root.after(0, self.update_image_display) | |
| # Wait based on FPS | |
| fps = self.speed_var.get() | |
| delay = 1.0 / fps | |
| import time | |
| time.sleep(delay) | |
| def create_date_range_video(self): | |
| """Create video for selected date range.""" | |
| try: | |
| # Parse dates | |
| start_date = datetime.strptime(self.video_start_date_var.get(), "%Y-%m-%d") | |
| end_date = datetime.strptime(self.video_end_date_var.get(), "%Y-%m-%d") | |
| except ValueError: | |
| messagebox.showerror("Invalid Date", "Please enter valid dates in YYYY-MM-DD format") | |
| return | |
| if start_date > end_date: | |
| messagebox.showerror("Invalid Date Range", "Start date must be before or equal to end date") | |
| return | |
| fps = self.video_fps_var.get() | |
| # Check if ffmpeg is available | |
| try: | |
| subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5) | |
| except: | |
| messagebox.showerror("FFmpeg Not Found", | |
| "FFmpeg is required to create videos.\n\n" | |
| "Please install FFmpeg:\n" | |
| "โข Windows: Download from https://ffmpeg.org/\n" | |
| "โข Or use: winget install FFmpeg") | |
| return | |
| # Create video in background thread | |
| threading.Thread(target=self._create_date_range_video_worker, args=(start_date, end_date, fps), daemon=True).start() | |
| def _create_date_range_video_worker(self, start_date, end_date, fps): | |
| """Create video for date range in background thread.""" | |
| try: | |
| # Reset progress bar | |
| self.root.after(0, lambda: self.video_progress_var.set(0)) | |
| self.root.after(0, lambda: self.video_status_label.config(text="Initializing video creation...")) | |
| # Create video directory if it doesn't exist | |
| video_dir = Path("video") | |
| video_dir.mkdir(exist_ok=True) | |
| # Generate output filename with date range | |
| if start_date == end_date: | |
| output_file = f"nasa_solar_{start_date.strftime('%Y%m%d')}.mp4" | |
| else: | |
| output_file = f"nasa_solar_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}.mp4" | |
| output_path = video_dir / output_file | |
| date_range_text = f"{start_date.strftime('%Y-%m-%d')}" if start_date == end_date else f"{start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}" | |
| # Update progress: 10% | |
| self.root.after(0, lambda: self.video_progress_var.set(10)) | |
| self.root.after(0, lambda: self.video_status_label.config(text="Collecting images...")) | |
| # Collect all images from the date range | |
| all_image_paths = [] | |
| current_date = start_date | |
| while current_date <= end_date: | |
| images = self.storage.list_local_images(current_date) | |
| if images: | |
| date_path = self.storage.get_date_path(current_date) | |
| # Add all images from this date | |
| for filename in sorted(images): | |
| image_path = date_path / filename | |
| if image_path.exists(): | |
| all_image_paths.append(image_path) | |
| current_date += timedelta(days=1) | |
| if not all_image_paths: | |
| self.root.after(0, lambda: self.video_status_label.config(text="No images found")) | |
| self.root.after(0, lambda: messagebox.showerror("Error", f"No images found for date range {date_range_text}")) | |
| return | |
| # Update progress: 20% | |
| self.root.after(0, lambda: self.video_progress_var.set(20)) | |
| self.root.after(0, lambda: self.video_status_label.config(text=f"Found {len(all_image_paths)} images. Preparing frames...")) | |
| # Create temporary directory for ffmpeg | |
| temp_dir = Path("temp_video_frames") | |
| temp_dir.mkdir(exist_ok=True) | |
| try: | |
| # Create sequential frame files for all images with progress updates | |
| total_images = len(all_image_paths) | |
| for i, src_path in enumerate(all_image_paths): | |
| temp_path = temp_dir / f"frame_{i:06d}.jpg" | |
| # Remove existing file if it exists | |
| if temp_path.exists(): | |
| temp_path.unlink() | |
| try: | |
| temp_path.symlink_to(src_path.absolute()) | |
| except OSError: | |
| shutil.copy2(src_path, temp_path) | |
| # Update progress: 20% to 70% for frame preparation | |
| progress = 20 + (i / total_images) * 50 | |
| self.root.after(0, lambda p=progress: self.video_progress_var.set(p)) | |
| self.root.after(0, lambda idx=i+1, total=total_images: | |
| self.video_status_label.config(text=f"Preparing frame {idx}/{total}...")) | |
| # Update progress: 70% | |
| self.root.after(0, lambda: self.video_progress_var.set(70)) | |
| self.root.after(0, lambda: self.video_status_label.config(text="Running FFmpeg to create video...")) | |
| # Run ffmpeg | |
| input_pattern = str(temp_dir / "frame_%06d.jpg") | |
| ffmpeg_cmd = [ | |
| 'ffmpeg', '-y', '-framerate', str(fps), | |
| '-i', input_pattern, '-c:v', 'libx264', | |
| '-pix_fmt', 'yuv420p', '-crf', '18', | |
| str(output_path) | |
| ] | |
| result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True) | |
| # Update progress: 90% | |
| self.root.after(0, lambda: self.video_progress_var.set(90)) | |
| self.root.after(0, lambda: self.video_status_label.config(text="Finalizing video...")) | |
| if result.returncode == 0: | |
| # Update progress: 100% | |
| self.root.after(0, lambda: self.video_progress_var.set(100)) | |
| self.root.after(0, lambda: self.video_status_label.config(text="Video created successfully!")) | |
| size_mb = output_path.stat().st_size / (1024 * 1024) | |
| duration = len(all_image_paths) / fps | |
| self.root.after(0, lambda: messagebox.showinfo("Success", | |
| f"Video created successfully!\n\n" | |
| f"File: {output_path}\n" | |
| f"Size: {size_mb:.1f} MB\n" | |
| f"Total frames: {len(all_image_paths)}\n" | |
| f"Duration: {duration:.1f} seconds\n" | |
| f"Date range: {date_range_text}")) | |
| else: | |
| self.root.after(0, lambda: self.video_status_label.config(text="Video creation failed")) | |
| self.root.after(0, lambda: messagebox.showerror("Error", | |
| f"FFmpeg error:\n{result.stderr}")) | |
| finally: | |
| if temp_dir.exists(): | |
| shutil.rmtree(temp_dir) | |
| except Exception as e: | |
| self.root.after(0, lambda: self.video_status_label.config(text="Video creation failed")) | |
| self.root.after(0, lambda: messagebox.showerror("Error", f"Video creation failed: {str(e)}")) | |
| def create_single_video(self): | |
| """Create video for selected date.""" | |
| selected = self.video_date_var.get() | |
| if not selected or selected not in self.available_dates: | |
| messagebox.showerror("Error", "Please select a valid date") | |
| return | |
| date = self.available_dates[selected] | |
| fps = self.video_fps_var.get() | |
| # Check if ffmpeg is available | |
| try: | |
| subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5) | |
| except: | |
| messagebox.showerror("FFmpeg Not Found", | |
| "FFmpeg is required to create videos.\n\n" | |
| "Please install FFmpeg:\n" | |
| "โข Windows: Download from https://ffmpeg.org/\n" | |
| "โข Or use: winget install FFmpeg") | |
| return | |
| # Create video in background thread | |
| threading.Thread(target=self._create_video_worker, args=(date, fps), daemon=True).start() | |
| def _create_video_worker(self, date, fps): | |
| """Create video in background thread.""" | |
| try: | |
| # Create video directory if it doesn't exist | |
| video_dir = Path("video") | |
| video_dir.mkdir(exist_ok=True) | |
| output_file = f"nasa_solar_{date.strftime('%Y%m%d')}.mp4" | |
| output_path = video_dir / output_file | |
| self.root.after(0, lambda: messagebox.showinfo("Creating Video", | |
| f"Creating video for {date.strftime('%Y-%m-%d')}...\n" | |
| f"This may take a few minutes.")) | |
| # Get images | |
| images = self.storage.list_local_images(date) | |
| if not images: | |
| self.root.after(0, lambda: messagebox.showerror("Error", "No images found for selected date")) | |
| return | |
| date_path = self.storage.get_date_path(date) | |
| # Create temporary directory for ffmpeg | |
| temp_dir = Path("temp_video_frames") | |
| temp_dir.mkdir(exist_ok=True) | |
| try: | |
| # Create sequential frame files | |
| sorted_images = sorted(images) | |
| for i, image in enumerate(sorted_images): | |
| src_path = date_path / image | |
| temp_path = temp_dir / f"frame_{i:06d}.jpg" | |
| if temp_path.exists(): | |
| temp_path.unlink() | |
| try: | |
| temp_path.symlink_to(src_path.absolute()) | |
| except OSError: | |
| shutil.copy2(src_path, temp_path) | |
| # Run ffmpeg | |
| input_pattern = str(temp_dir / "frame_%06d.jpg") | |
| ffmpeg_cmd = [ | |
| 'ffmpeg', '-y', '-framerate', str(fps), | |
| '-i', input_pattern, '-c:v', 'libx264', | |
| '-pix_fmt', 'yuv420p', '-crf', '18', | |
| str(output_path) | |
| ] | |
| result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True) | |
| if result.returncode == 0: | |
| size_mb = output_path.stat().st_size / (1024 * 1024) | |
| self.root.after(0, lambda: messagebox.showinfo("Success", | |
| f"Video created successfully!\n\n" | |
| f"File: {output_path}\n" | |
| f"Size: {size_mb:.1f} MB\n" | |
| f"Duration: ~{len(sorted_images)/fps:.1f} seconds")) | |
| else: | |
| self.root.after(0, lambda: messagebox.showerror("Error", | |
| f"FFmpeg error:\n{result.stderr}")) | |
| finally: | |
| if temp_dir.exists(): | |
| shutil.rmtree(temp_dir) | |
| except Exception as e: | |
| self.root.after(0, lambda: messagebox.showerror("Error", f"Video creation failed: {str(e)}")) | |
| def create_all_videos(self): | |
| """Create one combined video with all images from all dates.""" | |
| if not self.available_dates: | |
| messagebox.showwarning("No Data", "No downloaded images found") | |
| return | |
| fps = self.video_fps_var.get() | |
| # Count total images | |
| total_images = 0 | |
| for date_str, date in self.available_dates.items(): | |
| images = self.storage.list_local_images(date) | |
| total_images += len(images) | |
| result = messagebox.askyesno("Create Combined Video", | |
| f"Create one combined video with images from all {len(self.available_dates)} dates?\n" | |
| f"Total images: {total_images}\n" | |
| f"Estimated duration: ~{total_images/fps:.1f} seconds\n" | |
| f"This may take a long time.") | |
| if not result: | |
| return | |
| threading.Thread(target=self._create_combined_video_worker, args=(fps,), daemon=True).start() | |
| def _create_combined_video_worker(self, fps): | |
| """Create combined video in background thread.""" | |
| try: | |
| # Reset progress bar | |
| self.root.after(0, lambda: self.video_progress_var.set(0)) | |
| self.root.after(0, lambda: self.video_status_label.config(text="Initializing combined video creation...")) | |
| # Create video directory if it doesn't exist | |
| video_dir = Path("video") | |
| video_dir.mkdir(exist_ok=True) | |
| # Generate output filename with date range | |
| dates = sorted(self.available_dates.values()) | |
| start_date = dates[0] | |
| end_date = dates[-1] | |
| if start_date == end_date: | |
| output_file = f"nasa_solar_combined_{start_date.strftime('%Y%m%d')}.mp4" | |
| else: | |
| output_file = f"nasa_solar_combined_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}.mp4" | |
| output_path = video_dir / output_file | |
| # Update progress: 10% | |
| self.root.after(0, lambda: self.video_progress_var.set(10)) | |
| self.root.after(0, lambda: self.video_status_label.config(text="Collecting images from all dates...")) | |
| # Collect all images from all dates | |
| all_image_paths = [] | |
| for date in sorted(self.available_dates.values()): | |
| images = self.storage.list_local_images(date) | |
| if images: | |
| date_path = self.storage.get_date_path(date) | |
| # Add all images from this date | |
| for filename in sorted(images): | |
| image_path = date_path / filename | |
| all_image_paths.append(image_path) | |
| if not all_image_paths: | |
| self.root.after(0, lambda: self.video_status_label.config(text="No images found")) | |
| self.root.after(0, lambda: messagebox.showerror("Error", "No images found")) | |
| return | |
| # Update progress: 20% | |
| self.root.after(0, lambda: self.video_progress_var.set(20)) | |
| self.root.after(0, lambda: self.video_status_label.config(text=f"Found {len(all_image_paths)} images. Preparing frames...")) | |
| # Create temporary directory for ffmpeg | |
| temp_dir = Path("temp_combined_video_frames") | |
| temp_dir.mkdir(exist_ok=True) | |
| try: | |
| # Create sequential frame files for all images with progress updates | |
| total_images = len(all_image_paths) | |
| for i, src_path in enumerate(all_image_paths): | |
| temp_path = temp_dir / f"frame_{i:06d}.jpg" | |
| # Remove existing file if it exists | |
| if temp_path.exists(): | |
| temp_path.unlink() | |
| try: | |
| temp_path.symlink_to(src_path.absolute()) | |
| except OSError: | |
| shutil.copy2(src_path, temp_path) | |
| # Update progress: 20% to 70% for frame preparation | |
| progress = 20 + (i / total_images) * 50 | |
| self.root.after(0, lambda p=progress: self.video_progress_var.set(p)) | |
| self.root.after(0, lambda idx=i+1, total=total_images: | |
| self.video_status_label.config(text=f"Preparing frame {idx}/{total}...")) | |
| # Update progress: 70% | |
| self.root.after(0, lambda: self.video_progress_var.set(70)) | |
| self.root.after(0, lambda: self.video_status_label.config(text="Running FFmpeg to create combined video...")) | |
| # Run ffmpeg | |
| input_pattern = str(temp_dir / "frame_%06d.jpg") | |
| ffmpeg_cmd = [ | |
| 'ffmpeg', '-y', '-framerate', str(fps), | |
| '-i', input_pattern, '-c:v', 'libx264', | |
| '-pix_fmt', 'yuv420p', '-crf', '18', | |
| str(output_path) | |
| ] | |
| result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True) | |
| # Update progress: 90% | |
| self.root.after(0, lambda: self.video_progress_var.set(90)) | |
| self.root.after(0, lambda: self.video_status_label.config(text="Finalizing combined video...")) | |
| if result.returncode == 0: | |
| # Update progress: 100% | |
| self.root.after(0, lambda: self.video_progress_var.set(100)) | |
| self.root.after(0, lambda: self.video_status_label.config(text="Combined video created successfully!")) | |
| size_mb = output_path.stat().st_size / (1024 * 1024) | |
| duration = len(all_image_paths) / fps | |
| self.root.after(0, lambda: messagebox.showinfo("Success", | |
| f"Combined video created successfully!\n\n" | |
| f"File: {output_path}\n" | |
| f"Size: {size_mb:.1f} MB\n" | |
| f"Total frames: {len(all_image_paths)}\n" | |
| f"Duration: {duration:.1f} seconds\n" | |
| f"Date range: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")) | |
| else: | |
| self.root.after(0, lambda: self.video_status_label.config(text="Combined video creation failed")) | |
| self.root.after(0, lambda: messagebox.showerror("Error", | |
| f"FFmpeg error:\n{result.stderr}")) | |
| finally: | |
| if temp_dir.exists(): | |
| shutil.rmtree(temp_dir) | |
| except Exception as e: | |
| self.root.after(0, lambda: self.video_status_label.config(text="Combined video creation failed")) | |
| self.root.after(0, lambda: messagebox.showerror("Error", f"Combined video creation failed: {str(e)}")) | |
| def select_video_file(self): | |
| """Select MP4 file for playback.""" | |
| video_dir = Path("video") | |
| initial_dir = str(video_dir) if video_dir.exists() else str(Path.cwd()) | |
| file_path = filedialog.askopenfilename( | |
| title="Select MP4 Video", | |
| initialdir=initial_dir, | |
| filetypes=[("MP4 files", "*.mp4"), ("All files", "*.*")] | |
| ) | |
| if file_path: | |
| # Stop any current video playback | |
| if self.video_playing: | |
| self.stop_video() | |
| self.selected_video_path = file_path | |
| filename = Path(file_path).name | |
| self.selected_video_label.config(text=f"Selected: {filename}") | |
| self.video_play_btn.config(state=tk.NORMAL) | |
| self.fullscreen_btn.config(state=tk.NORMAL) | |
| # Show video info and preview frame | |
| try: | |
| cap = cv2.VideoCapture(file_path) | |
| if cap.isOpened(): | |
| fps = cap.get(cv2.CAP_PROP_FPS) | |
| frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) | |
| duration = frame_count / fps if fps > 0 else 0 | |
| width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) | |
| height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) | |
| info_text = f"Selected: {filename}\nDuration: {duration:.1f}s, Size: {width}x{height}, FPS: {fps:.1f}" | |
| self.selected_video_label.config(text=info_text) | |
| # Get first frame for preview with 1024x1024 display size | |
| ret, frame = cap.read() | |
| if ret: | |
| # Convert BGR to RGB | |
| frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) | |
| pil_image = Image.fromarray(frame_rgb) | |
| # Use fixed display size of 1024x1024 pixels | |
| display_width = 1024 | |
| display_height = 1024 | |
| # Calculate the best fit size while maintaining aspect ratio | |
| original_width, original_height = pil_image.size | |
| aspect_ratio = original_width / original_height | |
| # Calculate scaled dimensions to fit within 1024x1024 display area | |
| if aspect_ratio > 1.0: | |
| # Video is wider - fit to width | |
| new_width = display_width | |
| new_height = int(display_width / aspect_ratio) | |
| else: | |
| # Video is taller or square - fit to height | |
| new_height = display_height | |
| new_width = int(display_height * aspect_ratio) | |
| # Resize the preview image maintaining aspect ratio | |
| preview_image = pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS) | |
| preview_photo = ImageTk.PhotoImage(preview_image) | |
| # Show preview frame | |
| self.video_display_label.config(image=preview_photo, text="") | |
| self.video_display_label.image = preview_photo # Keep reference | |
| cap.release() | |
| else: | |
| self.selected_video_label.config(text=f"Selected: {filename} (Could not read video info)") | |
| self.video_display_label.config(image="", text="Could not load video preview") | |
| except Exception as e: | |
| self.selected_video_label.config(text=f"Selected: {filename}") | |
| self.video_display_label.config(image="", text=f"Error loading preview: {str(e)}") | |
| def play_video(self): | |
| """Play the selected MP4 video embedded in the GUI.""" | |
| if not self.selected_video_path: | |
| messagebox.showwarning("No Video", "Please select a video file first") | |
| return | |
| if self.video_playing: | |
| self.stop_video() | |
| return | |
| try: | |
| # Open video file with OpenCV | |
| self.video_cap = cv2.VideoCapture(self.selected_video_path) | |
| if not self.video_cap.isOpened(): | |
| messagebox.showerror("Error", "Could not open video file") | |
| return | |
| # Get video properties | |
| fps = self.video_cap.get(cv2.CAP_PROP_FPS) | |
| frame_count = int(self.video_cap.get(cv2.CAP_PROP_FRAME_COUNT)) | |
| duration = frame_count / fps if fps > 0 else 0 | |
| self.video_playing = True | |
| self.video_play_btn.config(text="โธ Pause") | |
| # Start video playback thread | |
| self.video_thread = threading.Thread(target=self._video_playback_loop, | |
| args=(fps,), daemon=True) | |
| self.video_thread.start() | |
| except Exception as e: | |
| messagebox.showerror("Error", f"Could not play video: {str(e)}") | |
| def stop_video(self): | |
| """Stop video playback.""" | |
| self.video_playing = False | |
| self.video_play_btn.config(text="โถ Play Video") | |
| # Exit fullscreen if active | |
| if self.fullscreen_mode: | |
| self.exit_fullscreen() | |
| if self.video_cap: | |
| self.video_cap.release() | |
| self.video_cap = None | |
| # Keep the last frame displayed instead of clearing | |
| # The last frame is already shown in self.video_display_label at 1024x1024 | |
| # So we don't need to clear it - just leave it as is to maintain the size | |
| def toggle_fullscreen(self): | |
| """Toggle fullscreen mode for video playback.""" | |
| if not self.selected_video_path: | |
| messagebox.showwarning("No Video", "Please select a video file first") | |
| return | |
| if self.fullscreen_mode: | |
| self.exit_fullscreen() | |
| else: | |
| self.enter_fullscreen() | |
| def enter_fullscreen(self): | |
| """Enter fullscreen mode.""" | |
| if self.fullscreen_mode: | |
| return | |
| self.fullscreen_mode = True | |
| self.fullscreen_btn.config(text="๐ฒ Exit Fullscreen") | |
| # Create fullscreen window | |
| self.fullscreen_window = tk.Toplevel(self.root) | |
| self.fullscreen_window.title("NASA Solar Video - Fullscreen") | |
| self.fullscreen_window.configure(bg='black') | |
| # Make it fullscreen | |
| self.fullscreen_window.attributes('-fullscreen', True) | |
| self.fullscreen_window.attributes('-topmost', True) | |
| # Create fullscreen video label | |
| self.fullscreen_video_label = tk.Label(self.fullscreen_window, | |
| text="Loading video...", | |
| background="black", foreground="white", | |
| font=("Arial", 24)) | |
| self.fullscreen_video_label.pack(fill=tk.BOTH, expand=True) | |
| # Bind escape key to exit fullscreen | |
| self.fullscreen_window.bind('<Escape>', lambda e: self.exit_fullscreen()) | |
| self.fullscreen_window.bind('<KeyPress>', lambda e: self.exit_fullscreen() if e.keysym == 'Escape' else None) | |
| self.fullscreen_window.focus_set() | |
| # Handle window close | |
| self.fullscreen_window.protocol("WM_DELETE_WINDOW", self.exit_fullscreen) | |
| # Start video if not already playing | |
| if not self.video_playing: | |
| self.play_video() | |
| def exit_fullscreen(self): | |
| """Exit fullscreen mode.""" | |
| if not self.fullscreen_mode: | |
| return | |
| self.fullscreen_mode = False | |
| self.fullscreen_btn.config(text="๐ณ Fullscreen") | |
| if self.fullscreen_window: | |
| self.fullscreen_window.destroy() | |
| self.fullscreen_window = None | |
| self.fullscreen_video_label = None | |
| def _video_playback_loop(self, fps): | |
| """Video playback loop running in background thread.""" | |
| frame_delay = 1.0 / fps if fps > 0 else 1.0 / 30 # Default to 30 FPS if unknown | |
| while self.video_playing and self.video_cap: | |
| ret, frame = self.video_cap.read() | |
| if not ret: | |
| # End of video, loop back to beginning | |
| self.video_cap.set(cv2.CAP_PROP_POS_FRAMES, 0) | |
| continue | |
| try: | |
| # Convert BGR to RGB | |
| frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) | |
| # Convert to PIL Image | |
| pil_image = Image.fromarray(frame_rgb) | |
| # Create different sized images for regular and fullscreen display | |
| if self.fullscreen_mode and self.fullscreen_window: | |
| # Get screen size for fullscreen | |
| screen_width = self.root.winfo_screenwidth() | |
| screen_height = self.root.winfo_screenheight() | |
| fullscreen_size = (screen_width, screen_height) | |
| # Create fullscreen image | |
| fullscreen_image = pil_image.copy() | |
| fullscreen_image.thumbnail(fullscreen_size, Image.Resampling.LANCZOS) | |
| fullscreen_photo = ImageTk.PhotoImage(fullscreen_image) | |
| # Update both displays | |
| self.root.after(0, self._update_video_frame, pil_image, fullscreen_photo) | |
| else: | |
| # Regular display only | |
| self.root.after(0, self._update_video_frame, pil_image, None) | |
| # Wait for next frame | |
| import time | |
| time.sleep(frame_delay) | |
| except Exception as e: | |
| print(f"Error displaying video frame: {e}") | |
| break | |
| # Cleanup when done | |
| self.root.after(0, self._video_playback_finished) | |
| def _update_video_frame(self, pil_image, fullscreen_photo=None): | |
| """Update video frame in the GUI (called from main thread).""" | |
| if self.video_playing: | |
| # Use fixed display size of 1024x1024 pixels | |
| display_width = 1024 | |
| display_height = 1024 | |
| # Calculate the best fit size while maintaining aspect ratio | |
| original_width, original_height = pil_image.size | |
| aspect_ratio = original_width / original_height | |
| # Calculate scaled dimensions to fit within 1024x1024 display area | |
| if aspect_ratio > 1.0: | |
| # Video is wider - fit to width | |
| new_width = display_width | |
| new_height = int(display_width / aspect_ratio) | |
| else: | |
| # Video is taller or square - fit to height | |
| new_height = display_height | |
| new_width = int(display_height * aspect_ratio) | |
| # Resize the image maintaining aspect ratio | |
| regular_image = pil_image.copy() | |
| regular_image = regular_image.resize((new_width, new_height), Image.Resampling.LANCZOS) | |
| regular_photo = ImageTk.PhotoImage(regular_image) | |
| self.video_display_label.config(image=regular_photo, text="") | |
| self.video_display_label.image = regular_photo # Keep reference | |
| # Update fullscreen display if active | |
| if self.fullscreen_mode and self.fullscreen_window and fullscreen_photo: | |
| try: | |
| self.fullscreen_video_label.config(image=fullscreen_photo, text="") | |
| self.fullscreen_video_label.image = fullscreen_photo # Keep reference | |
| except: | |
| pass # Fullscreen window might have been closed | |
| def _video_playback_finished(self): | |
| """Called when video playback finishes.""" | |
| self.video_playing = False | |
| self.video_play_btn.config(text="โถ Play Video") | |
| if self.video_cap: | |
| self.video_cap.release() | |
| self.video_cap = None | |
| def open_video_folder(self): | |
| """Open the folder containing videos.""" | |
| try: | |
| video_dir = Path("video") | |
| video_dir.mkdir(exist_ok=True) # Create if it doesn't exist | |
| if sys.platform.startswith('win'): | |
| os.startfile(video_dir) | |
| elif sys.platform.startswith('darwin'): | |
| subprocess.run(['open', str(video_dir)]) | |
| else: | |
| subprocess.run(['xdg-open', str(video_dir)]) | |
| except Exception as e: | |
| messagebox.showerror("Error", f"Could not open video folder: {str(e)}") | |
| def open_data_folder(self): | |
| """Open the data folder.""" | |
| try: | |
| data_dir = self.storage.base_data_dir | |
| if sys.platform.startswith('win'): | |
| os.startfile(data_dir) | |
| elif sys.platform.startswith('darwin'): | |
| subprocess.run(['open', str(data_dir)]) | |
| else: | |
| subprocess.run(['xdg-open', str(data_dir)]) | |
| except Exception as e: | |
| messagebox.showerror("Error", f"Could not open data folder: {str(e)}") | |
| def cleanup_files(self): | |
| """Clean up corrupted files.""" | |
| result = messagebox.askyesno("Clean Up Files", | |
| "This will remove corrupted (zero-size) image files.\n" | |
| "Continue?") | |
| if not result: | |
| return | |
| total_removed = 0 | |
| data_dir = self.storage.base_data_dir | |
| if data_dir.exists(): | |
| for year_dir in data_dir.iterdir(): | |
| if not year_dir.is_dir(): | |
| continue | |
| for month_dir in year_dir.iterdir(): | |
| if not month_dir.is_dir(): | |
| continue | |
| for day_dir in month_dir.iterdir(): | |
| if not day_dir.is_dir(): | |
| continue | |
| try: | |
| date = datetime(int(year_dir.name), int(month_dir.name), int(day_dir.name)) | |
| removed = self.storage.cleanup_corrupted_files(date) | |
| total_removed += removed | |
| except: | |
| continue | |
| messagebox.showinfo("Cleanup Complete", f"Removed {total_removed} corrupted files") | |
| self.refresh_available_dates() | |
| def run(self): | |
| """Run the GUI application.""" | |
| # Set up cleanup on window close | |
| self.root.protocol("WM_DELETE_WINDOW", self._on_closing) | |
| self.root.mainloop() | |
| def _on_closing(self): | |
| """Handle application closing.""" | |
| # Exit fullscreen mode | |
| if self.fullscreen_mode: | |
| self.exit_fullscreen() | |
| # Stop video playback | |
| if self.video_playing: | |
| self.stop_video() | |
| # Stop image playback | |
| if self.is_playing: | |
| self.stop_play() | |
| # Close the application | |
| self.root.destroy() | |
| def main(): | |
| """Main application entry point.""" | |
| if not HAS_GUI: | |
| return | |
| try: | |
| # Install required packages if missing | |
| try: | |
| import cv2 | |
| except ImportError: | |
| print("Installing opencv-python...") | |
| subprocess.run([sys.executable, '-m', 'pip', 'install', 'opencv-python']) | |
| app = NASADownloaderGUI() | |
| app.run() | |
| except Exception as e: | |
| print(f"โ Error starting GUI: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| if __name__ == "__main__": | |
| main() |