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
365 lines
12 KiB
Python
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!")
|