feat(gui): Add topic field and smart video naming

- Added AddVideoDialog for entering session topic when adding videos
- Videos now renamed to: gen-{num}_{topic}_{timestamp}.ext
- Shows next generation number in UI
- Shows project info (total videos, pending, processed)
- Better error handling and user feedback
- Cleaner layout with sections

When adding a video, user can enter a topic like 'lateral-support-detail'
and the video is automatically renamed to a standardized format.
This commit is contained in:
Mario Lavoie
2026-01-28 15:19:14 +00:00
parent e8cca0b9c5
commit ca01c7a944

View File

@@ -5,6 +5,9 @@ from tkinter import ttk, filedialog, messagebox
from pathlib import Path
import threading
import json
import shutil
import re
from datetime import datetime
try:
import customtkinter as ctk
@@ -20,13 +23,136 @@ from .incremental import IncrementalProcessor
from .config import load_config
def slugify(text: str) -> str:
"""Convert text to a filename-safe slug."""
text = text.lower().strip()
text = re.sub(r'[^\w\s-]', '', text)
text = re.sub(r'[\s_-]+', '-', text)
return text[:50] # Limit length
class AddVideoDialog:
"""Dialog for adding a video with topic."""
def __init__(self, parent, video_path: Path, gen_number: int):
self.result = None
self.video_path = video_path
self.gen_number = gen_number
self.dialog = ctk.CTkToplevel(parent) if USE_CTK else tk.Toplevel(parent)
self.dialog.title("Add Video")
self.dialog.geometry("500x250")
self.dialog.transient(parent)
self.dialog.grab_set()
# Make modal
self.dialog.focus_set()
# Center on parent
self.dialog.update_idletasks()
x = parent.winfo_x() + (parent.winfo_width() - 500) // 2
y = parent.winfo_y() + (parent.winfo_height() - 250) // 2
self.dialog.geometry(f"+{x}+{y}")
self._create_widgets()
def _create_widgets(self):
frame = ctk.CTkFrame(self.dialog) if USE_CTK else ttk.Frame(self.dialog)
frame.pack(fill="both", expand=True, padx=20, pady=20)
# Video info
info_text = f"Adding: {self.video_path.name}"
if USE_CTK:
ctk.CTkLabel(frame, text=info_text, wraplength=450).pack(anchor="w", pady=(0, 10))
else:
ttk.Label(frame, text=info_text, wraplength=450).pack(anchor="w", pady=(0, 10))
# Generation number
gen_text = f"This will be: Generation {self.gen_number:03d}"
if USE_CTK:
ctk.CTkLabel(frame, text=gen_text, text_color="gray").pack(anchor="w", pady=(0, 15))
else:
ttk.Label(frame, text=gen_text).pack(anchor="w", pady=(0, 15))
# Topic input
if USE_CTK:
ctk.CTkLabel(frame, text="Session Topic (optional):").pack(anchor="w")
else:
ttk.Label(frame, text="Session Topic (optional):").pack(anchor="w")
self.topic_var = tk.StringVar()
if USE_CTK:
self.topic_entry = ctk.CTkEntry(frame, textvariable=self.topic_var, width=400,
placeholder_text="e.g., lateral-support-detail, stage2-joints")
else:
self.topic_entry = ttk.Entry(frame, textvariable=self.topic_var, width=50)
self.topic_entry.pack(fill="x", pady=(5, 10))
self.topic_entry.focus_set()
# Preview
self.preview_var = tk.StringVar()
self._update_preview()
self.topic_var.trace_add("write", lambda *args: self._update_preview())
if USE_CTK:
ctk.CTkLabel(frame, text="Will be saved as:").pack(anchor="w", pady=(10, 0))
self.preview_label = ctk.CTkLabel(frame, textvariable=self.preview_var, text_color="cyan")
else:
ttk.Label(frame, text="Will be saved as:").pack(anchor="w", pady=(10, 0))
self.preview_label = ttk.Label(frame, textvariable=self.preview_var)
self.preview_label.pack(anchor="w")
# Buttons
btn_frame = ctk.CTkFrame(frame) if USE_CTK else ttk.Frame(frame)
btn_frame.pack(fill="x", pady=(20, 0))
if USE_CTK:
ctk.CTkButton(btn_frame, text="Cancel", width=100, command=self._cancel).pack(side="right", padx=5)
ctk.CTkButton(btn_frame, text="Add Video", width=100, command=self._confirm).pack(side="right", padx=5)
else:
ttk.Button(btn_frame, text="Cancel", command=self._cancel).pack(side="right", padx=5)
ttk.Button(btn_frame, text="Add Video", command=self._confirm).pack(side="right", padx=5)
# Bind Enter key
self.dialog.bind("<Return>", lambda e: self._confirm())
self.dialog.bind("<Escape>", lambda e: self._cancel())
def _update_preview(self):
topic = self.topic_var.get().strip()
topic_slug = slugify(topic) if topic else "session"
timestamp = datetime.now().strftime("%Y%m%d-%H%M")
new_name = f"gen-{self.gen_number:03d}_{topic_slug}_{timestamp}{self.video_path.suffix}"
self.preview_var.set(new_name)
def _confirm(self):
topic = self.topic_var.get().strip()
topic_slug = slugify(topic) if topic else "session"
timestamp = datetime.now().strftime("%Y%m%d-%H%M")
new_name = f"gen-{self.gen_number:03d}_{topic_slug}_{timestamp}{self.video_path.suffix}"
self.result = {
"topic": topic,
"new_filename": new_name,
"gen_number": self.gen_number
}
self.dialog.destroy()
def _cancel(self):
self.result = None
self.dialog.destroy()
def show(self):
self.dialog.wait_window()
return self.result
class CADDocumenterGUI:
"""Main GUI application."""
def __init__(self):
self.root = ctk.CTk() if USE_CTK else tk.Tk()
self.root.title("CAD Documenter")
self.root.geometry("700x600")
self.root.geometry("750x650")
self.current_project: Project | None = None
self.config = load_config()
@@ -40,45 +166,55 @@ class CADDocumenterGUI:
main_frame = ctk.CTkFrame(self.root) if USE_CTK else ttk.Frame(self.root)
main_frame.pack(fill="both", expand=True, padx=10, pady=10)
# Project selection
# === Project Section ===
project_frame = ctk.CTkFrame(main_frame) if USE_CTK else ttk.LabelFrame(main_frame, text="Project")
project_frame.pack(fill="x", pady=(0, 10))
if USE_CTK:
ctk.CTkLabel(project_frame, text="Project:").pack(side="left", padx=5)
else:
ttk.Label(project_frame, text="Project:").pack(side="left", padx=5)
ctk.CTkLabel(project_frame, text="Project:", font=("", 14, "bold")).pack(anchor="w", padx=10, pady=(10, 5))
proj_row = ctk.CTkFrame(project_frame) if USE_CTK else ttk.Frame(project_frame)
proj_row.pack(fill="x", padx=10, pady=(0, 10))
self.project_var = tk.StringVar()
self.project_combo = ctk.CTkComboBox(
project_frame,
proj_row,
variable=self.project_var,
values=[],
width=400,
command=self._on_project_selected
) if USE_CTK else ttk.Combobox(project_frame, textvariable=self.project_var)
self.project_combo.pack(side="left", fill="x", expand=True, padx=5)
) if USE_CTK else ttk.Combobox(proj_row, textvariable=self.project_var, width=50)
self.project_combo.pack(side="left", padx=(0, 10))
if not USE_CTK:
self.project_combo.bind("<<ComboboxSelected>>", lambda e: self._on_project_selected(None))
btn_new = ctk.CTkButton(project_frame, text="+ New", width=70, command=self._new_project) if USE_CTK else ttk.Button(project_frame, text="+ New", command=self._new_project)
btn_new = ctk.CTkButton(proj_row, text="+ New", width=80, command=self._new_project) if USE_CTK else ttk.Button(proj_row, text="+ New", command=self._new_project)
btn_new.pack(side="left", padx=5)
btn_open = ctk.CTkButton(project_frame, text="Open", width=70, command=self._open_project) if USE_CTK else ttk.Button(project_frame, text="Open", command=self._open_project)
btn_open = ctk.CTkButton(proj_row, text="Open", width=80, command=self._open_project) if USE_CTK else ttk.Button(proj_row, text="Open", command=self._open_project)
btn_open.pack(side="left", padx=5)
# Videos list
# Project info
self.project_info_var = tk.StringVar(value="No project loaded")
if USE_CTK:
self.project_info = ctk.CTkLabel(project_frame, textvariable=self.project_info_var, text_color="gray")
else:
self.project_info = ttk.Label(project_frame, textvariable=self.project_info_var)
self.project_info.pack(anchor="w", padx=10, pady=(0, 10))
# === Videos Section ===
videos_frame = ctk.CTkFrame(main_frame) if USE_CTK else ttk.LabelFrame(main_frame, text="Videos")
videos_frame.pack(fill="both", expand=True, pady=(0, 10))
if USE_CTK:
ctk.CTkLabel(videos_frame, text="Videos:").pack(anchor="w", padx=5, pady=5)
ctk.CTkLabel(videos_frame, text="Session Videos:", font=("", 14, "bold")).pack(anchor="w", padx=10, pady=(10, 5))
# Listbox for videos
list_frame = ctk.CTkFrame(videos_frame) if USE_CTK else ttk.Frame(videos_frame)
list_frame.pack(fill="both", expand=True, padx=5, pady=5)
list_frame.pack(fill="both", expand=True, padx=10, pady=5)
self.video_listbox = tk.Listbox(list_frame, height=10, selectmode="extended")
self.video_listbox = tk.Listbox(list_frame, height=12, selectmode="extended", font=("Consolas", 10))
self.video_listbox.pack(side="left", fill="both", expand=True)
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self.video_listbox.yview)
@@ -87,67 +223,100 @@ class CADDocumenterGUI:
# Video buttons
video_btn_frame = ctk.CTkFrame(videos_frame) if USE_CTK else ttk.Frame(videos_frame)
video_btn_frame.pack(fill="x", padx=5, pady=5)
video_btn_frame.pack(fill="x", padx=10, pady=10)
btn_add = ctk.CTkButton(video_btn_frame, text="Add Videos", command=self._add_videos) if USE_CTK else ttk.Button(video_btn_frame, text="Add Videos", command=self._add_videos)
btn_add.pack(side="left", padx=5)
btn_add = ctk.CTkButton(video_btn_frame, text="+ Add Video", width=120, command=self._add_videos) if USE_CTK else ttk.Button(video_btn_frame, text="+ Add Video", command=self._add_videos)
btn_add.pack(side="left", padx=(0, 10))
btn_remove = ctk.CTkButton(video_btn_frame, text="Remove", command=self._remove_video) if USE_CTK else ttk.Button(video_btn_frame, text="Remove", command=self._remove_video)
btn_remove.pack(side="left", padx=5)
btn_remove = ctk.CTkButton(video_btn_frame, text="Remove", width=100, command=self._remove_video, fg_color="gray") if USE_CTK else ttk.Button(video_btn_frame, text="Remove", command=self._remove_video)
btn_remove.pack(side="left")
# Drop zone hint
# Next gen hint
self.next_gen_var = tk.StringVar(value="")
if USE_CTK:
ctk.CTkLabel(video_btn_frame, text="(Drag & drop videos here)", text_color="gray").pack(side="right", padx=5)
ctk.CTkLabel(video_btn_frame, textvariable=self.next_gen_var, text_color="cyan").pack(side="right", padx=10)
else:
ttk.Label(video_btn_frame, textvariable=self.next_gen_var).pack(side="right", padx=10)
# Options
# === Options Section ===
options_frame = ctk.CTkFrame(main_frame) if USE_CTK else ttk.LabelFrame(main_frame, text="Options")
options_frame.pack(fill="x", pady=(0, 10))
if USE_CTK:
ctk.CTkLabel(options_frame, text="Options:").pack(anchor="w", padx=5, pady=5)
ctk.CTkLabel(options_frame, text="Processing Options:", font=("", 14, "bold")).pack(anchor="w", padx=10, pady=(10, 5))
opts_inner = ctk.CTkFrame(options_frame) if USE_CTK else ttk.Frame(options_frame)
opts_inner.pack(fill="x", padx=10, pady=(0, 10))
self.export_only_var = tk.BooleanVar(value=True)
cb_export = ctk.CTkCheckBox(options_frame, text="Export for Clawdbot (no API)", variable=self.export_only_var) if USE_CTK else ttk.Checkbutton(options_frame, text="Export for Clawdbot (no API)", variable=self.export_only_var)
cb_export.pack(anchor="w", padx=20, pady=2)
cb_export = ctk.CTkCheckBox(opts_inner, text="Export for Clawdbot (recommended)", variable=self.export_only_var) if USE_CTK else ttk.Checkbutton(opts_inner, text="Export for Clawdbot (recommended)", variable=self.export_only_var)
cb_export.pack(anchor="w", pady=2)
self.scene_detect_var = tk.BooleanVar(value=True)
cb_scene = ctk.CTkCheckBox(options_frame, text="Use scene detection", variable=self.scene_detect_var) if USE_CTK else ttk.Checkbutton(options_frame, text="Use scene detection", variable=self.scene_detect_var)
cb_scene.pack(anchor="w", padx=20, pady=2)
cb_scene = ctk.CTkCheckBox(opts_inner, text="Smart frame extraction (scene detection)", variable=self.scene_detect_var) if USE_CTK else ttk.Checkbutton(opts_inner, text="Smart frame extraction", variable=self.scene_detect_var)
cb_scene.pack(anchor="w", pady=2)
self.transcribe_var = tk.BooleanVar(value=True)
cb_trans = ctk.CTkCheckBox(options_frame, text="Whisper transcription", variable=self.transcribe_var) if USE_CTK else ttk.Checkbutton(options_frame, text="Whisper transcription", variable=self.transcribe_var)
cb_trans.pack(anchor="w", padx=20, pady=2)
cb_trans = ctk.CTkCheckBox(opts_inner, text="Whisper transcription (local GPU)", variable=self.transcribe_var) if USE_CTK else ttk.Checkbutton(opts_inner, text="Whisper transcription", variable=self.transcribe_var)
cb_trans.pack(anchor="w", pady=2)
# Process button
# === Process Button ===
self.btn_process = ctk.CTkButton(
main_frame,
text="Process Videos",
height=40,
text="Process Pending Videos",
height=45,
font=("", 16, "bold"),
command=self._process_videos
) if USE_CTK else ttk.Button(main_frame, text="Process Videos", command=self._process_videos)
) if USE_CTK else ttk.Button(main_frame, text="Process Pending Videos", command=self._process_videos)
self.btn_process.pack(fill="x", pady=(0, 10))
# Status
# === Status Section ===
status_frame = ctk.CTkFrame(main_frame) if USE_CTK else ttk.Frame(main_frame)
status_frame.pack(fill="x")
self.status_var = tk.StringVar(value="Ready")
self.status_var = tk.StringVar(value="Ready — Open or create a project to start")
status_label = ctk.CTkLabel(status_frame, textvariable=self.status_var) if USE_CTK else ttk.Label(status_frame, textvariable=self.status_var)
status_label.pack(side="left", padx=5)
self.progress = ctk.CTkProgressBar(status_frame) if USE_CTK else ttk.Progressbar(status_frame, mode="indeterminate")
self.progress.pack(side="right", fill="x", expand=True, padx=5)
self.progress = ctk.CTkProgressBar(status_frame, width=200) if USE_CTK else ttk.Progressbar(status_frame, mode="indeterminate", length=200)
self.progress.pack(side="right", padx=5)
if USE_CTK:
self.progress.set(0)
def _get_next_gen_number(self) -> int:
"""Get the next generation number for the current project."""
if not self.current_project:
return 1
# Count existing videos as generations
return len(self.current_project.manifest.videos) + 1
def _update_project_list(self):
"""Update the project dropdown with recent projects."""
# For now, just clear - could load from config/history
pass
pass # Could load from config/history
def _update_project_info(self):
"""Update the project info display."""
if not self.current_project:
self.project_info_var.set("No project loaded")
self.next_gen_var.set("")
return
m = self.current_project.manifest
total = len(m.videos)
pending = len(self.current_project.get_pending_videos())
processed = total - pending
info = f"📁 {m.name}{total} videos ({processed} processed, {pending} pending)"
self.project_info_var.set(info)
next_gen = self._get_next_gen_number()
self.next_gen_var.set(f"Next: Gen {next_gen:03d}")
def _on_project_selected(self, value):
"""Handle project selection."""
self._refresh_video_list()
self._update_project_info()
def _new_project(self):
"""Create a new project."""
@@ -156,20 +325,21 @@ class CADDocumenterGUI:
return
name = Path(folder).name
# Simple dialog for project name
dialog = ctk.CTkInputDialog(
text="Enter project name:",
title="New Project"
) if USE_CTK else None
# Dialog for project name
if USE_CTK:
dialog = ctk.CTkInputDialog(
text="Enter project name:",
title="New Project"
)
name = dialog.get_input() or name
try:
self.current_project = Project.create(Path(folder), name, "")
self.project_var.set(f"{name} ({folder})")
self.status_var.set(f"Created project: {name}")
self.project_var.set(f"{name}")
self.status_var.set(f"Created project: {name}")
self._refresh_video_list()
self._update_project_info()
except Exception as e:
messagebox.showerror("Error", str(e))
@@ -181,9 +351,12 @@ class CADDocumenterGUI:
try:
self.current_project = Project.load(Path(folder))
self.project_var.set(f"{self.current_project.manifest.name} ({folder})")
self.status_var.set(f"Loaded project: {self.current_project.manifest.name}")
self.project_var.set(f"{self.current_project.manifest.name}")
self.status_var.set(f"Loaded: {self.current_project.manifest.name}")
self._refresh_video_list()
self._update_project_info()
except FileNotFoundError:
messagebox.showerror("Error", f"No project.json found in {folder}\n\nUse '+ New' to create a new project.")
except Exception as e:
messagebox.showerror("Error", f"Failed to load project: {e}")
@@ -194,7 +367,7 @@ class CADDocumenterGUI:
if not self.current_project:
return
for video in self.current_project.manifest.videos:
for i, video in enumerate(self.current_project.manifest.videos, 1):
status_icon = {
"pending": "",
"processed": "",
@@ -202,35 +375,80 @@ class CADDocumenterGUI:
"error": ""
}.get(video.status, "?")
self.video_listbox.insert(tk.END, f"{status_icon} {video.filename}")
self.video_listbox.insert(tk.END, f"{status_icon} Gen {i:03d}: {video.filename}")
self._update_project_info()
def _add_videos(self):
"""Add videos to the project."""
"""Add videos to the project with topic dialog."""
if not self.current_project:
messagebox.showwarning("Warning", "Please create or open a project first")
return
files = filedialog.askopenfilenames(
title="Select videos",
title="Select video to add",
filetypes=[
("Video files", "*.mp4 *.mkv *.avi *.mov *.webm"),
("All files", "*.*")
]
)
added = 0
for f in files:
video_path = Path(f)
gen_num = self._get_next_gen_number()
# Show dialog for topic
dialog = AddVideoDialog(self.root, video_path, gen_num)
result = dialog.show()
if result is None:
continue # Cancelled
try:
self.current_project.add_video(Path(f), copy=True)
# Copy with new name
dest = self.current_project.videos_dir / result["new_filename"]
shutil.copy2(video_path, dest)
# Add to manifest (using new filename)
from .project import VideoEntry
entry = VideoEntry(
filename=result["new_filename"],
added_at=datetime.now().isoformat(),
)
self.current_project.manifest.videos.append(entry)
self.current_project.save()
added += 1
except Exception as e:
messagebox.showerror("Error", f"Failed to add {f}: {e}")
self._refresh_video_list()
self.status_var.set(f"Added {len(files)} video(s)")
if added > 0:
self._refresh_video_list()
self.status_var.set(f"✓ Added {added} video(s)")
def _remove_video(self):
"""Remove selected video from project."""
# TODO: Implement video removal
messagebox.showinfo("Info", "Video removal not yet implemented")
selection = self.video_listbox.curselection()
if not selection:
return
if not messagebox.askyesno("Confirm", "Remove selected video(s) from project?"):
return
# Remove in reverse order to maintain indices
for idx in reversed(selection):
video = self.current_project.manifest.videos[idx]
# Delete file
video_path = self.current_project.videos_dir / video.filename
if video_path.exists():
video_path.unlink()
# Remove from manifest
del self.current_project.manifest.videos[idx]
self.current_project.save()
self._refresh_video_list()
self.status_var.set(f"✓ Removed {len(selection)} video(s)")
def _process_videos(self):
"""Process videos in background thread."""
@@ -245,7 +463,7 @@ class CADDocumenterGUI:
# Disable button during processing
self.btn_process.configure(state="disabled")
self.status_var.set("Processing...")
self.status_var.set(f"Processing {len(pending)} video(s)...")
if USE_CTK:
self.progress.set(0)
@@ -262,6 +480,8 @@ class CADDocumenterGUI:
self.root.after(0, lambda: self._on_process_complete(results))
except Exception as e:
import traceback
traceback.print_exc()
self.root.after(0, lambda: self._on_process_error(str(e)))
thread = threading.Thread(target=process_thread, daemon=True)
@@ -278,11 +498,13 @@ class CADDocumenterGUI:
self.btn_process.configure(state="normal")
self._refresh_video_list()
msg = f"Processed {results.get('processed', 0)} video(s)"
if self.export_only_var.get():
msg += f"\n\nExports ready! Tell Clawdbot:\n\"Process CAD report for {self.current_project.manifest.name}\""
name = self.current_project.manifest.name
msg = f"✓ Processed {results.get('processed', 0)} video(s)"
self.status_var.set("Complete!")
if self.export_only_var.get():
msg += f"\n\n📤 Exports ready in clawdbot_export/\n\nTell Mario:\n\"Process CAD session for {name}\""
self.status_var.set("✓ Complete!")
messagebox.showinfo("Success", msg)
def _on_process_error(self, error):
@@ -294,8 +516,8 @@ class CADDocumenterGUI:
self.progress.stop()
self.btn_process.configure(state="normal")
self.status_var.set("Error")
messagebox.showerror("Error", f"Processing failed: {error}")
self.status_var.set("Error")
messagebox.showerror("Error", f"Processing failed:\n\n{error}")
def run(self):
"""Run the application."""