SolarImageDownloader / nasa_gui.py
AK51's picture
Upload 13308 files
b610d23 verified
#!/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()