Files
CAD-Documenter/src/cad_documenter/kb_capture.py
Mario Lavoie d5371cfe75 Add KB Capture v2 - clip-based recording system
New features:
- Clip-based workflow: record short clips, keep or delete
- Toggle recording with Ctrl+Shift+R
- Session management (start, clips, end)
- Modern CustomTkinter GUI with dark theme
- Global hotkeys for hands-free control
- Whisper transcription (local, no API)
- FFmpeg screen + audio capture
- Export to clawdbot_export/ for Mario processing

Files added:
- recorder.py: FFmpeg screen recording
- session.py: Session/clip management
- hotkeys.py: Global hotkey registration
- kb_capture.py: Main application logic
- gui_capture.py: Modern GUI
- export.py: Merge clips, transcribe, export

Docs:
- docs/KB-CAPTURE.md: Full documentation

Entry point: uv run kb-capture
2026-02-09 12:50:22 +00:00

365 lines
12 KiB
Python

"""
KB Capture - Knowledge Base Recording Tool
Main application that ties together:
- Screen recording
- Session/clip management
- Hotkey control
- System tray integration
- GUI interface
"""
import sys
import threading
import time
from pathlib import Path
from typing import Optional, Callable
from dataclasses import dataclass
from enum import Enum
from .recorder import ScreenRecorder, RecordingConfig
from .session import SessionManager, Session, Clip, ClipStatus, SessionType
from .hotkeys import HotkeyManager
class AppState(Enum):
"""Application state machine."""
IDLE = "idle" # No session, ready to start
SESSION_ACTIVE = "session" # Session started, not recording
RECORDING = "recording" # Currently recording a clip
PREVIEW = "preview" # Clip just recorded, awaiting decision
@dataclass
class AppStatus:
"""Current application status for UI updates."""
state: AppState
session_name: Optional[str] = None
project: Optional[str] = None
session_type: Optional[SessionType] = None
clip_count: int = 0
total_duration: float = 0.0
current_clip_duration: float = 0.0
last_clip_duration: float = 0.0
message: str = ""
class KBCaptureApp:
"""
Main KB Capture application.
Controls recording flow:
1. Start session (project, name, type)
2. Toggle recording to create clips
3. Keep or delete clips
4. End session to export
"""
def __init__(
self,
base_path: Path,
on_status_change: Optional[Callable[[AppStatus], None]] = None,
):
self.base_path = Path(base_path)
self.on_status_change = on_status_change or (lambda x: None)
# Components
self.session_manager = SessionManager(self.base_path)
self.recorder = ScreenRecorder(on_status=self._log)
self.hotkeys = HotkeyManager()
# State
self.state = AppState.IDLE
self._recording_start_time: Optional[float] = None
self._duration_thread: Optional[threading.Thread] = None
self._running = False
# Setup hotkeys
self.hotkeys.set_callback("toggle_recording", self.toggle_recording)
self.hotkeys.set_callback("keep_clip", self.keep_last_clip)
self.hotkeys.set_callback("delete_clip", self.delete_last_clip)
self.hotkeys.set_callback("end_session", self.end_session)
def _log(self, message: str) -> None:
"""Log a message and update status."""
print(f"[KB Capture] {message}")
self._update_status(message=message)
def _update_status(self, message: str = "") -> None:
"""Update status and notify listeners."""
session = self.session_manager.current_session
status = AppStatus(
state=self.state,
session_name=session.name if session else None,
project=session.project if session else None,
session_type=session.session_type if session else None,
clip_count=session.clip_count if session else 0,
total_duration=session.total_duration if session else 0.0,
current_clip_duration=self.recorder.get_duration() if self.state == AppState.RECORDING else 0.0,
last_clip_duration=self._get_last_clip_duration(),
message=message,
)
self.on_status_change(status)
def _get_last_clip_duration(self) -> float:
"""Get duration of the last clip."""
session = self.session_manager.current_session
if session and session.clips:
return session.clips[-1].duration_seconds
return 0.0
def _start_duration_thread(self) -> None:
"""Start thread to update duration while recording."""
self._running = True
def update_loop():
while self._running and self.state == AppState.RECORDING:
self._update_status()
time.sleep(0.5)
self._duration_thread = threading.Thread(target=update_loop, daemon=True)
self._duration_thread.start()
def _stop_duration_thread(self) -> None:
"""Stop duration update thread."""
self._running = False
if self._duration_thread:
self._duration_thread.join(timeout=1)
self._duration_thread = None
# === Public API ===
def start(self) -> None:
"""Start the application (register hotkeys)."""
self.hotkeys.register_all()
self._log("KB Capture started. Hotkeys active.")
def stop(self) -> None:
"""Stop the application."""
self.hotkeys.unregister_all()
self._stop_duration_thread()
if self.state == AppState.RECORDING:
self.recorder.stop()
self._log("KB Capture stopped.")
def start_session(
self,
name: str,
project: str,
session_type: SessionType = SessionType.DESIGN,
) -> Session:
"""Start a new recording session."""
if self.state != AppState.IDLE:
raise RuntimeError("Session already active")
session = self.session_manager.start_session(name, project, session_type)
self.state = AppState.SESSION_ACTIVE
self._log(f"Session started: {name}")
self._update_status()
return session
def toggle_recording(self) -> None:
"""Toggle recording state (hotkey handler)."""
if self.state == AppState.IDLE:
self._log("No session active. Start a session first.")
return
if self.state == AppState.RECORDING:
self._stop_recording()
else:
self._start_recording()
def _start_recording(self) -> None:
"""Start recording a new clip."""
if self.state not in (AppState.SESSION_ACTIVE, AppState.PREVIEW):
return
# Auto-keep any clip in preview
if self.state == AppState.PREVIEW:
self.session_manager.keep_last_clip()
# Start new clip
clip, clip_path = self.session_manager.start_clip()
config = RecordingConfig(
output_path=clip_path,
framerate=30,
)
if self.recorder.start(config):
self.state = AppState.RECORDING
self._start_duration_thread()
self._log(f"Recording clip {clip.id}...")
else:
self._log("Failed to start recording")
def _stop_recording(self) -> None:
"""Stop recording current clip."""
if self.state != AppState.RECORDING:
return
self._stop_duration_thread()
duration = self.recorder.get_duration()
output = self.recorder.stop()
if output and output.exists():
self.session_manager.end_clip(duration)
self.state = AppState.PREVIEW
self._log(f"Clip recorded: {duration:.1f}s - Keep (K) or Delete (D)?")
else:
self._log("Recording failed - no output")
self.state = AppState.SESSION_ACTIVE
self._update_status()
def keep_last_clip(self, note: str = "") -> Optional[Clip]:
"""Keep the last recorded clip."""
clip = self.session_manager.keep_last_clip(note)
if clip:
self.state = AppState.SESSION_ACTIVE
self._log(f"Kept clip: {clip.id}")
self._update_status()
return clip
def delete_last_clip(self) -> Optional[Clip]:
"""Delete the last recorded clip."""
clip = self.session_manager.delete_last_clip()
if clip:
self.state = AppState.SESSION_ACTIVE
self._log(f"Deleted clip: {clip.id}")
self._update_status()
return clip
def end_session(self) -> Optional[Session]:
"""End current session and prepare for export."""
if self.state == AppState.IDLE:
return None
# Stop recording if active
if self.state == AppState.RECORDING:
self._stop_recording()
# Keep any preview clips
if self.state == AppState.PREVIEW:
self.session_manager.keep_last_clip()
session = self.session_manager.end_session()
self.state = AppState.IDLE
self._log(f"Session ended: {session.clip_count} clips, {session.total_duration:.1f}s total")
self._update_status()
return session
def cancel_session(self) -> None:
"""Cancel current session and delete all clips."""
if self.state == AppState.RECORDING:
self._stop_duration_thread()
self.recorder.stop()
self.session_manager.cancel_session()
self.state = AppState.IDLE
self._log("Session cancelled")
self._update_status()
def get_status(self) -> AppStatus:
"""Get current application status."""
session = self.session_manager.current_session
return AppStatus(
state=self.state,
session_name=session.name if session else None,
project=session.project if session else None,
session_type=session.session_type if session else None,
clip_count=session.clip_count if session else 0,
total_duration=session.total_duration if session else 0.0,
current_clip_duration=self.recorder.get_duration() if self.state == AppState.RECORDING else 0.0,
last_clip_duration=self._get_last_clip_duration(),
)
def list_sessions(self) -> list[Session]:
"""List all recorded sessions."""
return self.session_manager.list_sessions()
def get_session_dir(self, session_id: str) -> Path:
"""Get session directory for export."""
return self.session_manager.get_session_dir(session_id)
# CLI for testing
if __name__ == "__main__":
import tempfile
def on_status(status: AppStatus):
state_icons = {
AppState.IDLE: "",
AppState.SESSION_ACTIVE: "🟢",
AppState.RECORDING: "🔴",
AppState.PREVIEW: "🟡",
}
icon = state_icons.get(status.state, "")
print(f"\n{icon} State: {status.state.value}")
if status.session_name:
print(f" Session: {status.session_name} ({status.project})")
print(f" Clips: {status.clip_count} | Duration: {status.total_duration:.1f}s")
if status.current_clip_duration > 0:
print(f" Recording: {status.current_clip_duration:.1f}s")
if status.message:
print(f"{status.message}")
with tempfile.TemporaryDirectory() as tmpdir:
app = KBCaptureApp(
base_path=Path(tmpdir),
on_status_change=on_status,
)
print("\n=== KB Capture Test ===")
print("Commands:")
print(" s - Start session")
print(" r - Toggle recording")
print(" k - Keep last clip")
print(" d - Delete last clip")
print(" e - End session")
print(" c - Cancel session")
print(" q - Quit")
print()
app.start()
try:
while True:
cmd = input("> ").strip().lower()
if cmd == "s":
name = input("Session name: ").strip() or "Test Session"
project = input("Project: ").strip() or "P04-GigaBIT-M1"
app.start_session(name, project)
elif cmd == "r":
app.toggle_recording()
elif cmd == "k":
app.keep_last_clip()
elif cmd == "d":
app.delete_last_clip()
elif cmd == "e":
app.end_session()
elif cmd == "c":
app.cancel_session()
elif cmd == "q":
break
else:
print("Unknown command")
except KeyboardInterrupt:
pass
app.stop()
print("\nGoodbye!")