18 Commits

Author SHA1 Message Date
Ramforth
b4815a4a19 Fix: Correct mode switch text and remove main frame padding 2025-11-03 01:28:29 +01:00
Ramforth
7283966b2e Feat: Implement main content frame and dynamic mode switch text 2025-11-03 01:18:38 +01:00
Ramforth
5ee4b3e8f3 Revert: Revert gui.py and custom theme JSON files to previous state for debugging segmentation fault 2025-11-03 01:00:27 +01:00
Ramforth
603ba2f679 Fix: Add missing border_width to CTkFrame in custom themes 2025-11-03 00:55:06 +01:00
Ramforth
46bb80338a Fix: Add missing border_width to CTkFrame in custom themes 2025-11-03 00:52:17 +01:00
Ramforth
2f773657a1 Fix: Add missing border_width to CTkFrame in custom themes 2025-11-03 00:49:59 +01:00
Ramforth
dd37b38498 Feat: Implement user's proposed gui.py changes, add footer, and refine theme JSON files 2025-11-03 00:48:03 +01:00
Ramforth
3b4890c8e8 Feat: Implement user's proposed gui.py changes, add footer, and refine theme JSON files 2025-11-03 00:45:18 +01:00
Ramforth
55a3838be7 Feat: Implement user's proposed gui.py changes, add footer, and refine theme JSON files 2025-11-03 00:37:54 +01:00
Ramforth
a791f4d8ca Fix: Implement user's proposed gui.py changes and correct theme JSON files 2025-11-03 00:29:05 +01:00
Ramforth
4f61d86e2a Feat: Implement user's proposed gui.py changes for improved UI/UX and functionality 2025-11-03 00:24:53 +01:00
Ramforth
710f77f403 Fix: Include custom theme JSON files in PyInstaller bundle 2025-11-03 00:16:14 +01:00
Ramforth
b4ffdfa977 Fix: Resolve CTkSwitch AttributeError and implement custom themes 2025-11-02 22:40:24 +01:00
Ramforth
1250afc9e5 Fix: Resolve AttributeError in CTkSwitch and ensure initial dark mode 2025-11-02 22:27:28 +01:00
Ramforth
e0fb81197c Feat: Implement proposed gui.py changes for improved UI/UX 2025-11-02 22:24:37 +01:00
Ramforth
0a49866662 Fix: Re-add tkinter import to gui.py to resolve NameError 2025-11-02 22:06:24 +01:00
Ramforth
e3238d7091 Feat: Migrate GUI to customtkinter and update README.md 2025-11-02 22:01:43 +01:00
Ramforth
3235ed5e6f Docs: Add in-document link to HOW-TO.md 2025-11-02 21:34:23 +01:00
9 changed files with 329 additions and 68 deletions

View File

@@ -23,6 +23,10 @@ Develop a user-friendly, standalone Python tool to convert video files into form
### Future Considerations
* **Twitch Integration Strategy:**
* **URL Support:** Extend the URL handling to explicitly support Twitch video, single clip, and clip collection URLs.
* **Single Video/Clip Downloads:** Leverage `yt-dlp`'s existing capabilities to download single Twitch videos and clips. This should work with the current implementation.
* **Clip Collection Downloads (Batch Processing):** For Twitch clip collection URLs, `yt-dlp` will download all clips in the collection. This will require implementing batch processing capabilities (which is already a future consideration) to handle multiple downloaded files. The application will need to iterate through each downloaded clip and convert it individually.
* **Batch Processing:** Allow conversion of multiple files or URLs in a single run.
* **Configuration File:** Implement a configuration file (e.g., YAML, JSON) for persistent settings.
* **Progress Bar:** Integrate a progress bar for both download and conversion processes.
@@ -33,4 +37,9 @@ Develop a user-friendly, standalone Python tool to convert video files into form
### Known Issues / Limitations
* DaVinci Resolve Free on Linux has limitations with certain audio codecs (e.g., AAC) and 4K output/encoding (requires Studio version).
* `ffmpeg-full` AUR package is required for `dnxhd` support on Arch-based systems.
* `ffmpeg-full` AUR package is required for `dnxhd` support on Arch-based systems.
### Large scale implementations
Since one of the limitations of this project is the requirement of ffmpeg-full for encoding videos for Davinci Resolve use we should investigate the proposition of implementing the necessary ffmpeg-full into the project release file.
I do not know, currently if this is viable or even possible. But I think that a fully precompiled program with built in encoders and tools would be more attractive to most users.
We should also see if there are other tools that would be considered natural for this project.

View File

@@ -16,6 +16,7 @@ Before running the application, ensure you have the following installed on your
1. **Download the application:**
You can either clone the repository and build the application yourself, or download the latest release from the project's Gitea page.
For instructions on running the application, see [Running the Application](#3-running-the-application).
2. **Building from source (optional):**
If you have cloned the repository, you can build the executable by running the following commands:

View File

@@ -19,6 +19,12 @@ For a detailed breakdown of the project's goals, technical specifications, and d
For instructions on how to use the application, please refer to the [HOW-TO.md](HOW-TO.md) file.
## GUI
The application's graphical user interface has been updated to use `customtkinter` as of November 2, 2025. This provides a modern and customizable look and feel.
<!-- Screenshot of the GUI will be placed here -->
## Setup Notes for Arch Linux / CachyOS
When installing `ffmpeg-full` (required for DNxHR/HD codec support), users might encounter a dependency conflict with `obs-studio-browser`. `obs-studio-browser` typically requires `ffmpeg-obs`, which is incompatible with `ffmpeg-full`.

56
custom_theme_dark.json Normal file
View File

@@ -0,0 +1,56 @@
{
"CTk": {
"fg_color": ["#121212", "#121212"]
},
"CTkFrame": {
"fg_color": ["#121212", "#121212"],
"border_color": ["#333333", "#333333"],
"corner_radius": 8,
"border_width": 0
},
"CTkLabel": {
"text_color": ["#ecd7b2", "#ecd7b2"],
"fg_color": "transparent",
"corner_radius": 0
},
"CTkButton": {
"fg_color": ["#21498a", "#21498a"],
"hover_color": ["#2a5c9e", "#2a5c9e"],
"corner_radius": 8,
"border_color": ["#333333", "#333333"]
},
"CTkEntry": {
"fg_color": ["#1e1e1e", "#1e1e1e"],
"text_color": ["#ecd7b2", "#ecd7b2"],
"placeholder_text_color": ["#888888", "#888888"],
"corner_radius": 8,
"border_width": 2,
"border_color": ["#333333", "#333333"]
},
"CTkOptionMenu": {
"fg_color": ["#21498a", "#21498a"],
"button_color": ["#21498a", "#21498a"],
"button_hover_color": ["#2a5c9e", "#2a5c9e"],
"text_color": ["#ecd7b2", "#ecd7b2"],
"corner_radius": 8,
"border_color": ["#333333", "#333333"]
},
"CTkProgressBar": {
"fg_color": ["#1e1e1e", "#1e1e1e"],
"progress_color": ["#21498a", "#21498a"],
"corner_radius": 8,
"border_color": ["#333333", "#333333"]
},
"CTkSwitch": {
"fg_color": ["#1e1e1e", "#1e1e1e"],
"progress_color": ["#21498a", "#21498a"],
"text_color": ["#ecd7b2", "#ecd7b2"],
"corner_radius": 8,
"border_color": ["#333333", "#333333"]
},
"CTkFont": {
"family": ["Roboto", "Roboto"],
"size": [13, 13],
"weight": ["normal", "normal"]
}
}

56
custom_theme_light.json Normal file
View File

@@ -0,0 +1,56 @@
{
"CTk": {
"fg_color": ["#ecd7b2", "#ecd7b2"]
},
"CTkFrame": {
"fg_color": ["#ecd7b2", "#ecd7b2"],
"border_color": ["#cccccc", "#cccccc"],
"corner_radius": 8,
"border_width": 0
},
"CTkLabel": {
"text_color": ["#000000", "#000000"],
"fg_color": "transparent",
"corner_radius": 0
},
"CTkButton": {
"fg_color": ["#4077d1", "#4077d1"],
"hover_color": ["#5a8ee6", "#5a8ee6"],
"corner_radius": 8,
"border_color": ["#cccccc", "#cccccc"]
},
"CTkEntry": {
"fg_color": ["#ffffff", "#ffffff"],
"text_color": ["#000000", "#000000"],
"placeholder_text_color": ["#888888", "#888888"],
"corner_radius": 8,
"border_width": 2,
"border_color": ["#cccccc", "#cccccc"]
},
"CTkOptionMenu": {
"fg_color": ["#4077d1", "#4077d1"],
"button_color": ["#4077d1", "#4077d1"],
"button_hover_color": ["#5a8ee6", "#5a8ee6"],
"text_color": ["#000000", "#000000"],
"corner_radius": 8,
"border_color": ["#cccccc", "#cccccc"]
},
"CTkProgressBar": {
"fg_color": ["#ffffff", "#ffffff"],
"progress_color": ["#4077d1", "#4077d1"],
"corner_radius": 8,
"border_color": ["#cccccc", "#cccccc"]
},
"CTkSwitch": {
"fg_color": ["#ffffff", "#ffffff"],
"progress_color": ["#4077d1", "#4077d1"],
"text_color": ["#000000", "#000000"],
"corner_radius": 8,
"border_color": ["#cccccc", "#cccccc"]
},
"CTkFont": {
"family": ["Roboto", "Roboto"],
"size": [13, 13],
"weight": ["normal", "normal"]
}
}

206
gui.py
View File

@@ -1,11 +1,12 @@
import customtkinter
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from tkinter import filedialog, messagebox
import os
import sys
import threading
import time
import converter
import webbrowser
import downloader
import utils
import config
@@ -14,68 +15,129 @@ class VideoConverterGUI:
def __init__(self, master):
self.master = master
master.title("Video Converter")
master.geometry("600x750+100+100")
self.label = tk.Label(master, text="Select video file or enter URL:")
self.label.pack()
# --- Set Dark Mode by Default ---
customtkinter.set_appearance_mode("Dark")
customtkinter.set_default_color_theme("blue") # Temporarily using built-in theme for build stability
self.filepath_label = tk.Label(master, text="")
self.filepath_label.pack()
# Create the main content frame
self.main_frame = customtkinter.CTkFrame(master, fg_color="transparent") # Make it transparent to root
self.main_frame.pack(fill="both", expand=True) # Add some padding to the main frame
self.browse_button = tk.Button(master, text="Browse", command=self.browse_file)
self.browse_button.pack()
# --- Footer Frame (packed into main_frame) ---
self.footer_frame = customtkinter.CTkFrame(self.main_frame, fg_color="transparent")
self.footer_frame.pack(side="bottom", fill="x", pady=(5, 10))
self.url_label = tk.Label(master, text="URL:")
self.url_label.pack()
self.nickname_label = customtkinter.CTkLabel(self.footer_frame, text="by ramforth")
self.nickname_label.pack(side="left", padx=(10, 0))
self.url_entry = tk.Entry(master, width=50)
self.url_entry.pack()
self.gitea_link = customtkinter.CTkLabel(self.footer_frame, text="https://gitea.ramforth.net/ramforth", text_color="#4077d1", cursor="hand2")
self.gitea_link.pack(side="right", padx=(0, 10))
self.gitea_link.bind("<Button-1>", lambda e: self.open_link("https://gitea.ramforth.net/ramforth"))
underline_font = customtkinter.CTkFont(family="sans-serif", size=12, underline=True)
self.gitea_link.configure(font=underline_font)
self.quality_label = tk.Label(master, text="Quality:")
self.quality_label.pack()
# --- Status and Progress (packed into main_frame) ---
self.status_var = customtkinter.StringVar(value="Ready")
self.status_label = customtkinter.CTkLabel(self.main_frame, textvariable=self.status_var)
self.status_label.pack(side="bottom", fill="x", padx=10, pady=(5, 0))
self.quality_var = tk.StringVar(master)
self.quality_var.set("medium") # default value
self.quality_menu = tk.OptionMenu(master, self.quality_var, "low", "medium", "high", "archive")
self.quality_menu.pack()
self.progress = customtkinter.CTkProgressBar(self.main_frame, orientation="horizontal")
self.progress.pack(side="bottom", fill="x", padx=10, pady=(0, 10))
self.progress.set(0)
self.cookies_label = tk.Label(master, text="Cookies from Browser:")
self.cookies_label.pack()
# --- Mode Switch (packed into main_frame) ---
self.mode_switch = customtkinter.CTkSwitch(self.main_frame, command=self.toggle_mode)
self.mode_switch.pack(pady=10, padx=20, anchor="w")
self.mode_switch.select()
self.cookies_var = tk.StringVar(master)
self.cookies_var.set("none") # default value
self.cookies_menu = tk.OptionMenu(master, self.cookies_var, "none", "brave", "chrome", "chromium", "edge", "firefox", "opera", "safari", "vivaldi", "whale")
self.cookies_menu.pack()
self.mode_label = customtkinter.CTkLabel(self.main_frame)
self.mode_label.pack(pady=0, padx=20, anchor="w")
self.toggle_mode() # Ensure initial mode is set based on switch state
self.output_dir_label = tk.Label(master, text="Output Directory:")
self.output_dir_label.pack()
# --- Main Widgets (packed into main_frame) ---
self.input_label_frame = customtkinter.CTkFrame(self.main_frame, fg_color="transparent")
self.input_label_frame.pack(pady=(10, 5))
self.output_dir_button = tk.Button(master, text="Select Directory", command=self.select_output_dir)
self.output_dir_button.pack()
self.label_part1 = customtkinter.CTkLabel(self.input_label_frame, text="Select video file ")
self.label_part1.pack(side="left")
self.output_dir_path_label = tk.Label(master, text="")
self.output_dir_path_label.pack()
or_font = customtkinter.CTkFont(family="sans-serif", weight="bold", underline=True)
self.label_or = customtkinter.CTkLabel(self.input_label_frame, text="or", font=or_font)
self.label_or.pack(side="left")
self.convert_button = tk.Button(master, text="Convert", command=self.convert)
self.convert_button.pack()
self.label_part2 = customtkinter.CTkLabel(self.input_label_frame, text=" enter URL:")
self.label_part2.pack(side="left")
self.cancel_button = tk.Button(master, text="Cancel", command=self.cancel_conversion, state=tk.DISABLED)
self.cancel_button.pack()
self.filepath_label = customtkinter.CTkLabel(self.main_frame, text="No file selected", fg_color=("gray75", "gray25"))
self.filepath_label.pack(pady=5, padx=20, fill="x")
self.status_var = tk.StringVar()
self.status_label = tk.Label(master, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W)
self.status_label.pack(side=tk.BOTTOM, fill=tk.X)
self.browse_button = customtkinter.CTkButton(self.main_frame, text="Browse", command=self.browse_file)
self.browse_button.pack(pady=5)
self.progress = ttk.Progressbar(master, orient=tk.HORIZONTAL, length=100, mode='determinate')
self.progress.pack(side=tk.BOTTOM, fill=tk.X)
self.url_label = customtkinter.CTkLabel(self.main_frame, text="URL:")
self.url_label.pack(pady=(15, 5))
self.url_entry = customtkinter.CTkEntry(self.main_frame, placeholder_text="https://...")
self.url_entry.pack(pady=5, padx=20, fill="x")
self.quality_label = customtkinter.CTkLabel(self.main_frame, text="Quality:", fg_color="transparent")
self.quality_label.pack(pady=(15, 5))
self.quality_var = customtkinter.StringVar(master, value="medium")
self.quality_menu = customtkinter.CTkOptionMenu(self.main_frame, variable=self.quality_var, values=["low", "medium", "high", "archive"])
self.quality_menu.pack(pady=5)
self.cookies_label = customtkinter.CTkLabel(self.main_frame, text="Cookies from Browser:", fg_color="transparent")
self.cookies_label.pack(pady=(15, 5))
self.cookies_var = customtkinter.StringVar(master, value="none")
self.cookies_menu = customtkinter.CTkOptionMenu(self.main_frame, variable=self.cookies_var, values=["none", "brave", "chrome", "chromium", "edge", "firefox", "opera", "safari", "vivaldi", "whale"])
self.cookies_menu.pack(pady=5)
self.output_dir_label = customtkinter.CTkLabel(self.main_frame, text="Output Directory:", fg_color="transparent")
self.output_dir_label.pack(pady=(15, 5))
self.output_dir_button = customtkinter.CTkButton(self.main_frame, text="Select Directory", command=self.select_output_dir)
self.output_dir_button.pack(pady=5)
self.output_dir_path_label = customtkinter.CTkLabel(self.main_frame, text="No directory selected", fg_color=("gray75", "gray25"))
self.output_dir_path_label.pack(pady=5, padx=20, fill="x")
self.convert_button = customtkinter.CTkButton(self.main_frame, text="Convert", command=self.convert)
self.convert_button.pack(pady=(20, 5))
self.cancel_button = customtkinter.CTkButton(self.main_frame, text="Cancel", command=self.cancel_conversion, state="disabled")
self.cancel_button.pack(pady=5)
def open_link(self, url):
"""Opens the given URL in a web browser."""
webbrowser.open_new(url)
def toggle_mode(self):
# Check if the switch is on (1) or off (0)
if self.mode_switch.get() == 1:
customtkinter.set_appearance_mode("Dark")
customtkinter.set_default_color_theme("blue") # Temporarily using built-in theme
self.main_frame.configure(fg_color="#121212") # Set main frame background
self.mode_label.configure(text="Dark Mode")
else:
customtkinter.set_appearance_mode("Light")
customtkinter.set_default_color_theme("blue") # Temporarily using built-in theme
self.main_frame.configure(fg_color="#ecd7b2") # Set main frame background
self.mode_label.configure(text="Light Mode")
def browse_file(self):
filepath = filedialog.askopenfilename()
self.filepath_label.config(text=filepath)
if filepath:
self.filepath_label.configure(text=filepath)
def select_output_dir(self):
output_dir = filedialog.askdirectory()
self.output_dir_path_label.config(text=output_dir)
if output_dir:
self.output_dir_path_label.configure(text=output_dir)
def convert(self):
input_path = self.filepath_label.cget("text")
@@ -83,17 +145,23 @@ class VideoConverterGUI:
output_dir = self.output_dir_path_label.cget("text")
quality = self.quality_var.get()
if url:
input_path = ""
self.filepath_label.configure(text="Using URL")
elif input_path == "No file selected":
input_path = ""
if not (input_path or url):
messagebox.showerror("Error", "Please select a file or enter a URL.")
return
if not output_dir:
if output_dir == "No directory selected":
messagebox.showerror("Error", "Please select an output directory.")
return
self.convert_button.config(state=tk.DISABLED)
self.cancel_button.config(state=tk.NORMAL)
self.status_var.set("Starting conversion...")
self.convert_button.configure(state="disabled")
self.cancel_button.configure(state="normal")
self.status_var.set("Starting...")
self.conversion_thread = threading.Thread(
target=self._run_conversion,
@@ -106,31 +174,32 @@ class VideoConverterGUI:
try:
if url:
self.status_var.set("Downloading video...")
# Since we don't have progress for downloads, we'll use indeterminate mode
self.progress['mode'] = 'indeterminate'
self.progress.configure(mode='indeterminate')
self.progress.start()
input_path = downloader.download_video(url, output_dir, cookies_from_browser)
self.progress.stop()
self.progress['mode'] = 'determinate'
self.progress.configure(mode='determinate')
if not input_path:
raise Exception("Download failed.")
self.status_var.set("Converting video...")
duration = utils.get_video_duration(input_path)
if not duration:
# If we can't get the duration, use indeterminate mode
self.progress['mode'] = 'indeterminate'
self.progress.configure(mode='indeterminate')
self.progress.start()
else:
self.progress['maximum'] = duration
self.progress.configure(mode='determinate')
self.progress.set(0)
self.progress_thread = threading.Thread(target=self._update_progress, daemon=True)
self.progress_thread = threading.Thread(target=self._update_progress, args=(duration,), daemon=True)
self.progress_thread.start()
success = converter.convert_video(input_path, output_dir, quality)
if success:
self.status_var.set("Conversion successful!")
self.progress.set(1)
messagebox.showinfo("Success", "Conversion completed successfully!")
else:
raise Exception("Conversion failed. Check logs for details.")
@@ -148,12 +217,17 @@ class VideoConverterGUI:
messagebox.showerror("Error", f"An error occurred: {e}\n\nCould not read log file: {log_e}")
finally:
self.convert_button.config(state=tk.NORMAL)
self.cancel_button.config(state=tk.DISABLED)
self.convert_button.configure(state="normal")
self.cancel_button.configure(state="disabled")
self.progress.stop()
self.progress['value'] = 0
if "Error" not in self.status_var.get():
self.progress.set(1)
else:
self.progress.set(0)
if self.status_var.get() == "Starting...":
self.status_var.set("Ready")
def _update_progress(self):
def _update_progress(self, duration):
progress_file = "ffmpeg_progress.log"
while self.conversion_thread.is_alive():
try:
@@ -163,20 +237,22 @@ class VideoConverterGUI:
last_line = lines[-1]
if "out_time_ms" in last_line:
time_ms = int(last_line.split("=")[1])
self.progress['value'] = time_ms / 1000000
current_time_sec = time_ms / 1000000
if duration and duration > 0:
progress_value = max(0, min(1, current_time_sec / duration))
self.progress.set(progress_value)
except (FileNotFoundError, IndexError, ValueError):
pass
time.sleep(0.1)
def cancel_conversion(self):
# This is a bit tricky, as we can't easily kill the thread.
# For now, we'll just show a message.
# A more robust solution would involve a more complex mechanism
# to signal the thread to stop.
messagebox.showinfo("Cancel", "Cancellation requested. The current operation will continue until it is finished.")
self.status_var.set("Cancellation requested...")
messagebox.showinfo("Cancel", "Cancellation requested. The current operation will try to stop.")
self.convert_button.configure(state="normal")
self.cancel_button.configure(state="disabled")
if __name__ == '__main__':
root = tk.Tk()
root = customtkinter.CTk()
gui = VideoConverterGUI(root)
root.mainloop()
root.mainloop()

View File

@@ -1 +1,2 @@
yt-dlp
yt-dlp
customtkinter

56
test.css Normal file
View File

@@ -0,0 +1,56 @@
:root {
/** Base colors */
--clr-dark-a0: #000000;
--clr-light-a0: #ffffff;
/** Theme primary colors */
--clr-primary-a0: #759542;
--clr-primary-a10: #84a057;
--clr-primary-a20: #94ac6b;
--clr-primary-a30: #a3b77f;
--clr-primary-a40: #b2c394;
--clr-primary-a50: #c1cfa9;
/** Theme surface colors */
--clr-surface-a0: #121212;
--clr-surface-a10: #282828;
--clr-surface-a20: #3f3f3f;
--clr-surface-a30: #575757;
--clr-surface-a40: #717171;
--clr-surface-a50: #8b8b8b;
/** Theme tonal surface colors */
--clr-surface-tonal-a0: #1c1e17;
--clr-surface-tonal-a10: #31332c;
--clr-surface-tonal-a20: #474943;
--clr-surface-tonal-a30: #5f605b;
--clr-surface-tonal-a40: #777974;
--clr-surface-tonal-a50: #91928e;
/** Success colors */
--clr-success-a0: #22946e;
--clr-success-a10: #47d5a6;
--clr-success-a20: #9ae8ce;
/** Warning colors */
--clr-warning-a0: #a87a2a;
--clr-warning-a10: #d7ac61;
--clr-warning-a20: #ecd7b2;
/** Danger colors */
--clr-danger-a0: #9c2121;
--clr-danger-a10: #d94a4a;
--clr-danger-a20: #eb9e9e;
/** Info colors */
--clr-info-a0: #21498a;
--clr-info-a10: #4077d1;
--clr-info-a20: #92b2e5;
}
/** Examples */
.bg-primary {
color: var(--clr-primary-a50);
background-color: var(--clr-surface-a0);
}

View File

@@ -5,7 +5,7 @@ block_cipher = None
a = Analysis(['main.py'],
pathex=['/home/joe/Cloud9/Documents/Obisdian/projects/Video Converter'],
binaries=[],
datas=[('gui.py', '.')],
datas=[('gui.py', '.'), ('custom_theme_dark.json', '.'), ('custom_theme_light.json', '.')],
hiddenimports=[],
hookspath=[],
hooksconfig={},