Simplify KB Capture to match Voice Recorder pattern
Major simplification: - Removed clips concept (no keep/delete segments) - Single continuous recording per session with pause/resume - Matches Voice Recorder UX pattern Antoine knows Flow: Start Session → Record → Pause → Resume → Stop → Transcribe → Done Features: - Record/Pause/Resume/Stop controls - Session types: Design / Analysis - Auto-transcribe with Whisper on stop - Finds 'screenshot' triggers in transcript for Clawdbot - Simple dark theme UI matching Voice Recorder Removed: - export.py (transcription now inline) - hotkeys.py (not needed for MVP) - Clip management
This commit is contained in:
@@ -1,338 +0,0 @@
|
||||
"""
|
||||
Session Export for Clawdbot
|
||||
|
||||
Merges clips, transcribes audio, and exports for Clawdbot processing.
|
||||
Uses local Whisper for transcription (no API).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, Callable
|
||||
import tempfile
|
||||
|
||||
from .session import Session, SessionManager, ClipStatus
|
||||
|
||||
|
||||
class SessionExporter:
|
||||
"""
|
||||
Export a recorded session for Clawdbot processing.
|
||||
|
||||
Steps:
|
||||
1. Merge all kept clips into one video
|
||||
2. Transcribe with Whisper (local)
|
||||
3. Create metadata.json
|
||||
4. Create clawdbot_export/ folder
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_manager: SessionManager,
|
||||
whisper_model: str = "base",
|
||||
on_progress: Optional[Callable[[str, float], None]] = None,
|
||||
):
|
||||
self.session_manager = session_manager
|
||||
self.whisper_model = whisper_model
|
||||
self.on_progress = on_progress or (lambda msg, pct: print(f"[{pct:.0%}] {msg}"))
|
||||
|
||||
def export(self, session_id: str) -> Path:
|
||||
"""
|
||||
Export a session for Clawdbot.
|
||||
|
||||
Returns path to clawdbot_export/ folder.
|
||||
"""
|
||||
session = self.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise ValueError(f"Session not found: {session_id}")
|
||||
|
||||
if not session.is_finalized:
|
||||
raise ValueError("Session not finalized. End the session first.")
|
||||
|
||||
session_dir = self.session_manager.get_session_dir(session_id)
|
||||
export_dir = session_dir / "clawdbot_export"
|
||||
export_dir.mkdir(exist_ok=True)
|
||||
|
||||
kept_clips = session.kept_clips
|
||||
if not kept_clips:
|
||||
raise ValueError("No clips to export")
|
||||
|
||||
self.on_progress("Starting export...", 0.0)
|
||||
|
||||
# Step 1: Merge clips
|
||||
self.on_progress("Merging clips...", 0.1)
|
||||
merged_path = self._merge_clips(session_dir, kept_clips, export_dir)
|
||||
|
||||
# Step 2: Transcribe (uses local GPU Whisper)
|
||||
self.on_progress("Transcribing audio...", 0.3)
|
||||
transcript = self._transcribe(merged_path, export_dir)
|
||||
|
||||
# Step 3: Create metadata
|
||||
# Note: Frame extraction is done by Clawdbot using the knowledge-base skill
|
||||
self.on_progress("Creating metadata...", 0.8)
|
||||
self._create_metadata(session, export_dir, merged_path, transcript)
|
||||
|
||||
self.on_progress("Export complete!", 1.0)
|
||||
|
||||
return export_dir
|
||||
|
||||
def _merge_clips(
|
||||
self,
|
||||
session_dir: Path,
|
||||
clips: list,
|
||||
export_dir: Path,
|
||||
) -> Path:
|
||||
"""Merge kept clips into a single video."""
|
||||
output_path = export_dir / "merged.mp4"
|
||||
clips_dir = session_dir / "clips"
|
||||
|
||||
if len(clips) == 1:
|
||||
# Single clip - just copy
|
||||
clip_path = clips_dir / clips[0].filename
|
||||
import shutil
|
||||
shutil.copy2(clip_path, output_path)
|
||||
return output_path
|
||||
|
||||
# Create concat list file
|
||||
concat_file = export_dir / "concat.txt"
|
||||
with open(concat_file, "w") as f:
|
||||
for clip in clips:
|
||||
clip_path = clips_dir / clip.filename
|
||||
# FFmpeg concat needs escaped paths
|
||||
escaped = str(clip_path).replace("'", "'\\''")
|
||||
f.write(f"file '{escaped}'\n")
|
||||
|
||||
# Merge with FFmpeg
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-i", str(concat_file),
|
||||
"-c", "copy",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg merge failed: {result.stderr[-500:]}")
|
||||
finally:
|
||||
concat_file.unlink(missing_ok=True)
|
||||
|
||||
return output_path
|
||||
|
||||
def _transcribe(self, video_path: Path, export_dir: Path) -> dict:
|
||||
"""Transcribe video audio with Whisper."""
|
||||
try:
|
||||
import whisper
|
||||
except ImportError:
|
||||
raise RuntimeError("Whisper not installed. Run: pip install openai-whisper")
|
||||
|
||||
# Load model
|
||||
model = whisper.load_model(self.whisper_model)
|
||||
|
||||
# Transcribe
|
||||
result = model.transcribe(
|
||||
str(video_path),
|
||||
language="en",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
# Save transcript
|
||||
transcript_path = export_dir / "transcript.json"
|
||||
with open(transcript_path, "w") as f:
|
||||
json.dump(result, f, indent=2)
|
||||
|
||||
return result
|
||||
|
||||
def _extract_frames(
|
||||
self,
|
||||
video_path: Path,
|
||||
transcript: dict,
|
||||
frames_dir: Path,
|
||||
) -> list[Path]:
|
||||
"""Extract frames at 'screenshot' trigger timestamps."""
|
||||
frames_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Find screenshot triggers in transcript
|
||||
triggers = []
|
||||
for segment in transcript.get("segments", []):
|
||||
text = segment.get("text", "").lower()
|
||||
if "screenshot" in text:
|
||||
# Get timestamp (start of segment)
|
||||
timestamp = segment.get("start", 0)
|
||||
triggers.append(timestamp)
|
||||
|
||||
if not triggers:
|
||||
# No triggers found - extract frames at regular intervals
|
||||
# Get video duration
|
||||
duration = self._get_video_duration(video_path)
|
||||
# Extract every 30 seconds
|
||||
triggers = list(range(0, int(duration), 30))
|
||||
|
||||
# Extract frames
|
||||
extracted = []
|
||||
for i, ts in enumerate(triggers):
|
||||
frame_path = frames_dir / f"{i+1:02d}_{self._format_timestamp(ts)}.png"
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", str(ts),
|
||||
"-i", str(video_path),
|
||||
"-vframes", "1",
|
||||
"-q:v", "2",
|
||||
str(frame_path),
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||
)
|
||||
if frame_path.exists():
|
||||
extracted.append(frame_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return extracted
|
||||
|
||||
def _get_video_duration(self, video_path: Path) -> float:
|
||||
"""Get video duration in seconds."""
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v", "quiet",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "json",
|
||||
str(video_path),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
data = json.loads(result.stdout)
|
||||
return float(data["format"]["duration"])
|
||||
except:
|
||||
return 300.0 # Default 5 minutes
|
||||
|
||||
def _format_timestamp(self, seconds: float) -> str:
|
||||
"""Format seconds as MM-SS."""
|
||||
mins = int(seconds // 60)
|
||||
secs = int(seconds % 60)
|
||||
return f"{mins:02d}-{secs:02d}"
|
||||
|
||||
def _find_screenshot_triggers(self, transcript: dict) -> list[dict]:
|
||||
"""Find 'screenshot' triggers in transcript with context."""
|
||||
triggers = []
|
||||
segments = transcript.get("segments", [])
|
||||
|
||||
for i, segment in enumerate(segments):
|
||||
text = segment.get("text", "").lower()
|
||||
if "screenshot" in text:
|
||||
timestamp = segment.get("start", 0)
|
||||
|
||||
# Get context: 2 segments before and after
|
||||
context_segments = segments[max(0, i-2):i+3]
|
||||
context = " ".join(s.get("text", "") for s in context_segments)
|
||||
|
||||
triggers.append({
|
||||
"timestamp": timestamp,
|
||||
"timestamp_formatted": self._format_timestamp(timestamp),
|
||||
"segment_text": segment.get("text", ""),
|
||||
"context": context.strip(),
|
||||
})
|
||||
|
||||
return triggers
|
||||
|
||||
def _create_metadata(
|
||||
self,
|
||||
session: Session,
|
||||
export_dir: Path,
|
||||
merged_path: Path,
|
||||
transcript: dict,
|
||||
) -> None:
|
||||
"""Create metadata.json for Clawdbot."""
|
||||
# Find screenshot triggers for Mario
|
||||
triggers = self._find_screenshot_triggers(transcript)
|
||||
|
||||
# Get video duration
|
||||
duration = self._get_video_duration(merged_path)
|
||||
|
||||
metadata = {
|
||||
"session_id": session.id,
|
||||
"name": session.name,
|
||||
"project": session.project,
|
||||
"session_type": session.session_type.value,
|
||||
"created_at": session.created_at.isoformat(),
|
||||
"exported_at": datetime.now().isoformat(),
|
||||
"clip_count": session.clip_count,
|
||||
"total_duration": duration,
|
||||
"status": "pending", # → "processed" after Clawdbot processes
|
||||
"clips": [
|
||||
{
|
||||
"id": clip.id,
|
||||
"duration": clip.duration_seconds,
|
||||
"note": clip.note,
|
||||
}
|
||||
for clip in session.kept_clips
|
||||
],
|
||||
"screenshot_triggers": triggers, # Pre-parsed for Mario
|
||||
"files": {
|
||||
"video": "merged.mp4",
|
||||
"transcript": "transcript.json",
|
||||
},
|
||||
}
|
||||
|
||||
metadata_path = export_dir / "metadata.json"
|
||||
with open(metadata_path, "w") as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
|
||||
# CLI for testing
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python -m cad_documenter.export <session_id>")
|
||||
print(" python -m cad_documenter.export --list")
|
||||
sys.exit(1)
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Find sessions directory
|
||||
if sys.platform == "win32":
|
||||
base = Path.home() / "Documents" / "KB-Capture"
|
||||
else:
|
||||
base = Path.home() / "kb-capture"
|
||||
|
||||
manager = SessionManager(base)
|
||||
|
||||
if sys.argv[1] == "--list":
|
||||
sessions = manager.list_sessions()
|
||||
if not sessions:
|
||||
print("No sessions found.")
|
||||
else:
|
||||
print("Sessions:")
|
||||
for s in sessions:
|
||||
status = "✓" if s.is_finalized else "○"
|
||||
print(f" {status} {s.id}: {s.name} ({s.clip_count} clips, {s.total_duration:.0f}s)")
|
||||
else:
|
||||
session_id = sys.argv[1]
|
||||
|
||||
def on_progress(msg, pct):
|
||||
bar = "█" * int(pct * 20) + "░" * (20 - int(pct * 20))
|
||||
print(f"\r[{bar}] {msg}", end="", flush=True)
|
||||
|
||||
exporter = SessionExporter(manager, on_progress=on_progress)
|
||||
|
||||
try:
|
||||
export_path = exporter.export(session_id)
|
||||
print(f"\n\nExported to: {export_path}")
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}")
|
||||
sys.exit(1)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,161 +0,0 @@
|
||||
"""
|
||||
Global Hotkey Manager
|
||||
|
||||
Registers global hotkeys for recording control.
|
||||
Works across all applications.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import Callable, Dict, Optional
|
||||
import sys
|
||||
|
||||
# Use keyboard library for cross-platform hotkeys
|
||||
try:
|
||||
import keyboard
|
||||
HAS_KEYBOARD = True
|
||||
except ImportError:
|
||||
HAS_KEYBOARD = False
|
||||
print("Warning: 'keyboard' library not installed. Hotkeys disabled.")
|
||||
|
||||
|
||||
class HotkeyManager:
|
||||
"""
|
||||
Manages global hotkeys for KB Capture.
|
||||
|
||||
Default hotkeys:
|
||||
Ctrl+Shift+R: Toggle recording
|
||||
Ctrl+Shift+K: Keep last clip
|
||||
Ctrl+Shift+D: Delete last clip
|
||||
Ctrl+Shift+E: End session
|
||||
Escape: Cancel (when in certain states)
|
||||
"""
|
||||
|
||||
DEFAULT_HOTKEYS = {
|
||||
"toggle_recording": "ctrl+shift+r",
|
||||
"keep_clip": "ctrl+shift+k",
|
||||
"delete_clip": "ctrl+shift+d",
|
||||
"end_session": "ctrl+shift+e",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.callbacks: Dict[str, Callable] = {}
|
||||
self.hotkeys: Dict[str, str] = self.DEFAULT_HOTKEYS.copy()
|
||||
self.registered: Dict[str, bool] = {}
|
||||
self.enabled = HAS_KEYBOARD
|
||||
|
||||
def set_callback(self, action: str, callback: Callable) -> None:
|
||||
"""Set callback for an action."""
|
||||
self.callbacks[action] = callback
|
||||
|
||||
def register_all(self) -> bool:
|
||||
"""Register all hotkeys."""
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
for action, hotkey in self.hotkeys.items():
|
||||
if action in self.callbacks:
|
||||
try:
|
||||
keyboard.add_hotkey(
|
||||
hotkey,
|
||||
self._create_handler(action),
|
||||
suppress=False, # Don't block the key
|
||||
)
|
||||
self.registered[action] = True
|
||||
except Exception as e:
|
||||
print(f"Failed to register hotkey {hotkey}: {e}")
|
||||
self.registered[action] = False
|
||||
|
||||
return all(self.registered.values())
|
||||
|
||||
def _create_handler(self, action: str) -> Callable:
|
||||
"""Create a handler that calls the callback in a thread."""
|
||||
def handler():
|
||||
if action in self.callbacks:
|
||||
# Run callback in thread to avoid blocking
|
||||
threading.Thread(
|
||||
target=self.callbacks[action],
|
||||
daemon=True,
|
||||
).start()
|
||||
return handler
|
||||
|
||||
def unregister_all(self) -> None:
|
||||
"""Unregister all hotkeys."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
for action, hotkey in self.hotkeys.items():
|
||||
if self.registered.get(action):
|
||||
try:
|
||||
keyboard.remove_hotkey(hotkey)
|
||||
except:
|
||||
pass
|
||||
self.registered[action] = False
|
||||
|
||||
def set_hotkey(self, action: str, hotkey: str) -> None:
|
||||
"""Change hotkey for an action."""
|
||||
if action in self.hotkeys:
|
||||
# Unregister old hotkey
|
||||
if self.registered.get(action) and self.enabled:
|
||||
try:
|
||||
keyboard.remove_hotkey(self.hotkeys[action])
|
||||
except:
|
||||
pass
|
||||
|
||||
self.hotkeys[action] = hotkey
|
||||
|
||||
# Register new hotkey
|
||||
if action in self.callbacks and self.enabled:
|
||||
try:
|
||||
keyboard.add_hotkey(
|
||||
hotkey,
|
||||
self._create_handler(action),
|
||||
suppress=False,
|
||||
)
|
||||
self.registered[action] = True
|
||||
except Exception as e:
|
||||
print(f"Failed to register hotkey {hotkey}: {e}")
|
||||
self.registered[action] = False
|
||||
|
||||
def get_hotkey_display(self, action: str) -> str:
|
||||
"""Get display-friendly hotkey string."""
|
||||
hotkey = self.hotkeys.get(action, "")
|
||||
return hotkey.replace("+", " + ").title()
|
||||
|
||||
|
||||
# Quick test
|
||||
if __name__ == "__main__":
|
||||
manager = HotkeyManager()
|
||||
|
||||
def on_toggle():
|
||||
print("Toggle recording!")
|
||||
|
||||
def on_keep():
|
||||
print("Keep clip!")
|
||||
|
||||
def on_delete():
|
||||
print("Delete clip!")
|
||||
|
||||
def on_end():
|
||||
print("End session!")
|
||||
|
||||
manager.set_callback("toggle_recording", on_toggle)
|
||||
manager.set_callback("keep_clip", on_keep)
|
||||
manager.set_callback("delete_clip", on_delete)
|
||||
manager.set_callback("end_session", on_end)
|
||||
|
||||
if manager.register_all():
|
||||
print("Hotkeys registered! Try:")
|
||||
print(" Ctrl+Shift+R: Toggle recording")
|
||||
print(" Ctrl+Shift+K: Keep clip")
|
||||
print(" Ctrl+Shift+D: Delete clip")
|
||||
print(" Ctrl+Shift+E: End session")
|
||||
print("\nPress Ctrl+C to exit")
|
||||
|
||||
try:
|
||||
keyboard.wait()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
manager.unregister_all()
|
||||
else:
|
||||
print("Failed to register hotkeys")
|
||||
@@ -1,15 +1,11 @@
|
||||
"""
|
||||
KB Capture - Knowledge Base Recording Tool
|
||||
KB Capture - Screen Recording for Knowledge Base
|
||||
|
||||
Main application that ties together:
|
||||
- Screen recording
|
||||
- Session/clip management
|
||||
- Hotkey control
|
||||
- System tray integration
|
||||
- GUI interface
|
||||
Simple flow: Record → Pause → Resume → Stop → Transcribe → Done
|
||||
|
||||
One session = one continuous recording.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -18,42 +14,38 @@ 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
|
||||
from .session import SessionManager, Session, SessionType, SessionStatus
|
||||
|
||||
|
||||
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
|
||||
"""Application state."""
|
||||
IDLE = "idle" # No session
|
||||
RECORDING = "recording" # Recording
|
||||
PAUSED = "paused" # Recording paused
|
||||
TRANSCRIBING = "transcribing" # Processing
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppStatus:
|
||||
"""Current application status for UI updates."""
|
||||
"""Current application status for UI."""
|
||||
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
|
||||
duration: float = 0.0
|
||||
message: str = ""
|
||||
|
||||
|
||||
class KBCaptureApp:
|
||||
"""
|
||||
Main KB Capture application.
|
||||
Main application.
|
||||
|
||||
Controls recording flow:
|
||||
1. Select project from available projects
|
||||
Flow:
|
||||
1. Select project
|
||||
2. Start session (name, type)
|
||||
3. Toggle recording to create clips
|
||||
4. Keep or delete clips
|
||||
5. End session to export
|
||||
3. Record → Pause → Resume → Stop
|
||||
4. Auto-transcribe with Whisper
|
||||
5. Ready for Clawdbot to process
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -67,22 +59,14 @@ class KBCaptureApp:
|
||||
# Components
|
||||
self.session_manager = SessionManager(self.projects_root)
|
||||
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."""
|
||||
"""Log and update status."""
|
||||
print(f"[KB Capture] {message}")
|
||||
self._update_status(message=message)
|
||||
|
||||
@@ -95,28 +79,18 @@ class KBCaptureApp:
|
||||
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(),
|
||||
duration=self.recorder.get_duration() if self.state in (AppState.RECORDING, AppState.PAUSED) else 0.0,
|
||||
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."""
|
||||
"""Start thread to update duration."""
|
||||
self._running = True
|
||||
|
||||
def update_loop():
|
||||
while self._running and self.state == AppState.RECORDING:
|
||||
while self._running and self.state in (AppState.RECORDING, AppState.PAUSED):
|
||||
self._update_status()
|
||||
time.sleep(0.5)
|
||||
|
||||
@@ -124,7 +98,7 @@ class KBCaptureApp:
|
||||
self._duration_thread.start()
|
||||
|
||||
def _stop_duration_thread(self) -> None:
|
||||
"""Stop duration update thread."""
|
||||
"""Stop duration thread."""
|
||||
self._running = False
|
||||
if self._duration_thread:
|
||||
self._duration_thread.join(timeout=1)
|
||||
@@ -132,146 +106,184 @@ class KBCaptureApp:
|
||||
|
||||
# === 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."""
|
||||
"""Start a new session and begin recording."""
|
||||
if self.state != AppState.IDLE:
|
||||
raise RuntimeError("Session already active")
|
||||
|
||||
# Create session
|
||||
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()
|
||||
|
||||
# Start recording
|
||||
config = RecordingConfig(
|
||||
output_path=clip_path,
|
||||
output_path=self.session_manager.get_video_path(),
|
||||
framerate=30,
|
||||
)
|
||||
|
||||
if self.recorder.start(config):
|
||||
self.state = AppState.RECORDING
|
||||
self._start_duration_thread()
|
||||
self._log(f"Recording clip {clip.id}...")
|
||||
self._log(f"Session started: {name}")
|
||||
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()
|
||||
self.session_manager.cancel_session()
|
||||
raise RuntimeError("Failed to start recording")
|
||||
|
||||
return session
|
||||
|
||||
def cancel_session(self) -> None:
|
||||
"""Cancel current session and delete all clips."""
|
||||
def pause(self) -> bool:
|
||||
"""Pause recording."""
|
||||
if self.state != AppState.RECORDING:
|
||||
return False
|
||||
|
||||
if self.recorder.pause():
|
||||
self.state = AppState.PAUSED
|
||||
self.session_manager.update_status(SessionStatus.PAUSED)
|
||||
self._update_status("Paused")
|
||||
return True
|
||||
return False
|
||||
|
||||
def resume(self) -> bool:
|
||||
"""Resume recording."""
|
||||
if self.state != AppState.PAUSED:
|
||||
return False
|
||||
|
||||
if self.recorder.resume():
|
||||
self.state = AppState.RECORDING
|
||||
self.session_manager.update_status(SessionStatus.RECORDING)
|
||||
self._update_status("Recording")
|
||||
return True
|
||||
return False
|
||||
|
||||
def toggle_pause(self) -> None:
|
||||
"""Toggle pause/resume."""
|
||||
if self.state == AppState.RECORDING:
|
||||
self.pause()
|
||||
elif self.state == AppState.PAUSED:
|
||||
self.resume()
|
||||
|
||||
def stop(self) -> Optional[Session]:
|
||||
"""Stop recording and transcribe."""
|
||||
if self.state not in (AppState.RECORDING, AppState.PAUSED):
|
||||
return None
|
||||
|
||||
self._stop_duration_thread()
|
||||
|
||||
# Get duration before stopping
|
||||
duration = self.recorder.get_duration()
|
||||
|
||||
# Stop recording
|
||||
output = self.recorder.stop()
|
||||
|
||||
if not output or not output.exists():
|
||||
self._log("Recording failed - no output")
|
||||
self.session_manager.cancel_session()
|
||||
self.state = AppState.IDLE
|
||||
return None
|
||||
|
||||
# Update session
|
||||
self.session_manager.set_duration(duration)
|
||||
self.session_manager.update_status(SessionStatus.TRANSCRIBING)
|
||||
self.state = AppState.TRANSCRIBING
|
||||
self._update_status("Transcribing...")
|
||||
|
||||
# Transcribe in background
|
||||
threading.Thread(
|
||||
target=self._transcribe,
|
||||
args=(output,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
return self.session_manager.current_session
|
||||
|
||||
def _transcribe(self, video_path: Path) -> None:
|
||||
"""Transcribe video with Whisper."""
|
||||
try:
|
||||
import whisper
|
||||
|
||||
self._log("Loading Whisper model...")
|
||||
model = whisper.load_model("base")
|
||||
|
||||
self._log("Transcribing...")
|
||||
result = model.transcribe(str(video_path), language="en", verbose=False)
|
||||
|
||||
# Save transcript
|
||||
import json
|
||||
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", ""),
|
||||
})
|
||||
|
||||
# Save metadata
|
||||
session = self.session_manager.current_session
|
||||
metadata = {
|
||||
"session_id": session.id,
|
||||
"name": session.name,
|
||||
"project": session.project,
|
||||
"session_type": session.session_type.value,
|
||||
"created_at": session.created_at.isoformat(),
|
||||
"duration": session.duration,
|
||||
"status": "ready",
|
||||
"screenshot_triggers": triggers,
|
||||
"files": {
|
||||
"video": session.video_file,
|
||||
"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_transcript("transcript.json")
|
||||
self.session_manager.end_session()
|
||||
|
||||
self.state = AppState.IDLE
|
||||
self._log(f"Done! {len(triggers)} screenshot triggers found")
|
||||
self._update_status(f"Session saved with {len(triggers)} screenshots")
|
||||
|
||||
except ImportError:
|
||||
self._log("Whisper not installed!")
|
||||
self.session_manager.end_session()
|
||||
self.state = AppState.IDLE
|
||||
self._update_status("Saved (no transcription)")
|
||||
except Exception as e:
|
||||
self._log(f"Transcription error: {e}")
|
||||
self.session_manager.end_session()
|
||||
self.state = AppState.IDLE
|
||||
self._update_status("Saved (transcription failed)")
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel session and delete files."""
|
||||
if self.state == AppState.IDLE:
|
||||
return
|
||||
|
||||
self._stop_duration_thread()
|
||||
|
||||
if self.recorder.is_recording:
|
||||
self.recorder.stop()
|
||||
|
||||
self.session_manager.cancel_session()
|
||||
self.state = AppState.IDLE
|
||||
|
||||
self._log("Session cancelled")
|
||||
self._update_status()
|
||||
self._update_status("Cancelled")
|
||||
|
||||
def get_status(self) -> AppStatus:
|
||||
"""Get current application status."""
|
||||
"""Get current status."""
|
||||
session = self.session_manager.current_session
|
||||
|
||||
return AppStatus(
|
||||
@@ -279,95 +291,5 @@ class KBCaptureApp:
|
||||
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(),
|
||||
duration=self.recorder.get_duration() if self.state in (AppState.RECORDING, AppState.PAUSED) else 0.0,
|
||||
)
|
||||
|
||||
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:
|
||||
# Create a test project
|
||||
test_project = Path(tmpdir) / "Test-Project"
|
||||
(test_project / "KB").mkdir(parents=True)
|
||||
|
||||
app = KBCaptureApp(
|
||||
projects_root=Path(tmpdir),
|
||||
on_status_change=on_status,
|
||||
)
|
||||
|
||||
print("\n=== KB Capture Test ===")
|
||||
print(f"Projects root: {tmpdir}")
|
||||
print(f"Available projects: {app.session_manager.list_projects()}")
|
||||
print("\nCommands:")
|
||||
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":
|
||||
projects = app.session_manager.list_projects()
|
||||
print(f"Available projects: {projects}")
|
||||
project = input("Project: ").strip() or (projects[0] if projects else "Test-Project")
|
||||
name = input("Session name: ").strip() or "Test Session"
|
||||
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!")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Screen + Audio Recorder using FFmpeg
|
||||
Screen + Audio Recorder (Simplified)
|
||||
|
||||
Records screen and microphone to video file.
|
||||
Supports Windows (gdigrab) and Linux (x11grab).
|
||||
Records screen and microphone to a single video file.
|
||||
Supports pause/resume within the same recording.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
@@ -12,7 +12,6 @@ import sys
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Callable
|
||||
import json
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -20,88 +19,77 @@ class RecordingConfig:
|
||||
"""Recording configuration."""
|
||||
output_path: Path
|
||||
framerate: int = 30
|
||||
audio_device: Optional[str] = None # None = default mic
|
||||
audio_device: Optional[str] = None
|
||||
video_codec: str = "libx264"
|
||||
audio_codec: str = "aac"
|
||||
crf: int = 23 # Quality (lower = better, 18-28 typical)
|
||||
preset: str = "ultrafast" # Encoding speed
|
||||
capture_region: Optional[tuple[int, int, int, int]] = None # x, y, w, h
|
||||
crf: int = 23
|
||||
preset: str = "ultrafast"
|
||||
|
||||
|
||||
class ScreenRecorder:
|
||||
"""
|
||||
FFmpeg-based screen recorder with audio.
|
||||
FFmpeg-based screen recorder with pause/resume.
|
||||
|
||||
Usage:
|
||||
recorder = ScreenRecorder()
|
||||
recorder.start(config)
|
||||
# ... recording ...
|
||||
recorder.pause()
|
||||
# ... thinking ...
|
||||
recorder.resume()
|
||||
# ... more recording ...
|
||||
recorder.stop()
|
||||
"""
|
||||
|
||||
def __init__(self, on_status: Optional[Callable[[str], None]] = None):
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self.is_recording = False
|
||||
self.is_paused = False
|
||||
self.start_time: Optional[float] = None
|
||||
self.pause_start: Optional[float] = None
|
||||
self.total_paused: float = 0.0
|
||||
self.output_path: Optional[Path] = None
|
||||
self.on_status = on_status or (lambda x: None)
|
||||
self._monitor_thread: Optional[threading.Thread] = None
|
||||
|
||||
def _get_platform_args(self, config: RecordingConfig) -> list[str]:
|
||||
"""Get platform-specific FFmpeg input arguments."""
|
||||
def _get_ffmpeg_cmd(self, config: RecordingConfig) -> list[str]:
|
||||
"""Build FFmpeg command."""
|
||||
cmd = ["ffmpeg", "-y"]
|
||||
|
||||
if sys.platform == "win32":
|
||||
# Windows: gdigrab for screen, dshow for audio
|
||||
args = [
|
||||
# Windows: gdigrab for screen
|
||||
cmd.extend([
|
||||
"-f", "gdigrab",
|
||||
"-framerate", str(config.framerate),
|
||||
]
|
||||
|
||||
if config.capture_region:
|
||||
x, y, w, h = config.capture_region
|
||||
args.extend([
|
||||
"-offset_x", str(x),
|
||||
"-offset_y", str(y),
|
||||
"-video_size", f"{w}x{h}",
|
||||
"-i", "desktop",
|
||||
])
|
||||
|
||||
args.extend(["-i", "desktop"])
|
||||
|
||||
# Add audio input
|
||||
if config.audio_device:
|
||||
args.extend([
|
||||
# Audio: dshow
|
||||
audio_device = config.audio_device or "Microphone Array"
|
||||
cmd.extend([
|
||||
"-f", "dshow",
|
||||
"-i", f"audio={config.audio_device}",
|
||||
"-i", f"audio={audio_device}",
|
||||
])
|
||||
else:
|
||||
# Try to find default microphone
|
||||
args.extend([
|
||||
"-f", "dshow",
|
||||
"-i", "audio=Microphone Array", # Common default
|
||||
])
|
||||
|
||||
else:
|
||||
# Linux: x11grab for screen, pulse for audio
|
||||
display = ":0.0"
|
||||
args = [
|
||||
# Linux: x11grab + pulse
|
||||
cmd.extend([
|
||||
"-f", "x11grab",
|
||||
"-framerate", str(config.framerate),
|
||||
]
|
||||
|
||||
if config.capture_region:
|
||||
x, y, w, h = config.capture_region
|
||||
args.extend(["-video_size", f"{w}x{h}"])
|
||||
display = f":0.0+{x},{y}"
|
||||
|
||||
args.extend(["-i", display])
|
||||
|
||||
# Add audio (PulseAudio)
|
||||
args.extend([
|
||||
"-i", ":0.0",
|
||||
"-f", "pulse",
|
||||
"-i", "default",
|
||||
])
|
||||
|
||||
return args
|
||||
# Output settings
|
||||
cmd.extend([
|
||||
"-c:v", config.video_codec,
|
||||
"-preset", config.preset,
|
||||
"-crf", str(config.crf),
|
||||
"-c:a", config.audio_codec,
|
||||
"-b:a", "128k",
|
||||
str(config.output_path),
|
||||
])
|
||||
|
||||
return cmd
|
||||
|
||||
def start(self, config: RecordingConfig) -> bool:
|
||||
"""Start recording."""
|
||||
@@ -112,24 +100,9 @@ class ScreenRecorder:
|
||||
self.output_path = config.output_path
|
||||
self.output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build FFmpeg command
|
||||
cmd = ["ffmpeg", "-y"] # -y to overwrite
|
||||
|
||||
# Platform-specific inputs
|
||||
cmd.extend(self._get_platform_args(config))
|
||||
|
||||
# Output settings
|
||||
cmd.extend([
|
||||
"-c:v", config.video_codec,
|
||||
"-preset", config.preset,
|
||||
"-crf", str(config.crf),
|
||||
"-c:a", config.audio_codec,
|
||||
"-b:a", "128k",
|
||||
str(self.output_path),
|
||||
])
|
||||
cmd = self._get_ffmpeg_cmd(config)
|
||||
|
||||
try:
|
||||
# Start FFmpeg process
|
||||
self.process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
@@ -139,65 +112,129 @@ class ScreenRecorder:
|
||||
)
|
||||
|
||||
self.is_recording = True
|
||||
self.is_paused = False
|
||||
self.start_time = time.time()
|
||||
self.on_status(f"Recording started: {self.output_path.name}")
|
||||
|
||||
# Start monitor thread
|
||||
self._monitor_thread = threading.Thread(target=self._monitor_process, daemon=True)
|
||||
self._monitor_thread.start()
|
||||
self.total_paused = 0.0
|
||||
self.on_status("Recording started")
|
||||
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
self.on_status("FFmpeg not found. Please install FFmpeg.")
|
||||
self.on_status("FFmpeg not found")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.on_status(f"Failed to start recording: {e}")
|
||||
self.on_status(f"Failed: {e}")
|
||||
return False
|
||||
|
||||
def _monitor_process(self):
|
||||
"""Monitor FFmpeg process for errors."""
|
||||
if self.process:
|
||||
stderr = self.process.stderr.read() if self.process.stderr else b""
|
||||
if self.process.returncode and self.process.returncode != 0:
|
||||
self.on_status(f"Recording error: {stderr.decode()[-200:]}")
|
||||
def pause(self) -> bool:
|
||||
"""Pause recording (Windows: suspend process)."""
|
||||
if not self.is_recording or self.is_paused:
|
||||
return False
|
||||
|
||||
self.is_paused = True
|
||||
self.pause_start = time.time()
|
||||
|
||||
# On Windows, we can suspend the FFmpeg process
|
||||
if sys.platform == "win32" and self.process:
|
||||
try:
|
||||
import ctypes
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
handle = kernel32.OpenProcess(0x1F0FFF, False, self.process.pid)
|
||||
kernel32.DebugActiveProcess(self.process.pid)
|
||||
self.on_status("Paused")
|
||||
except:
|
||||
self.on_status("Paused (soft)")
|
||||
else:
|
||||
self.on_status("Paused")
|
||||
|
||||
return True
|
||||
|
||||
def resume(self) -> bool:
|
||||
"""Resume recording."""
|
||||
if not self.is_recording or not self.is_paused:
|
||||
return False
|
||||
|
||||
if self.pause_start:
|
||||
self.total_paused += time.time() - self.pause_start
|
||||
|
||||
self.is_paused = False
|
||||
self.pause_start = None
|
||||
|
||||
# Resume FFmpeg process on Windows
|
||||
if sys.platform == "win32" and self.process:
|
||||
try:
|
||||
import ctypes
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
kernel32.DebugActiveProcessStop(self.process.pid)
|
||||
except:
|
||||
pass
|
||||
|
||||
self.on_status("Recording resumed")
|
||||
return True
|
||||
|
||||
def stop(self) -> Optional[Path]:
|
||||
"""Stop recording and return output path."""
|
||||
if not self.is_recording or not self.process:
|
||||
return None
|
||||
|
||||
# If paused, add final pause duration
|
||||
if self.is_paused and self.pause_start:
|
||||
self.total_paused += time.time() - self.pause_start
|
||||
# Resume first so we can stop properly
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
# Send 'q' to FFmpeg to stop gracefully
|
||||
import ctypes
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
kernel32.DebugActiveProcessStop(self.process.pid)
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Send 'q' to FFmpeg
|
||||
if self.process.stdin:
|
||||
self.process.stdin.write(b"q")
|
||||
self.process.stdin.flush()
|
||||
|
||||
# Wait for process to finish (with timeout)
|
||||
self.process.wait(timeout=10)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=5)
|
||||
except Exception as e:
|
||||
self.on_status(f"Error stopping recording: {e}")
|
||||
self.on_status(f"Stop error: {e}")
|
||||
self.process.terminate()
|
||||
|
||||
self.is_recording = False
|
||||
duration = time.time() - self.start_time if self.start_time else 0
|
||||
self.on_status(f"Recording stopped: {duration:.1f}s")
|
||||
self.is_paused = False
|
||||
|
||||
duration = self.get_duration()
|
||||
self.on_status(f"Stopped: {duration:.1f}s")
|
||||
|
||||
return self.output_path if self.output_path and self.output_path.exists() else None
|
||||
|
||||
def get_duration(self) -> float:
|
||||
"""Get current recording duration in seconds."""
|
||||
if self.is_recording and self.start_time:
|
||||
return time.time() - self.start_time
|
||||
"""Get actual recording duration (excluding pauses)."""
|
||||
if not self.start_time:
|
||||
return 0.0
|
||||
|
||||
elapsed = time.time() - self.start_time
|
||||
|
||||
# Subtract paused time
|
||||
if self.is_paused and self.pause_start:
|
||||
elapsed -= (time.time() - self.pause_start)
|
||||
elapsed -= self.total_paused
|
||||
|
||||
return max(0, elapsed)
|
||||
|
||||
def get_elapsed(self) -> float:
|
||||
"""Get total elapsed time (including pauses)."""
|
||||
if not self.start_time:
|
||||
return 0.0
|
||||
return time.time() - self.start_time
|
||||
|
||||
@staticmethod
|
||||
def list_audio_devices() -> list[str]:
|
||||
"""List available audio input devices (Windows only)."""
|
||||
"""List available audio input devices (Windows)."""
|
||||
if sys.platform != "win32":
|
||||
return ["default"]
|
||||
|
||||
@@ -209,7 +246,6 @@ class ScreenRecorder:
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
|
||||
# Parse audio devices from stderr
|
||||
devices = []
|
||||
in_audio = False
|
||||
for line in result.stderr.split("\n"):
|
||||
@@ -218,40 +254,12 @@ class ScreenRecorder:
|
||||
elif "DirectShow video devices" in line:
|
||||
in_audio = False
|
||||
elif in_audio and '"' in line:
|
||||
# Extract device name between quotes
|
||||
start = line.find('"') + 1
|
||||
end = line.rfind('"')
|
||||
if start < end:
|
||||
devices.append(line[start:end])
|
||||
|
||||
return devices if devices else ["default"]
|
||||
return devices if devices else ["Microphone Array"]
|
||||
|
||||
except Exception:
|
||||
return ["default"]
|
||||
|
||||
|
||||
# Quick test
|
||||
if __name__ == "__main__":
|
||||
def status(msg):
|
||||
print(f"[STATUS] {msg}")
|
||||
|
||||
recorder = ScreenRecorder(on_status=status)
|
||||
|
||||
print("Available audio devices:", recorder.list_audio_devices())
|
||||
|
||||
config = RecordingConfig(
|
||||
output_path=Path("test_recording.mp4"),
|
||||
framerate=30,
|
||||
)
|
||||
|
||||
print("Starting recording... (Ctrl+C to stop)")
|
||||
if recorder.start(config):
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
print(f"Recording: {recorder.get_duration():.1f}s")
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
output = recorder.stop()
|
||||
print(f"Saved to: {output}")
|
||||
except:
|
||||
return ["Microphone Array"]
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
"""
|
||||
Session Manager for KB Capture
|
||||
Session Manager for KB Capture (Simplified)
|
||||
|
||||
Manages recording sessions with multiple clips.
|
||||
Clips can be kept or deleted before finalizing.
|
||||
One session = one continuous recording (with pause/resume).
|
||||
No clips, no keep/delete. Just record → transcribe → done.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
import uuid
|
||||
|
||||
|
||||
class ClipStatus(Enum):
|
||||
RECORDING = "recording"
|
||||
PREVIEW = "preview" # Just recorded, awaiting decision
|
||||
KEPT = "kept"
|
||||
DELETED = "deleted"
|
||||
|
||||
|
||||
class SessionType(Enum):
|
||||
@@ -27,66 +18,26 @@ class SessionType(Enum):
|
||||
ANALYSIS = "analysis" # FEA/Analysis KB
|
||||
|
||||
|
||||
@dataclass
|
||||
class Clip:
|
||||
"""A single recording clip within a session."""
|
||||
id: str
|
||||
filename: str
|
||||
start_time: datetime
|
||||
end_time: Optional[datetime] = None
|
||||
duration_seconds: float = 0.0
|
||||
status: ClipStatus = ClipStatus.RECORDING
|
||||
note: str = "" # Optional quick note
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"filename": self.filename,
|
||||
"start_time": self.start_time.isoformat(),
|
||||
"end_time": self.end_time.isoformat() if self.end_time else None,
|
||||
"duration_seconds": self.duration_seconds,
|
||||
"status": self.status.value,
|
||||
"note": self.note,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Clip":
|
||||
return cls(
|
||||
id=data["id"],
|
||||
filename=data["filename"],
|
||||
start_time=datetime.fromisoformat(data["start_time"]),
|
||||
end_time=datetime.fromisoformat(data["end_time"]) if data.get("end_time") else None,
|
||||
duration_seconds=data.get("duration_seconds", 0.0),
|
||||
status=ClipStatus(data.get("status", "kept")),
|
||||
note=data.get("note", ""),
|
||||
)
|
||||
class SessionStatus(Enum):
|
||||
RECORDING = "recording"
|
||||
PAUSED = "paused"
|
||||
TRANSCRIBING = "transcribing"
|
||||
READY = "ready" # Transcribed, ready for sync
|
||||
PROCESSED = "processed" # Clawdbot has processed it
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""A recording session containing multiple clips."""
|
||||
"""A recording session."""
|
||||
id: str
|
||||
name: str
|
||||
project: str
|
||||
session_type: SessionType
|
||||
created_at: datetime
|
||||
clips: List[Clip] = field(default_factory=list)
|
||||
is_finalized: bool = False
|
||||
|
||||
@property
|
||||
def total_duration(self) -> float:
|
||||
"""Total duration of kept clips."""
|
||||
return sum(c.duration_seconds for c in self.clips if c.status == ClipStatus.KEPT)
|
||||
|
||||
@property
|
||||
def kept_clips(self) -> List[Clip]:
|
||||
"""Clips marked as kept."""
|
||||
return [c for c in self.clips if c.status == ClipStatus.KEPT]
|
||||
|
||||
@property
|
||||
def clip_count(self) -> int:
|
||||
"""Number of kept clips."""
|
||||
return len(self.kept_clips)
|
||||
duration: float = 0.0
|
||||
status: SessionStatus = SessionStatus.RECORDING
|
||||
video_file: str = "recording.mp4"
|
||||
transcript_file: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -95,8 +46,10 @@ class Session:
|
||||
"project": self.project,
|
||||
"session_type": self.session_type.value,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"clips": [c.to_dict() for c in self.clips],
|
||||
"is_finalized": self.is_finalized,
|
||||
"duration": self.duration,
|
||||
"status": self.status.value,
|
||||
"video_file": self.video_file,
|
||||
"transcript_file": self.transcript_file,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -107,52 +60,38 @@ class Session:
|
||||
project=data["project"],
|
||||
session_type=SessionType(data.get("session_type", "design")),
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
clips=[Clip.from_dict(c) for c in data.get("clips", [])],
|
||||
is_finalized=data.get("is_finalized", False),
|
||||
duration=data.get("duration", 0.0),
|
||||
status=SessionStatus(data.get("status", "ready")),
|
||||
video_file=data.get("video_file", "recording.mp4"),
|
||||
transcript_file=data.get("transcript_file"),
|
||||
)
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""
|
||||
Manages recording sessions and clips.
|
||||
Manages recording sessions.
|
||||
|
||||
Project-centric structure:
|
||||
/2-Projects/<ProjectName>/
|
||||
├── KB/
|
||||
│ └── dev/ # gen-XXX.md session captures (Mario creates)
|
||||
├── Images/
|
||||
│ └── screenshot-sessions/ # Frames organized by session
|
||||
└── _capture/ # Session staging
|
||||
/Projects/<ProjectName>/
|
||||
└── _capture/
|
||||
└── <session-id>/
|
||||
├── session.json
|
||||
├── clips/
|
||||
│ ├── clip-001.mp4
|
||||
│ └── ...
|
||||
└── clawdbot_export/ # Ready for Mario
|
||||
├── merged.mp4
|
||||
├── transcript.json
|
||||
└── metadata.json
|
||||
├── session.json # Metadata
|
||||
├── recording.mp4 # Video
|
||||
└── transcript.json # Whisper output
|
||||
"""
|
||||
|
||||
def __init__(self, projects_root: Path):
|
||||
"""
|
||||
Initialize session manager.
|
||||
|
||||
Args:
|
||||
projects_root: Path to projects folder (e.g., /2-Projects/ or D:/ATODrive/Projects/)
|
||||
"""
|
||||
self.projects_root = Path(projects_root)
|
||||
self.current_session: Optional[Session] = None
|
||||
self.current_clip: Optional[Clip] = None
|
||||
self._current_project_path: Optional[Path] = None
|
||||
|
||||
def list_projects(self) -> List[str]:
|
||||
"""List available projects (folders in projects_root)."""
|
||||
"""List available projects."""
|
||||
projects = []
|
||||
if self.projects_root.exists():
|
||||
for p in sorted(self.projects_root.iterdir()):
|
||||
if p.is_dir() and not p.name.startswith((".", "_")):
|
||||
# Check if it looks like a project (has KB folder or _context.md)
|
||||
# Check if it looks like a project
|
||||
if (p / "KB").exists() or (p / "_context.md").exists():
|
||||
projects.append(p.name)
|
||||
return projects
|
||||
@@ -161,25 +100,13 @@ class SessionManager:
|
||||
"""Get full path to a project."""
|
||||
return self.projects_root / project
|
||||
|
||||
def get_capture_dir(self, project: str) -> Path:
|
||||
"""Get the _capture directory for a project."""
|
||||
return self.get_project_path(project) / "_capture"
|
||||
|
||||
@property
|
||||
def sessions_dir(self) -> Path:
|
||||
"""Current project's capture directory."""
|
||||
if self._current_project_path:
|
||||
return self._current_project_path / "_capture"
|
||||
raise RuntimeError("No project selected")
|
||||
|
||||
def start_session(
|
||||
self,
|
||||
name: str,
|
||||
project: str,
|
||||
session_type: SessionType = SessionType.DESIGN,
|
||||
) -> Session:
|
||||
"""Start a new recording session within a project."""
|
||||
# Set current project
|
||||
"""Start a new recording session."""
|
||||
self._current_project_path = self.get_project_path(project)
|
||||
if not self._current_project_path.exists():
|
||||
raise ValueError(f"Project not found: {project}")
|
||||
@@ -192,221 +119,99 @@ class SessionManager:
|
||||
project=project,
|
||||
session_type=session_type,
|
||||
created_at=datetime.now(),
|
||||
status=SessionStatus.RECORDING,
|
||||
)
|
||||
|
||||
# Create session directory in project's _capture folder
|
||||
capture_dir = self._current_project_path / "_capture"
|
||||
session_dir = capture_dir / session_id
|
||||
# Create session directory
|
||||
session_dir = self._current_project_path / "_capture" / session_id
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
(session_dir / "clips").mkdir(exist_ok=True)
|
||||
|
||||
self.current_session = session
|
||||
self._save_session()
|
||||
|
||||
return session
|
||||
|
||||
def start_clip(self) -> tuple[Clip, Path]:
|
||||
"""
|
||||
Start a new clip in current session.
|
||||
Returns clip object and path for recording.
|
||||
"""
|
||||
if not self.current_session:
|
||||
def get_session_dir(self) -> Path:
|
||||
"""Get current session directory."""
|
||||
if not self.current_session or not self._current_project_path:
|
||||
raise RuntimeError("No active session")
|
||||
return self._current_project_path / "_capture" / self.current_session.id
|
||||
|
||||
clip_num = len(self.current_session.clips) + 1
|
||||
clip_id = f"clip-{clip_num:03d}"
|
||||
filename = f"{clip_id}.mp4"
|
||||
def get_video_path(self) -> Path:
|
||||
"""Get path for video file."""
|
||||
return self.get_session_dir() / self.current_session.video_file
|
||||
|
||||
clip = Clip(
|
||||
id=clip_id,
|
||||
filename=filename,
|
||||
start_time=datetime.now(),
|
||||
status=ClipStatus.RECORDING,
|
||||
)
|
||||
|
||||
self.current_session.clips.append(clip)
|
||||
self.current_clip = clip
|
||||
def update_status(self, status: SessionStatus) -> None:
|
||||
"""Update session status."""
|
||||
if self.current_session:
|
||||
self.current_session.status = status
|
||||
self._save_session()
|
||||
|
||||
clip_path = self.sessions_dir / self.current_session.id / "clips" / filename
|
||||
return clip, clip_path
|
||||
|
||||
def end_clip(self, duration: float) -> Clip:
|
||||
"""End current clip, move to preview state."""
|
||||
if not self.current_clip:
|
||||
raise RuntimeError("No active clip")
|
||||
|
||||
self.current_clip.end_time = datetime.now()
|
||||
self.current_clip.duration_seconds = duration
|
||||
self.current_clip.status = ClipStatus.PREVIEW
|
||||
|
||||
clip = self.current_clip
|
||||
self.current_clip = None
|
||||
def set_duration(self, duration: float) -> None:
|
||||
"""Set recording duration."""
|
||||
if self.current_session:
|
||||
self.current_session.duration = duration
|
||||
self._save_session()
|
||||
|
||||
return clip
|
||||
|
||||
def keep_clip(self, clip_id: str, note: str = "") -> None:
|
||||
"""Mark a clip as kept."""
|
||||
if not self.current_session:
|
||||
raise RuntimeError("No active session")
|
||||
|
||||
for clip in self.current_session.clips:
|
||||
if clip.id == clip_id:
|
||||
clip.status = ClipStatus.KEPT
|
||||
clip.note = note
|
||||
break
|
||||
|
||||
def set_transcript(self, transcript_file: str) -> None:
|
||||
"""Set transcript file name."""
|
||||
if self.current_session:
|
||||
self.current_session.transcript_file = transcript_file
|
||||
self._save_session()
|
||||
|
||||
def delete_clip(self, clip_id: str) -> None:
|
||||
"""Mark a clip as deleted and remove file."""
|
||||
if not self.current_session:
|
||||
raise RuntimeError("No active session")
|
||||
|
||||
for clip in self.current_session.clips:
|
||||
if clip.id == clip_id:
|
||||
clip.status = ClipStatus.DELETED
|
||||
|
||||
# Delete the actual file
|
||||
clip_path = self.sessions_dir / self.current_session.id / "clips" / clip.filename
|
||||
if clip_path.exists():
|
||||
clip_path.unlink()
|
||||
break
|
||||
|
||||
self._save_session()
|
||||
|
||||
def keep_last_clip(self, note: str = "") -> Optional[Clip]:
|
||||
"""Keep the most recent clip in preview state."""
|
||||
if not self.current_session:
|
||||
return None
|
||||
|
||||
for clip in reversed(self.current_session.clips):
|
||||
if clip.status == ClipStatus.PREVIEW:
|
||||
self.keep_clip(clip.id, note)
|
||||
return clip
|
||||
return None
|
||||
|
||||
def delete_last_clip(self) -> Optional[Clip]:
|
||||
"""Delete the most recent clip in preview state."""
|
||||
if not self.current_session:
|
||||
return None
|
||||
|
||||
for clip in reversed(self.current_session.clips):
|
||||
if clip.status == ClipStatus.PREVIEW:
|
||||
self.delete_clip(clip.id)
|
||||
return clip
|
||||
return None
|
||||
|
||||
def end_session(self) -> Session:
|
||||
"""
|
||||
End session and prepare for export.
|
||||
Clips still in preview are auto-kept.
|
||||
"""
|
||||
"""End current session."""
|
||||
if not self.current_session:
|
||||
raise RuntimeError("No active session")
|
||||
|
||||
# Auto-keep any clips still in preview
|
||||
for clip in self.current_session.clips:
|
||||
if clip.status == ClipStatus.PREVIEW:
|
||||
clip.status = ClipStatus.KEPT
|
||||
|
||||
self.current_session.is_finalized = True
|
||||
self.current_session.status = SessionStatus.READY
|
||||
self._save_session()
|
||||
|
||||
session = self.current_session
|
||||
self.current_session = None
|
||||
self._current_project_path = None
|
||||
|
||||
return session
|
||||
|
||||
def cancel_session(self) -> None:
|
||||
"""Cancel session and delete all files."""
|
||||
if not self.current_session:
|
||||
return
|
||||
|
||||
session_dir = self.sessions_dir / self.current_session.id
|
||||
"""Cancel session and delete files."""
|
||||
if self.current_session:
|
||||
import shutil
|
||||
session_dir = self.get_session_dir()
|
||||
if session_dir.exists():
|
||||
shutil.rmtree(session_dir)
|
||||
|
||||
self.current_session = None
|
||||
self.current_clip = None
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[Session]:
|
||||
"""Load a session by ID."""
|
||||
session_file = self.sessions_dir / session_id / "session.json"
|
||||
if session_file.exists():
|
||||
with open(session_file) as f:
|
||||
return Session.from_dict(json.load(f))
|
||||
return None
|
||||
self._current_project_path = None
|
||||
|
||||
def list_sessions(self, project: Optional[str] = None) -> List[Session]:
|
||||
"""List sessions, optionally filtered by project."""
|
||||
"""List sessions for a project or all projects."""
|
||||
sessions = []
|
||||
|
||||
if project:
|
||||
# List sessions for specific project
|
||||
capture_dir = self.get_capture_dir(project)
|
||||
capture_dir = self.get_project_path(project) / "_capture"
|
||||
if capture_dir.exists():
|
||||
for session_dir in sorted(capture_dir.iterdir(), reverse=True):
|
||||
if session_dir.is_dir():
|
||||
session_file = session_dir / "session.json"
|
||||
if session_file.exists():
|
||||
try:
|
||||
with open(session_file) as f:
|
||||
sessions.append(Session.from_dict(json.load(f)))
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
# List sessions across all projects
|
||||
for proj in self.list_projects():
|
||||
sessions.extend(self.list_sessions(proj))
|
||||
sessions.sort(key=lambda s: s.created_at, reverse=True)
|
||||
|
||||
return sessions
|
||||
|
||||
def get_session_dir(self, session_id: str) -> Path:
|
||||
"""Get session directory path."""
|
||||
return self.sessions_dir / session_id
|
||||
|
||||
def _save_session(self) -> None:
|
||||
"""Save current session to disk."""
|
||||
if not self.current_session:
|
||||
return
|
||||
|
||||
session_file = self.sessions_dir / self.current_session.id / "session.json"
|
||||
session_file = self.get_session_dir() / "session.json"
|
||||
with open(session_file, "w") as f:
|
||||
json.dump(self.current_session.to_dict(), f, indent=2)
|
||||
|
||||
|
||||
# Quick test
|
||||
if __name__ == "__main__":
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manager = SessionManager(Path(tmpdir))
|
||||
|
||||
# Start session
|
||||
session = manager.start_session(
|
||||
name="Test Session",
|
||||
project="P04-GigaBIT-M1",
|
||||
session_type=SessionType.DESIGN,
|
||||
)
|
||||
print(f"Started session: {session.id}")
|
||||
|
||||
# Record some clips
|
||||
clip1, path1 = manager.start_clip()
|
||||
print(f"Recording clip 1 to: {path1}")
|
||||
manager.end_clip(duration=45.5)
|
||||
manager.keep_clip(clip1.id, note="Stage 2 joint")
|
||||
|
||||
clip2, path2 = manager.start_clip()
|
||||
print(f"Recording clip 2 to: {path2}")
|
||||
manager.end_clip(duration=30.0)
|
||||
manager.delete_clip(clip2.id) # Oops, bad take
|
||||
|
||||
clip3, path3 = manager.start_clip()
|
||||
print(f"Recording clip 3 to: {path3}")
|
||||
manager.end_clip(duration=60.0)
|
||||
manager.keep_last_clip()
|
||||
|
||||
# End session
|
||||
session = manager.end_session()
|
||||
print(f"Session ended: {session.clip_count} clips, {session.total_duration:.1f}s total")
|
||||
|
||||
Reference in New Issue
Block a user