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:
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
KB Capture GUI
|
||||
KB Capture GUI (OBS Mode)
|
||||
|
||||
Simple recording interface modeled after Voice Recorder.
|
||||
Record → Pause → Resume → Stop → Transcribe → Done
|
||||
Load OBS recordings → Transcribe → Process for KB
|
||||
|
||||
Simple flow: Load Video → Transcribe → Done
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -16,17 +18,16 @@ from datetime import datetime
|
||||
try:
|
||||
import customtkinter as ctk
|
||||
from customtkinter import CTk, CTkFrame, CTkLabel, CTkButton, CTkEntry
|
||||
from customtkinter import CTkOptionMenu, CTkScrollableFrame, CTkToplevel
|
||||
from customtkinter import CTkOptionMenu, CTkToplevel, CTkProgressBar
|
||||
HAS_CTK = True
|
||||
except ImportError:
|
||||
HAS_CTK = False
|
||||
|
||||
from .kb_capture import KBCaptureApp, AppState, AppStatus
|
||||
from .session import SessionType
|
||||
from .session import SessionManager, Session, SessionType, SessionStatus
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# THEME (matching Voice Recorder)
|
||||
# THEME
|
||||
# ============================================================================
|
||||
|
||||
COLORS = {
|
||||
@@ -115,7 +116,7 @@ Created: {datetime.now().strftime("%Y-%m-%d")}
|
||||
# ============================================================================
|
||||
|
||||
class KBCaptureGUI:
|
||||
"""Main application window."""
|
||||
"""Main application window - OBS video loading only."""
|
||||
|
||||
def __init__(self):
|
||||
if not HAS_CTK:
|
||||
@@ -131,37 +132,33 @@ class KBCaptureGUI:
|
||||
if saved.exists():
|
||||
self.projects_root = saved
|
||||
|
||||
# App
|
||||
self.app = None
|
||||
self.recording_indicator_visible = True
|
||||
# Session manager
|
||||
self.session_manager = None
|
||||
|
||||
# Processing state
|
||||
self.is_processing = False
|
||||
|
||||
# Window
|
||||
ctk.set_appearance_mode("dark")
|
||||
|
||||
self.window = CTk()
|
||||
self.window.title("KB Capture")
|
||||
self.window.geometry("400x600")
|
||||
self.window.minsize(380, 550)
|
||||
self.window.geometry("420x520")
|
||||
self.window.minsize(400, 480)
|
||||
self.window.configure(fg_color=COLORS["bg"])
|
||||
|
||||
self._build_ui()
|
||||
|
||||
# Initialize app if we have a projects root
|
||||
# Initialize if we have a projects root
|
||||
if self.projects_root:
|
||||
self._init_app()
|
||||
self._init_session_manager()
|
||||
self._refresh_projects()
|
||||
|
||||
# Start indicator animation
|
||||
self._update_indicator()
|
||||
|
||||
self.window.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
def _init_app(self):
|
||||
"""Initialize the capture app."""
|
||||
self.app = KBCaptureApp(
|
||||
projects_root=self.projects_root,
|
||||
on_status_change=self._on_status_change,
|
||||
)
|
||||
def _init_session_manager(self):
|
||||
"""Initialize session manager."""
|
||||
self.session_manager = SessionManager(self.projects_root)
|
||||
|
||||
def _build_ui(self):
|
||||
"""Build the interface."""
|
||||
@@ -170,12 +167,12 @@ class KBCaptureGUI:
|
||||
|
||||
# Header
|
||||
header = CTkFrame(main, fg_color="transparent")
|
||||
header.pack(fill="x", pady=(0, 12))
|
||||
header.pack(fill="x", pady=(0, 16))
|
||||
|
||||
CTkLabel(
|
||||
header,
|
||||
text="KB Capture",
|
||||
font=("Segoe UI Semibold", 18),
|
||||
font=("Segoe UI Semibold", 20),
|
||||
text_color=COLORS["text"],
|
||||
).pack(side="left")
|
||||
|
||||
@@ -190,93 +187,48 @@ class KBCaptureGUI:
|
||||
command=self._browse_folder,
|
||||
).pack(side="right")
|
||||
|
||||
# Load video button
|
||||
CTkButton(
|
||||
header,
|
||||
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))
|
||||
# Info card
|
||||
info_frame = CTkFrame(main, fg_color=COLORS["bg_card"], corner_radius=10)
|
||||
info_frame.pack(fill="x", pady=(0, 16))
|
||||
|
||||
# Timer card
|
||||
timer_frame = CTkFrame(main, fg_color=COLORS["bg_card"], corner_radius=10)
|
||||
timer_frame.pack(fill="x", pady=(0, 12))
|
||||
info_inner = CTkFrame(info_frame, fg_color="transparent")
|
||||
info_inner.pack(pady=16, padx=16, fill="x")
|
||||
|
||||
timer_inner = CTkFrame(timer_frame, fg_color="transparent")
|
||||
timer_inner.pack(pady=20)
|
||||
CTkLabel(
|
||||
info_inner,
|
||||
text="📹 Record with OBS, then load here",
|
||||
font=("Segoe UI Semibold", 13),
|
||||
text_color=COLORS["text"],
|
||||
).pack(anchor="w")
|
||||
|
||||
# Recording indicator + timer
|
||||
timer_row = CTkFrame(timer_inner, fg_color="transparent")
|
||||
timer_row.pack()
|
||||
|
||||
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",
|
||||
CTkLabel(
|
||||
info_inner,
|
||||
text="Video → Transcribe → Ready for Clawdbot",
|
||||
font=("", 11),
|
||||
text_color=COLORS["text_secondary"],
|
||||
)
|
||||
self.status_label.pack(pady=(8, 0))
|
||||
).pack(anchor="w", pady=(4, 0))
|
||||
|
||||
# Recording info (what's being captured)
|
||||
self.capture_info_label = CTkLabel(
|
||||
timer_inner,
|
||||
# Status area
|
||||
self.status_label = CTkLabel(
|
||||
info_inner,
|
||||
text="",
|
||||
font=("", 9),
|
||||
font=("", 11),
|
||||
text_color=COLORS["text_muted"],
|
||||
)
|
||||
self.capture_info_label.pack(pady=(4, 0))
|
||||
self.status_label.pack(anchor="w", pady=(8, 0))
|
||||
|
||||
# Recording controls
|
||||
controls = CTkFrame(main, fg_color="transparent")
|
||||
controls.pack(fill="x", pady=12)
|
||||
controls.grid_columnconfigure((0, 1), weight=1)
|
||||
|
||||
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),
|
||||
# Progress bar (hidden by default)
|
||||
self.progress = CTkProgressBar(
|
||||
info_inner,
|
||||
width=300,
|
||||
height=6,
|
||||
fg_color=COLORS["border"],
|
||||
hover_color=COLORS["bg_elevated"],
|
||||
state="disabled",
|
||||
command=self._toggle_pause,
|
||||
progress_color=COLORS["blue"],
|
||||
)
|
||||
self.pause_btn.grid(row=0, column=1, padx=(6, 0), sticky="ew")
|
||||
# Don't pack yet - will show during processing
|
||||
|
||||
# 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
|
||||
proj_frame = CTkFrame(main, fg_color="transparent")
|
||||
@@ -306,7 +258,7 @@ class KBCaptureGUI:
|
||||
self.project_menu = CTkOptionMenu(
|
||||
proj_frame,
|
||||
values=["(Select folder first)"],
|
||||
width=340,
|
||||
width=360,
|
||||
height=35,
|
||||
fg_color=COLORS["bg_card"],
|
||||
button_color=COLORS["bg_elevated"],
|
||||
@@ -324,7 +276,7 @@ class KBCaptureGUI:
|
||||
|
||||
self.name_entry = CTkEntry(
|
||||
main,
|
||||
placeholder_text="What are you working on?",
|
||||
placeholder_text="e.g., Vertical support walkthrough",
|
||||
height=35,
|
||||
fg_color=COLORS["bg_card"],
|
||||
border_color=COLORS["border"],
|
||||
@@ -332,15 +284,22 @@ class KBCaptureGUI:
|
||||
self.name_entry.pack(fill="x")
|
||||
|
||||
# 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.pack(fill="x", pady=(12, 0))
|
||||
type_frame.pack(fill="x")
|
||||
type_frame.grid_columnconfigure((0, 1), weight=1)
|
||||
|
||||
self.type_var = ctk.StringVar(value="design")
|
||||
|
||||
self.design_btn = CTkButton(
|
||||
type_frame,
|
||||
text="🎨 Design",
|
||||
text="🎨 Design → KB/Design/",
|
||||
height=40,
|
||||
font=("", 11),
|
||||
fg_color=COLORS["blue"],
|
||||
@@ -351,7 +310,7 @@ class KBCaptureGUI:
|
||||
|
||||
self.analysis_btn = CTkButton(
|
||||
type_frame,
|
||||
text="📊 Analysis",
|
||||
text="📊 Analysis → KB/Analysis/",
|
||||
height=40,
|
||||
font=("", 11),
|
||||
fg_color=COLORS["border"],
|
||||
@@ -360,71 +319,42 @@ class KBCaptureGUI:
|
||||
)
|
||||
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
|
||||
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
|
||||
self.folder_label = CTkLabel(
|
||||
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),
|
||||
text_color=COLORS["text_muted"],
|
||||
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())
|
||||
|
||||
# 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):
|
||||
"""Set session type."""
|
||||
@@ -432,72 +362,16 @@ class KBCaptureGUI:
|
||||
if type_id == "design":
|
||||
self.design_btn.configure(fg_color=COLORS["blue"])
|
||||
self.analysis_btn.configure(fg_color=COLORS["border"])
|
||||
self.status_label.configure(text="Design session → KB/Design/", text_color=COLORS["blue"])
|
||||
else:
|
||||
self.design_btn.configure(fg_color=COLORS["border"])
|
||||
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):
|
||||
"""Browse for projects folder."""
|
||||
initial = str(self.projects_root) if self.projects_root else str(Path.home())
|
||||
|
||||
folder = filedialog.askdirectory(
|
||||
title="Select Projects Folder",
|
||||
title="Select Projects Folder (e.g., ATODrive/Projects)",
|
||||
initialdir=initial,
|
||||
)
|
||||
|
||||
@@ -508,25 +382,42 @@ class KBCaptureGUI:
|
||||
self.config["projects_root"] = str(self.projects_root)
|
||||
save_config(self.config)
|
||||
|
||||
self._init_app()
|
||||
self._init_session_manager()
|
||||
self._refresh_projects()
|
||||
|
||||
def _refresh_projects(self):
|
||||
"""Refresh project list."""
|
||||
if not self.app:
|
||||
if not self.session_manager:
|
||||
self.project_menu.configure(values=["(Select folder first)"])
|
||||
return
|
||||
|
||||
projects = self.app.session_manager.list_projects()
|
||||
projects = self.session_manager.list_projects()
|
||||
if projects:
|
||||
self.project_menu.configure(values=projects)
|
||||
self.project_menu.set(projects[0])
|
||||
self.record_btn.configure(state="normal")
|
||||
self.status_label.configure(text="Ready to record")
|
||||
# Try to restore last used project
|
||||
last_project = self.config.get("last_project")
|
||||
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:
|
||||
self.project_menu.configure(values=["(No projects - click + New)"])
|
||||
self.record_btn.configure(state="disabled")
|
||||
self.status_label.configure(text="Create a project first")
|
||||
self.load_btn.configure(state="disabled")
|
||||
|
||||
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):
|
||||
"""Create new project."""
|
||||
@@ -605,13 +496,8 @@ class KBCaptureGUI:
|
||||
name_entry.bind("<Return>", lambda e: create())
|
||||
|
||||
def _load_video(self):
|
||||
"""Load an existing video file for processing."""
|
||||
if not self.app:
|
||||
messagebox.showwarning("No Folder", "Select a projects folder first")
|
||||
return
|
||||
|
||||
if self.app.state != AppState.IDLE:
|
||||
messagebox.showwarning("Busy", "Stop current recording first")
|
||||
"""Load an OBS recording for processing."""
|
||||
if self.is_processing:
|
||||
return
|
||||
|
||||
project = self.project_menu.get()
|
||||
@@ -619,11 +505,18 @@ class KBCaptureGUI:
|
||||
messagebox.showwarning("No Project", "Select a project first")
|
||||
return
|
||||
|
||||
# Save last project
|
||||
self.config["last_project"] = project
|
||||
save_config(self.config)
|
||||
|
||||
# Ask for video file
|
||||
initial_dir = self.config.get("last_video_dir", str(Path.home() / "Videos"))
|
||||
|
||||
video_path = filedialog.askopenfilename(
|
||||
title="Select Video to Process",
|
||||
title="Select OBS Recording",
|
||||
initialdir=initial_dir,
|
||||
filetypes=[
|
||||
("Video files", "*.mp4 *.mkv *.avi *.mov *.webm"),
|
||||
("Video files", "*.mp4 *.mkv *.avi *.mov *.webm *.flv"),
|
||||
("All files", "*.*"),
|
||||
],
|
||||
)
|
||||
@@ -636,199 +529,181 @@ class KBCaptureGUI:
|
||||
messagebox.showerror("Error", "File not found")
|
||||
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
|
||||
session_type = SessionType.DESIGN if self.type_var.get() == "design" else SessionType.ANALYSIS
|
||||
|
||||
# Create session folder and copy/link video
|
||||
import shutil
|
||||
session = self.app.session_manager.start_session(name, project, session_type)
|
||||
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()
|
||||
# Start processing
|
||||
self.is_processing = True
|
||||
self._set_processing_ui(True, "Copying video...")
|
||||
|
||||
# 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:
|
||||
# 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)
|
||||
|
||||
# Transcribe
|
||||
self._update_status("Loading Whisper model...")
|
||||
self._transcribe(dest_video, session)
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to copy video: {e}")
|
||||
self.app.session_manager.cancel_session()
|
||||
return
|
||||
|
||||
# Now transcribe
|
||||
self.status_label.configure(text="Transcribing...", text_color=COLORS["orange"])
|
||||
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()
|
||||
self._update_status(f"Error: {e}", error=True)
|
||||
if self.session_manager.current_session:
|
||||
self.session_manager.cancel_session()
|
||||
finally:
|
||||
self.is_processing = False
|
||||
self.window.after(0, lambda: self._set_processing_ui(False))
|
||||
|
||||
def _toggle_recording(self):
|
||||
"""Start or stop recording."""
|
||||
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()
|
||||
|
||||
def _transcribe(self, video_path: Path, session: Session):
|
||||
"""Transcribe video with Whisper."""
|
||||
try:
|
||||
# Get selected screen
|
||||
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
|
||||
import whisper
|
||||
|
||||
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
|
||||
self.record_btn.configure(text="Stop", fg_color=COLORS["border"], state="normal")
|
||||
self.pause_btn.configure(state="normal", fg_color=COLORS["orange"])
|
||||
# Whisper can handle video files directly
|
||||
result = model.transcribe(str(video_path), language="en", verbose=False)
|
||||
|
||||
# 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.name_entry.configure(state="disabled")
|
||||
self.design_btn.configure(state="disabled")
|
||||
self.analysis_btn.configure(state="disabled")
|
||||
self.screen_menu.configure(state="disabled")
|
||||
self.timer_label.configure(text_color=COLORS["text"])
|
||||
self.status_label.configure(text="Recording...", text_color=COLORS["red"])
|
||||
|
||||
# Show what's being captured
|
||||
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"])
|
||||
self.progress.pack(fill="x", pady=(8, 0))
|
||||
self.progress.configure(mode="indeterminate")
|
||||
self.progress.start()
|
||||
if status:
|
||||
self.status_label.configure(text=status, text_color=COLORS["text_secondary"])
|
||||
else:
|
||||
self.pause_btn.configure(text="Pause", fg_color=COLORS["orange"])
|
||||
self.timer_label.configure(text_color=COLORS["text"])
|
||||
self.status_label.configure(text="Recording...", text_color=COLORS["red"])
|
||||
|
||||
def _on_status_change(self, status: AppStatus):
|
||||
"""Handle status updates."""
|
||||
self.window.after(0, lambda: self._update_ui(status))
|
||||
|
||||
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.load_btn.configure(state="normal", text="📂 Load OBS Recording")
|
||||
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.progress.stop()
|
||||
self.progress.pack_forget()
|
||||
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):
|
||||
"""Handle window close."""
|
||||
if self.app and self.app.state != AppState.IDLE:
|
||||
if messagebox.askyesno("Recording Active", "Cancel current recording?"):
|
||||
self.app.cancel()
|
||||
else:
|
||||
if self.is_processing:
|
||||
if not messagebox.askyesno("Processing", "Video is being processed. Close anyway?"):
|
||||
return
|
||||
|
||||
self.window.destroy()
|
||||
|
||||
def run(self):
|
||||
|
||||
Reference in New Issue
Block a user