refactor: OBS-only mode - remove broken screen recording

Simplified to just load OBS recordings:
- Removed all FFmpeg screen capture code
- Single 'Load OBS Recording' button
- Cleaner UI with progress indicator
- Handles videos without audio gracefully
- Remembers last project and video folder

Flow: Select Project → Load Video → Transcribe → Done

OBS handles recording, this tool handles processing.
This commit is contained in:
Mario Lavoie
2026-02-10 18:36:17 +00:00
parent 8c5e35c301
commit 7f8ef93247

View File

@@ -1,12 +1,14 @@
""" """
KB Capture GUI KB Capture GUI (OBS Mode)
Simple recording interface modeled after Voice Recorder. Load OBS recordings → Transcribe → Process for KB
Record → Pause → Resume → Stop → Transcribe → Done
Simple flow: Load Video → Transcribe → Done
""" """
import sys import sys
import json import json
import shutil
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -16,17 +18,16 @@ from datetime import datetime
try: try:
import customtkinter as ctk import customtkinter as ctk
from customtkinter import CTk, CTkFrame, CTkLabel, CTkButton, CTkEntry from customtkinter import CTk, CTkFrame, CTkLabel, CTkButton, CTkEntry
from customtkinter import CTkOptionMenu, CTkScrollableFrame, CTkToplevel from customtkinter import CTkOptionMenu, CTkToplevel, CTkProgressBar
HAS_CTK = True HAS_CTK = True
except ImportError: except ImportError:
HAS_CTK = False HAS_CTK = False
from .kb_capture import KBCaptureApp, AppState, AppStatus from .session import SessionManager, Session, SessionType, SessionStatus
from .session import SessionType
# ============================================================================ # ============================================================================
# THEME (matching Voice Recorder) # THEME
# ============================================================================ # ============================================================================
COLORS = { COLORS = {
@@ -115,7 +116,7 @@ Created: {datetime.now().strftime("%Y-%m-%d")}
# ============================================================================ # ============================================================================
class KBCaptureGUI: class KBCaptureGUI:
"""Main application window.""" """Main application window - OBS video loading only."""
def __init__(self): def __init__(self):
if not HAS_CTK: if not HAS_CTK:
@@ -131,37 +132,33 @@ class KBCaptureGUI:
if saved.exists(): if saved.exists():
self.projects_root = saved self.projects_root = saved
# App # Session manager
self.app = None self.session_manager = None
self.recording_indicator_visible = True
# Processing state
self.is_processing = False
# Window # Window
ctk.set_appearance_mode("dark") ctk.set_appearance_mode("dark")
self.window = CTk() self.window = CTk()
self.window.title("KB Capture") self.window.title("KB Capture")
self.window.geometry("400x600") self.window.geometry("420x520")
self.window.minsize(380, 550) self.window.minsize(400, 480)
self.window.configure(fg_color=COLORS["bg"]) self.window.configure(fg_color=COLORS["bg"])
self._build_ui() self._build_ui()
# Initialize app if we have a projects root # Initialize if we have a projects root
if self.projects_root: if self.projects_root:
self._init_app() self._init_session_manager()
self._refresh_projects() self._refresh_projects()
# Start indicator animation
self._update_indicator()
self.window.protocol("WM_DELETE_WINDOW", self._on_close) self.window.protocol("WM_DELETE_WINDOW", self._on_close)
def _init_app(self): def _init_session_manager(self):
"""Initialize the capture app.""" """Initialize session manager."""
self.app = KBCaptureApp( self.session_manager = SessionManager(self.projects_root)
projects_root=self.projects_root,
on_status_change=self._on_status_change,
)
def _build_ui(self): def _build_ui(self):
"""Build the interface.""" """Build the interface."""
@@ -170,12 +167,12 @@ class KBCaptureGUI:
# Header # Header
header = CTkFrame(main, fg_color="transparent") header = CTkFrame(main, fg_color="transparent")
header.pack(fill="x", pady=(0, 12)) header.pack(fill="x", pady=(0, 16))
CTkLabel( CTkLabel(
header, header,
text="KB Capture", text="KB Capture",
font=("Segoe UI Semibold", 18), font=("Segoe UI Semibold", 20),
text_color=COLORS["text"], text_color=COLORS["text"],
).pack(side="left") ).pack(side="left")
@@ -190,93 +187,48 @@ class KBCaptureGUI:
command=self._browse_folder, command=self._browse_folder,
).pack(side="right") ).pack(side="right")
# Load video button # Info card
CTkButton( info_frame = CTkFrame(main, fg_color=COLORS["bg_card"], corner_radius=10)
header, info_frame.pack(fill="x", pady=(0, 16))
text="📂 Load",
width=60,
height=32,
font=("", 10),
fg_color=COLORS["bg_card"],
hover_color=COLORS["bg_elevated"],
command=self._load_video,
).pack(side="right", padx=(0, 8))
# Timer card info_inner = CTkFrame(info_frame, fg_color="transparent")
timer_frame = CTkFrame(main, fg_color=COLORS["bg_card"], corner_radius=10) info_inner.pack(pady=16, padx=16, fill="x")
timer_frame.pack(fill="x", pady=(0, 12))
timer_inner = CTkFrame(timer_frame, fg_color="transparent") CTkLabel(
timer_inner.pack(pady=20) info_inner,
text="📹 Record with OBS, then load here",
font=("Segoe UI Semibold", 13),
text_color=COLORS["text"],
).pack(anchor="w")
# Recording indicator + timer CTkLabel(
timer_row = CTkFrame(timer_inner, fg_color="transparent") info_inner,
timer_row.pack() text="Video → Transcribe → Ready for Clawdbot",
self.indicator = CTkLabel(
timer_row,
text="",
font=("", 10),
text_color=COLORS["red"],
)
self.indicator.pack(side="left", padx=(0, 4))
self.timer_label = CTkLabel(
timer_row,
text="00:00:00",
font=("Consolas", 36, "bold"),
text_color=COLORS["text_muted"],
)
self.timer_label.pack(side="left")
self.status_label = CTkLabel(
timer_inner,
text="Select a project to start",
font=("", 11), font=("", 11),
text_color=COLORS["text_secondary"], text_color=COLORS["text_secondary"],
) ).pack(anchor="w", pady=(4, 0))
self.status_label.pack(pady=(8, 0))
# Recording info (what's being captured) # Status area
self.capture_info_label = CTkLabel( self.status_label = CTkLabel(
timer_inner, info_inner,
text="", text="",
font=("", 9), font=("", 11),
text_color=COLORS["text_muted"], text_color=COLORS["text_muted"],
) )
self.capture_info_label.pack(pady=(4, 0)) self.status_label.pack(anchor="w", pady=(8, 0))
# Recording controls # Progress bar (hidden by default)
controls = CTkFrame(main, fg_color="transparent") self.progress = CTkProgressBar(
controls.pack(fill="x", pady=12) info_inner,
controls.grid_columnconfigure((0, 1), weight=1) width=300,
height=6,
self.record_btn = CTkButton(
controls,
text="Record",
height=45,
font=("Segoe UI Semibold", 12),
fg_color=COLORS["red"],
hover_color="#dc2626",
state="disabled",
command=self._toggle_recording,
)
self.record_btn.grid(row=0, column=0, padx=(0, 6), sticky="ew")
self.pause_btn = CTkButton(
controls,
text="Pause",
height=45,
font=("", 12),
fg_color=COLORS["border"], fg_color=COLORS["border"],
hover_color=COLORS["bg_elevated"], progress_color=COLORS["blue"],
state="disabled",
command=self._toggle_pause,
) )
self.pause_btn.grid(row=0, column=1, padx=(6, 0), sticky="ew") # Don't pack yet - will show during processing
# Separator # Separator
CTkFrame(main, fg_color=COLORS["border"], height=1).pack(fill="x", pady=12) CTkFrame(main, fg_color=COLORS["border"], height=1).pack(fill="x", pady=8)
# Project selector # Project selector
proj_frame = CTkFrame(main, fg_color="transparent") proj_frame = CTkFrame(main, fg_color="transparent")
@@ -306,7 +258,7 @@ class KBCaptureGUI:
self.project_menu = CTkOptionMenu( self.project_menu = CTkOptionMenu(
proj_frame, proj_frame,
values=["(Select folder first)"], values=["(Select folder first)"],
width=340, width=360,
height=35, height=35,
fg_color=COLORS["bg_card"], fg_color=COLORS["bg_card"],
button_color=COLORS["bg_elevated"], button_color=COLORS["bg_elevated"],
@@ -324,7 +276,7 @@ class KBCaptureGUI:
self.name_entry = CTkEntry( self.name_entry = CTkEntry(
main, main,
placeholder_text="What are you working on?", placeholder_text="e.g., Vertical support walkthrough",
height=35, height=35,
fg_color=COLORS["bg_card"], fg_color=COLORS["bg_card"],
border_color=COLORS["border"], border_color=COLORS["border"],
@@ -332,15 +284,22 @@ class KBCaptureGUI:
self.name_entry.pack(fill="x") self.name_entry.pack(fill="x")
# Session type # Session type
CTkLabel(
main,
text="Session Type",
font=("Segoe UI Semibold", 11),
text_color=COLORS["text_secondary"],
).pack(anchor="w", pady=(16, 8))
type_frame = CTkFrame(main, fg_color="transparent") type_frame = CTkFrame(main, fg_color="transparent")
type_frame.pack(fill="x", pady=(12, 0)) type_frame.pack(fill="x")
type_frame.grid_columnconfigure((0, 1), weight=1) type_frame.grid_columnconfigure((0, 1), weight=1)
self.type_var = ctk.StringVar(value="design") self.type_var = ctk.StringVar(value="design")
self.design_btn = CTkButton( self.design_btn = CTkButton(
type_frame, type_frame,
text="🎨 Design", text="🎨 Design → KB/Design/",
height=40, height=40,
font=("", 11), font=("", 11),
fg_color=COLORS["blue"], fg_color=COLORS["blue"],
@@ -351,7 +310,7 @@ class KBCaptureGUI:
self.analysis_btn = CTkButton( self.analysis_btn = CTkButton(
type_frame, type_frame,
text="📊 Analysis", text="📊 Analysis → KB/Analysis/",
height=40, height=40,
font=("", 11), font=("", 11),
fg_color=COLORS["border"], fg_color=COLORS["border"],
@@ -360,71 +319,42 @@ class KBCaptureGUI:
) )
self.analysis_btn.grid(row=0, column=1, padx=(4, 0), sticky="ew") self.analysis_btn.grid(row=0, column=1, padx=(4, 0), sticky="ew")
# Screen selector (for multi-monitor setups)
screen_frame = CTkFrame(main, fg_color="transparent")
screen_frame.pack(fill="x", pady=(12, 0))
CTkLabel(
screen_frame,
text="Capture",
font=("Segoe UI Semibold", 11),
text_color=COLORS["text_secondary"],
).pack(side="left")
# Get available screens
self.screens = self._get_screens()
screen_values = ["All Screens"] + [f"Screen {i+1}" for i in range(len(self.screens))]
self.screen_menu = CTkOptionMenu(
screen_frame,
values=screen_values,
width=120,
height=28,
font=("", 10),
fg_color=COLORS["bg_card"],
button_color=COLORS["bg_elevated"],
button_hover_color=COLORS["border"],
)
self.screen_menu.pack(side="right")
self.screen_menu.set("All Screens")
# Spacer # Spacer
CTkFrame(main, fg_color="transparent").pack(fill="both", expand=True) CTkFrame(main, fg_color="transparent").pack(fill="both", expand=True)
# Load button (main action)
self.load_btn = CTkButton(
main,
text="📂 Load OBS Recording",
height=50,
font=("Segoe UI Semibold", 14),
fg_color=COLORS["blue"],
hover_color="#2563eb",
state="disabled",
command=self._load_video,
)
self.load_btn.pack(fill="x", pady=(16, 0))
# Recent sessions link
self.recent_label = CTkLabel(
main,
text="",
font=("", 10),
text_color=COLORS["text_muted"],
cursor="hand2",
)
self.recent_label.pack(pady=(12, 0))
# Folder path # Folder path
self.folder_label = CTkLabel( self.folder_label = CTkLabel(
main, main,
text=str(self.projects_root) if self.projects_root else "No folder selected", text=str(self.projects_root) if self.projects_root else "Click 📁 to select projects folder",
font=("", 9), font=("", 9),
text_color=COLORS["text_muted"], text_color=COLORS["text_muted"],
cursor="hand2", cursor="hand2",
) )
self.folder_label.pack(pady=(12, 0)) self.folder_label.pack(pady=(8, 0))
self.folder_label.bind("<Button-1>", lambda e: self._browse_folder()) self.folder_label.bind("<Button-1>", lambda e: self._browse_folder())
# Bottom row: hints + reset
bottom_row = CTkFrame(main, fg_color="transparent")
bottom_row.pack(fill="x", pady=(8, 0))
CTkLabel(
bottom_row,
text="Ctrl+Shift+R: Record/Stop",
font=("", 9),
text_color=COLORS["text_muted"],
).pack(side="left")
# Reset button (hidden until needed)
self.reset_btn = CTkButton(
bottom_row,
text="🔄 Reset",
width=60,
height=24,
font=("", 9),
fg_color=COLORS["red"],
hover_color="#dc2626",
command=self._force_reset,
)
# Don't pack initially - will show when stuck
def _set_type(self, type_id: str): def _set_type(self, type_id: str):
"""Set session type.""" """Set session type."""
@@ -432,72 +362,16 @@ class KBCaptureGUI:
if type_id == "design": if type_id == "design":
self.design_btn.configure(fg_color=COLORS["blue"]) self.design_btn.configure(fg_color=COLORS["blue"])
self.analysis_btn.configure(fg_color=COLORS["border"]) self.analysis_btn.configure(fg_color=COLORS["border"])
self.status_label.configure(text="Design session → KB/Design/", text_color=COLORS["blue"])
else: else:
self.design_btn.configure(fg_color=COLORS["border"]) self.design_btn.configure(fg_color=COLORS["border"])
self.analysis_btn.configure(fg_color=COLORS["orange"]) self.analysis_btn.configure(fg_color=COLORS["orange"])
self.status_label.configure(text="Analysis session → KB/Analysis/", text_color=COLORS["orange"])
def _get_screens(self) -> list:
"""Get list of available screens."""
screens = []
try:
if sys.platform == "win32":
import ctypes
user32 = ctypes.windll.user32
# EnumDisplayMonitors callback
def callback(hMonitor, hdcMonitor, lprcMonitor, dwData):
screens.append({
"handle": hMonitor,
"left": lprcMonitor.contents.left,
"top": lprcMonitor.contents.top,
"right": lprcMonitor.contents.right,
"bottom": lprcMonitor.contents.bottom,
})
return True
# Define RECT structure
class RECT(ctypes.Structure):
_fields_ = [
("left", ctypes.c_long),
("top", ctypes.c_long),
("right", ctypes.c_long),
("bottom", ctypes.c_long),
]
MONITORENUMPROC = ctypes.WINFUNCTYPE(
ctypes.c_bool, ctypes.c_ulong, ctypes.c_ulong,
ctypes.POINTER(RECT), ctypes.c_double
)
user32.EnumDisplayMonitors(None, None, MONITORENUMPROC(callback), 0)
except Exception as e:
print(f"Error getting screens: {e}")
return screens if screens else [{"name": "Primary"}]
def _update_indicator(self):
"""Animate recording indicator."""
if self.app and self.app.state == AppState.RECORDING:
self.recording_indicator_visible = not self.recording_indicator_visible
self.indicator.configure(
text="" if self.recording_indicator_visible else "",
text_color=COLORS["red"],
)
elif self.app and self.app.state == AppState.PAUSED:
self.indicator.configure(text="", text_color=COLORS["orange"])
else:
self.indicator.configure(text="")
self.window.after(500, self._update_indicator)
def _browse_folder(self): def _browse_folder(self):
"""Browse for projects folder.""" """Browse for projects folder."""
initial = str(self.projects_root) if self.projects_root else str(Path.home()) initial = str(self.projects_root) if self.projects_root else str(Path.home())
folder = filedialog.askdirectory( folder = filedialog.askdirectory(
title="Select Projects Folder", title="Select Projects Folder (e.g., ATODrive/Projects)",
initialdir=initial, initialdir=initial,
) )
@@ -508,25 +382,42 @@ class KBCaptureGUI:
self.config["projects_root"] = str(self.projects_root) self.config["projects_root"] = str(self.projects_root)
save_config(self.config) save_config(self.config)
self._init_app() self._init_session_manager()
self._refresh_projects() self._refresh_projects()
def _refresh_projects(self): def _refresh_projects(self):
"""Refresh project list.""" """Refresh project list."""
if not self.app: if not self.session_manager:
self.project_menu.configure(values=["(Select folder first)"]) self.project_menu.configure(values=["(Select folder first)"])
return return
projects = self.app.session_manager.list_projects() projects = self.session_manager.list_projects()
if projects: if projects:
self.project_menu.configure(values=projects) self.project_menu.configure(values=projects)
self.project_menu.set(projects[0]) # Try to restore last used project
self.record_btn.configure(state="normal") last_project = self.config.get("last_project")
self.status_label.configure(text="Ready to record") if last_project in projects:
self.project_menu.set(last_project)
else:
self.project_menu.set(projects[0])
self.load_btn.configure(state="normal")
self._update_recent()
else: else:
self.project_menu.configure(values=["(No projects - click + New)"]) self.project_menu.configure(values=["(No projects - click + New)"])
self.record_btn.configure(state="disabled") self.load_btn.configure(state="disabled")
self.status_label.configure(text="Create a project first")
def _update_recent(self):
"""Update recent sessions count."""
project = self.project_menu.get()
if project.startswith("("):
self.recent_label.configure(text="")
return
sessions = self.session_manager.list_sessions(project)
if sessions:
self.recent_label.configure(text=f"📋 {len(sessions)} previous sessions")
else:
self.recent_label.configure(text="No sessions yet")
def _new_project(self): def _new_project(self):
"""Create new project.""" """Create new project."""
@@ -605,13 +496,8 @@ class KBCaptureGUI:
name_entry.bind("<Return>", lambda e: create()) name_entry.bind("<Return>", lambda e: create())
def _load_video(self): def _load_video(self):
"""Load an existing video file for processing.""" """Load an OBS recording for processing."""
if not self.app: if self.is_processing:
messagebox.showwarning("No Folder", "Select a projects folder first")
return
if self.app.state != AppState.IDLE:
messagebox.showwarning("Busy", "Stop current recording first")
return return
project = self.project_menu.get() project = self.project_menu.get()
@@ -619,11 +505,18 @@ class KBCaptureGUI:
messagebox.showwarning("No Project", "Select a project first") messagebox.showwarning("No Project", "Select a project first")
return return
# Save last project
self.config["last_project"] = project
save_config(self.config)
# Ask for video file # Ask for video file
initial_dir = self.config.get("last_video_dir", str(Path.home() / "Videos"))
video_path = filedialog.askopenfilename( video_path = filedialog.askopenfilename(
title="Select Video to Process", title="Select OBS Recording",
initialdir=initial_dir,
filetypes=[ filetypes=[
("Video files", "*.mp4 *.mkv *.avi *.mov *.webm"), ("Video files", "*.mp4 *.mkv *.avi *.mov *.webm *.flv"),
("All files", "*.*"), ("All files", "*.*"),
], ],
) )
@@ -636,199 +529,181 @@ class KBCaptureGUI:
messagebox.showerror("Error", "File not found") messagebox.showerror("Error", "File not found")
return return
# Save last video directory
self.config["last_video_dir"] = str(video_path.parent)
save_config(self.config)
# Get session name
name = self.name_entry.get().strip() or video_path.stem name = self.name_entry.get().strip() or video_path.stem
session_type = SessionType.DESIGN if self.type_var.get() == "design" else SessionType.ANALYSIS session_type = SessionType.DESIGN if self.type_var.get() == "design" else SessionType.ANALYSIS
# Create session folder and copy/link video # Start processing
import shutil self.is_processing = True
session = self.app.session_manager.start_session(name, project, session_type) self._set_processing_ui(True, "Copying video...")
session_dir = self.app.session_manager.get_session_dir()
# Copy video to session folder
dest_video = session_dir / "recording.mp4"
self.status_label.configure(text="Copying video...", text_color=COLORS["orange"])
self.window.update()
# Run in background
threading.Thread(
target=self._process_video,
args=(video_path, name, project, session_type),
daemon=True,
).start()
def _process_video(self, video_path: Path, name: str, project: str, session_type: SessionType):
"""Process video in background thread."""
try: try:
# Create session
session = self.session_manager.start_session(name, project, session_type)
session_dir = self.session_manager.get_session_dir()
# Copy video
dest_video = session_dir / "recording.mp4"
self._update_status("Copying video...")
shutil.copy2(video_path, dest_video) shutil.copy2(video_path, dest_video)
# Transcribe
self._update_status("Loading Whisper model...")
self._transcribe(dest_video, session)
except Exception as e: except Exception as e:
messagebox.showerror("Error", f"Failed to copy video: {e}") self._update_status(f"Error: {e}", error=True)
self.app.session_manager.cancel_session() if self.session_manager.current_session:
return self.session_manager.cancel_session()
finally:
# Now transcribe self.is_processing = False
self.status_label.configure(text="Transcribing...", text_color=COLORS["orange"]) self.window.after(0, lambda: self._set_processing_ui(False))
self.record_btn.configure(state="disabled")
self.window.update()
# Run transcription in background
def do_transcribe():
self.app._transcribe(dest_video)
self.app.state = AppState.TRANSCRIBING
threading.Thread(target=do_transcribe, daemon=True).start()
def _toggle_recording(self): def _transcribe(self, video_path: Path, session: Session):
"""Start or stop recording.""" """Transcribe video with Whisper."""
if not self.app:
return
if self.app.state == AppState.IDLE:
self._start_recording()
elif self.app.state in (AppState.RECORDING, AppState.PAUSED):
self._stop_recording()
def _start_recording(self):
"""Start recording session."""
# Prevent double-click
if self.app and self.app.state != AppState.IDLE:
return
project = self.project_menu.get()
if project.startswith("("):
messagebox.showwarning("No Project", "Select a project first")
return
name = self.name_entry.get().strip() or f"Session {datetime.now().strftime('%H:%M')}"
session_type = SessionType.DESIGN if self.type_var.get() == "design" else SessionType.ANALYSIS
# Disable Record button immediately to prevent double-click
self.record_btn.configure(state="disabled", text="Starting...")
self.window.update()
try: try:
# Get selected screen import whisper
screen_selection = self.screen_menu.get()
screen_index = None
if screen_selection != "All Screens":
try:
screen_index = int(screen_selection.split()[-1]) - 1
except:
pass
self.app.start_session(name, project, session_type, screen_index=screen_index) self._update_status("Transcribing (this takes a while)...")
model = whisper.load_model("base")
# Update UI for recording state # Whisper can handle video files directly
self.record_btn.configure(text="Stop", fg_color=COLORS["border"], state="normal") result = model.transcribe(str(video_path), language="en", verbose=False)
self.pause_btn.configure(state="normal", fg_color=COLORS["orange"])
# Save transcript
transcript_path = video_path.parent / "transcript.json"
with open(transcript_path, "w") as f:
json.dump(result, f, indent=2)
# Find screenshot triggers
triggers = []
for segment in result.get("segments", []):
text = segment.get("text", "").lower()
if "screenshot" in text:
triggers.append({
"timestamp": segment.get("start", 0),
"text": segment.get("text", ""),
})
# Get video duration
duration = self._get_video_duration(video_path)
# Save metadata
metadata = {
"session_id": session.id,
"name": session.name,
"project": session.project,
"session_type": session.session_type.value,
"created_at": session.created_at.isoformat(),
"duration": duration,
"status": "ready",
"screenshot_triggers": triggers,
"source_file": video_path.name,
"files": {
"video": "recording.mp4",
"transcript": "transcript.json",
},
}
metadata_path = video_path.parent / "metadata.json"
with open(metadata_path, "w") as f:
json.dump(metadata, f, indent=2)
# Update session
self.session_manager.set_duration(duration)
self.session_manager.set_transcript("transcript.json")
self.session_manager.end_session()
self._update_status(f"✅ Done! {len(triggers)} screenshot triggers found", success=True)
self.window.after(0, self._update_recent)
except ImportError:
self._update_status("⚠️ Saved (Whisper not installed)", warning=True)
self.session_manager.end_session()
except Exception as e:
error_msg = str(e)
if "audio" in error_msg.lower():
self._update_status("⚠️ Saved (video has no audio track)", warning=True)
# Still save the session without transcript
self.session_manager.end_session()
else:
raise
def _get_video_duration(self, video_path: Path) -> float:
"""Get video duration using ffprobe."""
try:
import subprocess
result = subprocess.run(
[
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(video_path),
],
capture_output=True,
text=True,
timeout=30,
)
return float(result.stdout.strip())
except:
return 0.0
def _update_status(self, text: str, error: bool = False, success: bool = False, warning: bool = False):
"""Update status label from any thread."""
def update():
if error:
color = COLORS["red"]
elif success:
color = COLORS["green"]
elif warning:
color = COLORS["orange"]
else:
color = COLORS["text_secondary"]
self.status_label.configure(text=text, text_color=color)
self.window.after(0, update)
def _set_processing_ui(self, processing: bool, status: str = ""):
"""Update UI for processing state."""
if processing:
self.load_btn.configure(state="disabled", text="Processing...")
self.project_menu.configure(state="disabled") self.project_menu.configure(state="disabled")
self.name_entry.configure(state="disabled") self.name_entry.configure(state="disabled")
self.design_btn.configure(state="disabled") self.design_btn.configure(state="disabled")
self.analysis_btn.configure(state="disabled") self.analysis_btn.configure(state="disabled")
self.screen_menu.configure(state="disabled") self.progress.pack(fill="x", pady=(8, 0))
self.timer_label.configure(text_color=COLORS["text"]) self.progress.configure(mode="indeterminate")
self.status_label.configure(text="Recording...", text_color=COLORS["red"]) self.progress.start()
if status:
# Show what's being captured self.status_label.configure(text=status, text_color=COLORS["text_secondary"])
capture_info = f"🖥️ {screen_selection}"
audio_devices = self.app.recorder.list_audio_devices() if hasattr(self.app, 'recorder') else []
if audio_devices:
capture_info += f" + 🎤 {audio_devices[0][:20]}"
else:
capture_info += " (no mic)"
self.capture_info_label.configure(text=capture_info)
except Exception as e:
# Reset UI on failure
self._reset_ui_to_idle()
messagebox.showerror("Recording Failed", str(e))
def _reset_ui_to_idle(self):
"""Reset UI to idle state (for error recovery)."""
self.record_btn.configure(text="Record", fg_color=COLORS["red"], state="normal")
self.pause_btn.configure(state="disabled", text="Pause", fg_color=COLORS["border"])
self.project_menu.configure(state="normal")
self.name_entry.configure(state="normal")
self.design_btn.configure(state="normal")
self.analysis_btn.configure(state="normal")
self.screen_menu.configure(state="normal")
self.timer_label.configure(text="00:00:00", text_color=COLORS["text_muted"])
self.capture_info_label.configure(text="")
self.status_label.configure(text="Ready to record", text_color=COLORS["text_secondary"])
# Hide reset button
self.reset_btn.pack_forget()
def _show_reset_button(self):
"""Show reset button when stuck."""
self.reset_btn.pack(side="right")
def _force_reset(self):
"""Force reset the app to idle state."""
if self.app:
self.app.force_reset()
self._reset_ui_to_idle()
self.status_label.configure(text="Reset complete - ready to record", text_color=COLORS["green"])
def _stop_recording(self):
"""Stop recording and transcribe."""
if not self.app:
return
self.app.stop()
self.record_btn.configure(text="Record", fg_color=COLORS["red"], state="disabled")
self.pause_btn.configure(state="disabled", text="Pause", fg_color=COLORS["border"])
self.timer_label.configure(text_color=COLORS["green"])
self.status_label.configure(text="Transcribing...", text_color=COLORS["orange"])
def _toggle_pause(self):
"""Toggle pause/resume."""
if not self.app:
return
self.app.toggle_pause()
if self.app.state == AppState.PAUSED:
self.pause_btn.configure(text="Resume", fg_color=COLORS["green"])
self.timer_label.configure(text_color=COLORS["orange"])
self.status_label.configure(text="Paused", text_color=COLORS["orange"])
else: else:
self.pause_btn.configure(text="Pause", fg_color=COLORS["orange"]) self.load_btn.configure(state="normal", text="📂 Load OBS Recording")
self.timer_label.configure(text_color=COLORS["text"]) self.project_menu.configure(state="normal")
self.status_label.configure(text="Recording...", text_color=COLORS["red"]) self.name_entry.configure(state="normal")
self.design_btn.configure(state="normal")
def _on_status_change(self, status: AppStatus): self.analysis_btn.configure(state="normal")
"""Handle status updates.""" self.progress.stop()
self.window.after(0, lambda: self._update_ui(status)) self.progress.pack_forget()
def _update_ui(self, status: AppStatus):
"""Update UI from status."""
# Timer
if status.state in (AppState.RECORDING, AppState.PAUSED):
secs = int(status.duration)
hours = secs // 3600
mins = (secs % 3600) // 60
secs = secs % 60
self.timer_label.configure(text=f"{hours:02d}:{mins:02d}:{secs:02d}")
# Back to idle (including after errors)
if status.state == AppState.IDLE:
self._reset_ui_to_idle()
self.name_entry.delete(0, "end") self.name_entry.delete(0, "end")
# Keep the timer showing final time if we just finished
if "Stopped" in status.message or "Done" in status.message:
self.timer_label.configure(text_color=COLORS["green"])
# Status message
if status.message:
# Color code the message
if "failed" in status.message.lower() or "error" in status.message.lower():
self.status_label.configure(text=status.message, text_color=COLORS["red"])
# Show reset button on errors
self._show_reset_button()
elif "done" in status.message.lower() or "saved" in status.message.lower():
self.status_label.configure(text=status.message, text_color=COLORS["green"])
else:
self.status_label.configure(text=status.message, text_color=COLORS["text_secondary"])
def _on_close(self): def _on_close(self):
"""Handle window close.""" """Handle window close."""
if self.app and self.app.state != AppState.IDLE: if self.is_processing:
if messagebox.askyesno("Recording Active", "Cancel current recording?"): if not messagebox.askyesno("Processing", "Video is being processed. Close anyway?"):
self.app.cancel()
else:
return return
self.window.destroy() self.window.destroy()
def run(self): def run(self):