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
This commit is contained in:
181
docs/KB-CAPTURE.md
Normal file
181
docs/KB-CAPTURE.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# KB Capture v2
|
||||||
|
|
||||||
|
**Clip-based recording for engineering knowledge capture.**
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
KB Capture is a lightweight recording tool that captures your CAD/FEM work as short clips, not one long video. Record what matters, delete mistakes, keep the good stuff.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
cd CAD-Documenter
|
||||||
|
uv sync
|
||||||
|
uv pip install customtkinter keyboard
|
||||||
|
|
||||||
|
# Launch
|
||||||
|
uv run kb-capture
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Start Session
|
||||||
|
- Open KB Capture (system tray or GUI)
|
||||||
|
- Enter project name (e.g., "P04-GigaBIT-M1")
|
||||||
|
- Enter session description (e.g., "Vertical support refinement")
|
||||||
|
- Select type: **Design** (CAD) or **Analysis** (FEA)
|
||||||
|
- Click **Start Session**
|
||||||
|
|
||||||
|
### 2. Record Clips
|
||||||
|
While working in NX/CAD:
|
||||||
|
- Press **Ctrl+Shift+R** to start recording
|
||||||
|
- Narrate what you're doing
|
||||||
|
- Say "screenshot" when you want a frame captured
|
||||||
|
- Press **Ctrl+Shift+R** again to stop
|
||||||
|
|
||||||
|
### 3. Review Clips
|
||||||
|
After each clip:
|
||||||
|
- **Keep (K)**: Keep the clip
|
||||||
|
- **Delete (D)**: Discard the clip (bad take)
|
||||||
|
- Or just start recording again (auto-keeps previous)
|
||||||
|
|
||||||
|
### 4. End Session
|
||||||
|
- Press **Ctrl+Shift+E** or click **End Session**
|
||||||
|
- Clips are merged and transcribed
|
||||||
|
- Exported to `clawdbot_export/` for Mario processing
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Action | Shortcut |
|
||||||
|
|--------|----------|
|
||||||
|
| Start/Stop Recording | Ctrl+Shift+R |
|
||||||
|
| Keep Last Clip | Ctrl+Shift+K |
|
||||||
|
| Delete Last Clip | Ctrl+Shift+D |
|
||||||
|
| End Session | Ctrl+Shift+E |
|
||||||
|
|
||||||
|
## Session Types
|
||||||
|
|
||||||
|
| Type | Updates | Use For |
|
||||||
|
|------|---------|---------|
|
||||||
|
| **Design** | KB/Design/ | CAD work, component design, assembly |
|
||||||
|
| **Analysis** | KB/Analysis/ | FEA setup, mesh, BCs, results |
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
After ending a session:
|
||||||
|
|
||||||
|
```
|
||||||
|
sessions/<session-id>/
|
||||||
|
├── clips/
|
||||||
|
│ ├── clip-001.mp4
|
||||||
|
│ ├── clip-002.mp4
|
||||||
|
│ └── ...
|
||||||
|
├── session.json
|
||||||
|
└── clawdbot_export/
|
||||||
|
├── merged.mp4 # All clips merged
|
||||||
|
├── transcript.json # Whisper transcription
|
||||||
|
├── frames/ # Extracted at "screenshot" triggers
|
||||||
|
│ ├── 01_00-30.png
|
||||||
|
│ └── ...
|
||||||
|
└── metadata.json # Session info for Clawdbot
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Happens Next
|
||||||
|
|
||||||
|
1. **Syncthing** syncs `clawdbot_export/` to Clawdbot
|
||||||
|
2. **Mario** detects new session
|
||||||
|
3. **Vision analysis** categorizes frames
|
||||||
|
4. **KB updated** with new information
|
||||||
|
5. **Slack notification** when complete
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
### Recording
|
||||||
|
- Narrate naturally — explain what you're doing
|
||||||
|
- Say "screenshot" before important views
|
||||||
|
- Keep clips short (30s - 2min)
|
||||||
|
- It's okay to delete bad takes
|
||||||
|
|
||||||
|
### Organization
|
||||||
|
- One session per work block (30-60 min)
|
||||||
|
- Use descriptive session names
|
||||||
|
- Match project name to your PKM folder
|
||||||
|
|
||||||
|
### Quality
|
||||||
|
- Close unnecessary windows before recording
|
||||||
|
- Undock NX 3D viewport for clean captures
|
||||||
|
- Speak clearly for better transcription
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Hotkeys not working
|
||||||
|
- Run as Administrator (Windows)
|
||||||
|
- Check for conflicts with other apps
|
||||||
|
- Try restarting KB Capture
|
||||||
|
|
||||||
|
### Recording fails
|
||||||
|
- Ensure FFmpeg is installed: `choco install ffmpeg`
|
||||||
|
- Check disk space
|
||||||
|
- Check microphone permissions
|
||||||
|
|
||||||
|
### No transcription
|
||||||
|
- Whisper needs ~2GB RAM for 'base' model
|
||||||
|
- Try 'tiny' model: `--whisper-model tiny`
|
||||||
|
- Check CUDA/GPU drivers for faster processing
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ KB Capture (Windows) │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ ┌───────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Hotkeys │ │ GUI (optional) │ │
|
||||||
|
│ └─────┬─────┘ └────────┬─────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ Session Manager │ │
|
||||||
|
│ │ (clips, keep/delete, merge) │ │
|
||||||
|
│ └─────────────┬───────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────────┼───────────────────┐ │
|
||||||
|
│ │ ▼ │ │
|
||||||
|
│ │ ┌─────────────────────────┐ │ │
|
||||||
|
│ │ │ Screen Recorder │ │ │
|
||||||
|
│ │ │ (FFmpeg gdigrab) │ │ │
|
||||||
|
│ │ └─────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────────────────┐ │ │
|
||||||
|
│ │ │ Whisper Transcriber │ │ │
|
||||||
|
│ │ │ (local GPU) │ │ │
|
||||||
|
│ │ └─────────────────────────┘ │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ clawdbot_export/ │ │
|
||||||
|
│ │ merged.mp4 + transcript.json │ │
|
||||||
|
│ └─────────────┬───────────────────┘ │
|
||||||
|
└────────────────┼────────────────────────┘
|
||||||
|
│ Syncthing
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Clawdbot (Mario) │
|
||||||
|
│ Vision analysis → KB update → Notify │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Windows 10/11
|
||||||
|
- Python 3.12+
|
||||||
|
- FFmpeg (`choco install ffmpeg`)
|
||||||
|
- CUDA GPU (recommended for Whisper)
|
||||||
|
- ~4GB RAM (for Whisper 'base' model)
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [CAD-Documenter README](../README.md) — Original project overview
|
||||||
|
- [Knowledge Base Skill](http://100.80.199.40:3000/Antoine/clawdbot-shared-skills) — How Mario processes sessions
|
||||||
@@ -41,6 +41,11 @@ dev = [
|
|||||||
gui = [
|
gui = [
|
||||||
"customtkinter>=5.2.0",
|
"customtkinter>=5.2.0",
|
||||||
]
|
]
|
||||||
|
capture = [
|
||||||
|
"customtkinter>=5.2.0",
|
||||||
|
"keyboard>=0.13.5", # Global hotkeys
|
||||||
|
"pystray>=0.19.0", # System tray (optional)
|
||||||
|
]
|
||||||
pdf = [
|
pdf = [
|
||||||
"pandoc", # For PDF generation fallback
|
"pandoc", # For PDF generation fallback
|
||||||
]
|
]
|
||||||
@@ -48,6 +53,7 @@ pdf = [
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
cad-doc = "cad_documenter.cli:main"
|
cad-doc = "cad_documenter.cli:main"
|
||||||
cad-doc-gui = "cad_documenter.gui:main"
|
cad-doc-gui = "cad_documenter.gui:main"
|
||||||
|
kb-capture = "cad_documenter.gui_capture:main"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "http://100.80.199.40:3000/Antoine/CAD-Documenter"
|
Homepage = "http://100.80.199.40:3000/Antoine/CAD-Documenter"
|
||||||
|
|||||||
311
src/cad_documenter/export.py
Normal file
311
src/cad_documenter/export.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
self.on_progress("Transcribing audio...", 0.3)
|
||||||
|
transcript = self._transcribe(merged_path, export_dir)
|
||||||
|
|
||||||
|
# Step 3: Extract frames at screenshot triggers
|
||||||
|
self.on_progress("Extracting frames...", 0.7)
|
||||||
|
frames_dir = export_dir / "frames"
|
||||||
|
self._extract_frames(merged_path, transcript, frames_dir)
|
||||||
|
|
||||||
|
# Step 4: Create metadata
|
||||||
|
self.on_progress("Creating metadata...", 0.9)
|
||||||
|
self._create_metadata(session, export_dir, merged_path)
|
||||||
|
|
||||||
|
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 _create_metadata(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
export_dir: Path,
|
||||||
|
merged_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Create metadata.json for Clawdbot."""
|
||||||
|
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": session.total_duration,
|
||||||
|
"clips": [
|
||||||
|
{
|
||||||
|
"id": clip.id,
|
||||||
|
"duration": clip.duration_seconds,
|
||||||
|
"note": clip.note,
|
||||||
|
}
|
||||||
|
for clip in session.kept_clips
|
||||||
|
],
|
||||||
|
"files": {
|
||||||
|
"video": "merged.mp4",
|
||||||
|
"transcript": "transcript.json",
|
||||||
|
"frames": "frames/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
480
src/cad_documenter/gui_capture.py
Normal file
480
src/cad_documenter/gui_capture.py
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
"""
|
||||||
|
KB Capture GUI
|
||||||
|
|
||||||
|
Modern, clean interface for recording engineering sessions.
|
||||||
|
Uses CustomTkinter for a native look.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import customtkinter as ctk
|
||||||
|
from customtkinter import CTk, CTkFrame, CTkLabel, CTkButton, CTkEntry
|
||||||
|
from customtkinter import CTkOptionMenu, CTkScrollableFrame, CTkProgressBar
|
||||||
|
HAS_CTK = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_CTK = False
|
||||||
|
print("CustomTkinter not installed. Run: pip install customtkinter")
|
||||||
|
|
||||||
|
from .kb_capture import KBCaptureApp, AppState, AppStatus
|
||||||
|
from .session import SessionType, ClipStatus
|
||||||
|
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
COLORS = {
|
||||||
|
"idle": "#6B7280", # Gray
|
||||||
|
"session": "#10B981", # Green
|
||||||
|
"recording": "#EF4444", # Red
|
||||||
|
"preview": "#F59E0B", # Amber
|
||||||
|
"bg_dark": "#1F2937",
|
||||||
|
"bg_light": "#374151",
|
||||||
|
"text": "#F9FAFB",
|
||||||
|
"text_dim": "#9CA3AF",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ClipCard(CTkFrame):
|
||||||
|
"""A card showing a single clip."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
master,
|
||||||
|
clip_id: str,
|
||||||
|
duration: float,
|
||||||
|
status: ClipStatus,
|
||||||
|
note: str = "",
|
||||||
|
on_delete: Optional[callable] = None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
super().__init__(master, **kwargs)
|
||||||
|
|
||||||
|
self.configure(fg_color=COLORS["bg_light"], corner_radius=8)
|
||||||
|
|
||||||
|
# Status indicator
|
||||||
|
status_colors = {
|
||||||
|
ClipStatus.KEPT: "#10B981",
|
||||||
|
ClipStatus.PREVIEW: "#F59E0B",
|
||||||
|
ClipStatus.DELETED: "#EF4444",
|
||||||
|
ClipStatus.RECORDING: "#EF4444",
|
||||||
|
}
|
||||||
|
color = status_colors.get(status, COLORS["idle"])
|
||||||
|
|
||||||
|
# Layout
|
||||||
|
self.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
# Status dot
|
||||||
|
dot = CTkLabel(self, text="●", text_color=color, font=("", 16))
|
||||||
|
dot.grid(row=0, column=0, padx=(10, 5), pady=10)
|
||||||
|
|
||||||
|
# Clip info
|
||||||
|
info_frame = CTkFrame(self, fg_color="transparent")
|
||||||
|
info_frame.grid(row=0, column=1, sticky="w", pady=10)
|
||||||
|
|
||||||
|
title = CTkLabel(
|
||||||
|
info_frame,
|
||||||
|
text=clip_id,
|
||||||
|
font=("", 14, "bold"),
|
||||||
|
text_color=COLORS["text"],
|
||||||
|
)
|
||||||
|
title.pack(anchor="w")
|
||||||
|
|
||||||
|
subtitle = CTkLabel(
|
||||||
|
info_frame,
|
||||||
|
text=f"{duration:.1f}s" + (f" • {note}" if note else ""),
|
||||||
|
font=("", 12),
|
||||||
|
text_color=COLORS["text_dim"],
|
||||||
|
)
|
||||||
|
subtitle.pack(anchor="w")
|
||||||
|
|
||||||
|
# Duration
|
||||||
|
duration_label = CTkLabel(
|
||||||
|
self,
|
||||||
|
text=f"{int(duration//60)}:{int(duration%60):02d}",
|
||||||
|
font=("", 14),
|
||||||
|
text_color=COLORS["text_dim"],
|
||||||
|
)
|
||||||
|
duration_label.grid(row=0, column=2, padx=10)
|
||||||
|
|
||||||
|
# Delete button (only for preview/kept)
|
||||||
|
if status in (ClipStatus.PREVIEW, ClipStatus.KEPT) and on_delete:
|
||||||
|
delete_btn = CTkButton(
|
||||||
|
self,
|
||||||
|
text="🗑️",
|
||||||
|
width=30,
|
||||||
|
height=30,
|
||||||
|
fg_color="transparent",
|
||||||
|
hover_color=COLORS["bg_dark"],
|
||||||
|
command=lambda: on_delete(clip_id),
|
||||||
|
)
|
||||||
|
delete_btn.grid(row=0, column=3, padx=(0, 10))
|
||||||
|
|
||||||
|
|
||||||
|
class KBCaptureGUI:
|
||||||
|
"""Main GUI window for KB Capture."""
|
||||||
|
|
||||||
|
def __init__(self, base_path: Path):
|
||||||
|
if not HAS_CTK:
|
||||||
|
raise RuntimeError("CustomTkinter not installed")
|
||||||
|
|
||||||
|
# App
|
||||||
|
self.app = KBCaptureApp(
|
||||||
|
base_path=base_path,
|
||||||
|
on_status_change=self._on_status_change,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Window
|
||||||
|
ctk.set_appearance_mode("dark")
|
||||||
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
|
self.window = CTk()
|
||||||
|
self.window.title("KB Capture")
|
||||||
|
self.window.geometry("500x600")
|
||||||
|
self.window.minsize(400, 500)
|
||||||
|
|
||||||
|
# Build UI
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
# Start app
|
||||||
|
self.app.start()
|
||||||
|
|
||||||
|
# Cleanup on close
|
||||||
|
self.window.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
"""Build the main UI."""
|
||||||
|
self.window.grid_columnconfigure(0, weight=1)
|
||||||
|
self.window.grid_rowconfigure(2, weight=1)
|
||||||
|
|
||||||
|
# === Header ===
|
||||||
|
header = CTkFrame(self.window, fg_color="transparent")
|
||||||
|
header.grid(row=0, column=0, sticky="ew", padx=20, pady=(20, 10))
|
||||||
|
header.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
self.status_indicator = CTkLabel(
|
||||||
|
header,
|
||||||
|
text="●",
|
||||||
|
font=("", 32),
|
||||||
|
text_color=COLORS["idle"],
|
||||||
|
)
|
||||||
|
self.status_indicator.grid(row=0, column=0, padx=(0, 10))
|
||||||
|
|
||||||
|
self.status_label = CTkLabel(
|
||||||
|
header,
|
||||||
|
text="Ready",
|
||||||
|
font=("", 24, "bold"),
|
||||||
|
text_color=COLORS["text"],
|
||||||
|
)
|
||||||
|
self.status_label.grid(row=0, column=1, sticky="w")
|
||||||
|
|
||||||
|
self.duration_label = CTkLabel(
|
||||||
|
header,
|
||||||
|
text="",
|
||||||
|
font=("", 20),
|
||||||
|
text_color=COLORS["text_dim"],
|
||||||
|
)
|
||||||
|
self.duration_label.grid(row=0, column=2)
|
||||||
|
|
||||||
|
# === Session Info / Start Form ===
|
||||||
|
self.session_frame = CTkFrame(self.window, fg_color=COLORS["bg_light"], corner_radius=12)
|
||||||
|
self.session_frame.grid(row=1, column=0, sticky="ew", padx=20, pady=10)
|
||||||
|
self.session_frame.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
# Project
|
||||||
|
CTkLabel(self.session_frame, text="Project:", text_color=COLORS["text_dim"]).grid(
|
||||||
|
row=0, column=0, padx=15, pady=(15, 5), sticky="w"
|
||||||
|
)
|
||||||
|
self.project_entry = CTkEntry(
|
||||||
|
self.session_frame,
|
||||||
|
placeholder_text="P04-GigaBIT-M1",
|
||||||
|
width=250,
|
||||||
|
)
|
||||||
|
self.project_entry.grid(row=0, column=1, padx=(0, 15), pady=(15, 5), sticky="ew")
|
||||||
|
|
||||||
|
# Session name
|
||||||
|
CTkLabel(self.session_frame, text="Session:", text_color=COLORS["text_dim"]).grid(
|
||||||
|
row=1, column=0, padx=15, pady=5, sticky="w"
|
||||||
|
)
|
||||||
|
self.name_entry = CTkEntry(
|
||||||
|
self.session_frame,
|
||||||
|
placeholder_text="What are you working on?",
|
||||||
|
width=250,
|
||||||
|
)
|
||||||
|
self.name_entry.grid(row=1, column=1, padx=(0, 15), pady=5, sticky="ew")
|
||||||
|
|
||||||
|
# Session type
|
||||||
|
CTkLabel(self.session_frame, text="Type:", text_color=COLORS["text_dim"]).grid(
|
||||||
|
row=2, column=0, padx=15, pady=5, sticky="w"
|
||||||
|
)
|
||||||
|
self.type_menu = CTkOptionMenu(
|
||||||
|
self.session_frame,
|
||||||
|
values=["Design", "Analysis"],
|
||||||
|
width=150,
|
||||||
|
)
|
||||||
|
self.type_menu.grid(row=2, column=1, padx=(0, 15), pady=5, sticky="w")
|
||||||
|
|
||||||
|
# Start button
|
||||||
|
self.start_btn = CTkButton(
|
||||||
|
self.session_frame,
|
||||||
|
text="Start Session",
|
||||||
|
font=("", 14, "bold"),
|
||||||
|
height=40,
|
||||||
|
command=self._start_session,
|
||||||
|
)
|
||||||
|
self.start_btn.grid(row=3, column=0, columnspan=2, padx=15, pady=15, sticky="ew")
|
||||||
|
|
||||||
|
# === Clips List ===
|
||||||
|
clips_header = CTkFrame(self.window, fg_color="transparent")
|
||||||
|
clips_header.grid(row=2, column=0, sticky="new", padx=20, pady=(10, 0))
|
||||||
|
clips_header.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
CTkLabel(
|
||||||
|
clips_header,
|
||||||
|
text="Clips",
|
||||||
|
font=("", 16, "bold"),
|
||||||
|
text_color=COLORS["text"],
|
||||||
|
).grid(row=0, column=0, sticky="w")
|
||||||
|
|
||||||
|
self.clips_count = CTkLabel(
|
||||||
|
clips_header,
|
||||||
|
text="0 clips • 0:00",
|
||||||
|
font=("", 14),
|
||||||
|
text_color=COLORS["text_dim"],
|
||||||
|
)
|
||||||
|
self.clips_count.grid(row=0, column=1, sticky="e")
|
||||||
|
|
||||||
|
self.clips_frame = CTkScrollableFrame(
|
||||||
|
self.window,
|
||||||
|
fg_color="transparent",
|
||||||
|
)
|
||||||
|
self.clips_frame.grid(row=3, column=0, sticky="nsew", padx=20, pady=10)
|
||||||
|
self.clips_frame.grid_columnconfigure(0, weight=1)
|
||||||
|
self.window.grid_rowconfigure(3, weight=1)
|
||||||
|
|
||||||
|
# Empty state
|
||||||
|
self.empty_label = CTkLabel(
|
||||||
|
self.clips_frame,
|
||||||
|
text="No clips yet.\nPress Ctrl+Shift+R to start recording.",
|
||||||
|
font=("", 14),
|
||||||
|
text_color=COLORS["text_dim"],
|
||||||
|
justify="center",
|
||||||
|
)
|
||||||
|
self.empty_label.grid(row=0, column=0, pady=40)
|
||||||
|
|
||||||
|
# === Control Bar ===
|
||||||
|
controls = CTkFrame(self.window, fg_color=COLORS["bg_light"], corner_radius=0)
|
||||||
|
controls.grid(row=4, column=0, sticky="sew", pady=0)
|
||||||
|
controls.grid_columnconfigure((0, 1, 2), weight=1)
|
||||||
|
|
||||||
|
self.record_btn = CTkButton(
|
||||||
|
controls,
|
||||||
|
text="⏺️ Record",
|
||||||
|
font=("", 14),
|
||||||
|
height=50,
|
||||||
|
fg_color=COLORS["recording"],
|
||||||
|
hover_color="#DC2626",
|
||||||
|
command=self.app.toggle_recording,
|
||||||
|
state="disabled",
|
||||||
|
)
|
||||||
|
self.record_btn.grid(row=0, column=0, padx=5, pady=10, sticky="ew")
|
||||||
|
|
||||||
|
self.keep_btn = CTkButton(
|
||||||
|
controls,
|
||||||
|
text="✓ Keep",
|
||||||
|
font=("", 14),
|
||||||
|
height=50,
|
||||||
|
fg_color=COLORS["session"],
|
||||||
|
hover_color="#059669",
|
||||||
|
command=lambda: self.app.keep_last_clip(),
|
||||||
|
state="disabled",
|
||||||
|
)
|
||||||
|
self.keep_btn.grid(row=0, column=1, padx=5, pady=10, sticky="ew")
|
||||||
|
|
||||||
|
self.end_btn = CTkButton(
|
||||||
|
controls,
|
||||||
|
text="End Session",
|
||||||
|
font=("", 14),
|
||||||
|
height=50,
|
||||||
|
fg_color=COLORS["idle"],
|
||||||
|
hover_color="#4B5563",
|
||||||
|
command=self._end_session,
|
||||||
|
state="disabled",
|
||||||
|
)
|
||||||
|
self.end_btn.grid(row=0, column=2, padx=5, pady=10, sticky="ew")
|
||||||
|
|
||||||
|
# Hotkey hints
|
||||||
|
hints = CTkLabel(
|
||||||
|
controls,
|
||||||
|
text="Ctrl+Shift+R: Record • K: Keep • D: Delete • E: End",
|
||||||
|
font=("", 11),
|
||||||
|
text_color=COLORS["text_dim"],
|
||||||
|
)
|
||||||
|
hints.grid(row=1, column=0, columnspan=3, pady=(0, 10))
|
||||||
|
|
||||||
|
def _start_session(self):
|
||||||
|
"""Start a new session."""
|
||||||
|
project = self.project_entry.get() or "P04-GigaBIT-M1"
|
||||||
|
name = self.name_entry.get() or "Recording Session"
|
||||||
|
session_type = SessionType.DESIGN if self.type_menu.get() == "Design" else SessionType.ANALYSIS
|
||||||
|
|
||||||
|
self.app.start_session(name, project, session_type)
|
||||||
|
|
||||||
|
# Update UI
|
||||||
|
self.start_btn.configure(state="disabled", text="Session Active")
|
||||||
|
self.record_btn.configure(state="normal")
|
||||||
|
self.end_btn.configure(state="normal")
|
||||||
|
self.project_entry.configure(state="disabled")
|
||||||
|
self.name_entry.configure(state="disabled")
|
||||||
|
self.type_menu.configure(state="disabled")
|
||||||
|
|
||||||
|
def _end_session(self):
|
||||||
|
"""End the current session."""
|
||||||
|
session = self.app.end_session()
|
||||||
|
|
||||||
|
if session:
|
||||||
|
# Show summary
|
||||||
|
self.status_label.configure(text="Session Saved!")
|
||||||
|
|
||||||
|
# Reset UI
|
||||||
|
self.start_btn.configure(state="normal", text="Start Session")
|
||||||
|
self.record_btn.configure(state="disabled")
|
||||||
|
self.keep_btn.configure(state="disabled")
|
||||||
|
self.end_btn.configure(state="disabled")
|
||||||
|
self.project_entry.configure(state="normal")
|
||||||
|
self.name_entry.configure(state="normal")
|
||||||
|
self.type_menu.configure(state="normal")
|
||||||
|
|
||||||
|
# Clear clips
|
||||||
|
for widget in self.clips_frame.winfo_children():
|
||||||
|
widget.destroy()
|
||||||
|
self.empty_label = CTkLabel(
|
||||||
|
self.clips_frame,
|
||||||
|
text=f"Session saved: {session.clip_count} clips, {session.total_duration:.0f}s\nReady for next session.",
|
||||||
|
font=("", 14),
|
||||||
|
text_color=COLORS["text_dim"],
|
||||||
|
justify="center",
|
||||||
|
)
|
||||||
|
self.empty_label.grid(row=0, column=0, pady=40)
|
||||||
|
|
||||||
|
def _on_status_change(self, status: AppStatus):
|
||||||
|
"""Handle status updates from the app."""
|
||||||
|
# Run on main thread
|
||||||
|
self.window.after(0, lambda: self._update_ui(status))
|
||||||
|
|
||||||
|
def _update_ui(self, status: AppStatus):
|
||||||
|
"""Update UI based on status."""
|
||||||
|
# State colors and labels
|
||||||
|
state_config = {
|
||||||
|
AppState.IDLE: (COLORS["idle"], "Ready", ""),
|
||||||
|
AppState.SESSION_ACTIVE: (COLORS["session"], "Session Active", ""),
|
||||||
|
AppState.RECORDING: (COLORS["recording"], "Recording", ""),
|
||||||
|
AppState.PREVIEW: (COLORS["preview"], "Review Clip", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
color, label, _ = state_config.get(status.state, (COLORS["idle"], "Ready", ""))
|
||||||
|
|
||||||
|
self.status_indicator.configure(text_color=color)
|
||||||
|
|
||||||
|
if status.state == AppState.RECORDING:
|
||||||
|
self.status_label.configure(text=f"Recording... {status.current_clip_duration:.1f}s")
|
||||||
|
self.record_btn.configure(text="⏹️ Stop", fg_color=COLORS["recording"])
|
||||||
|
elif status.state == AppState.PREVIEW:
|
||||||
|
self.status_label.configure(text=f"Keep or Delete? ({status.last_clip_duration:.1f}s)")
|
||||||
|
self.record_btn.configure(text="⏺️ Record", fg_color=COLORS["session"])
|
||||||
|
self.keep_btn.configure(state="normal")
|
||||||
|
else:
|
||||||
|
self.status_label.configure(text=label)
|
||||||
|
self.record_btn.configure(text="⏺️ Record", fg_color=COLORS["recording"])
|
||||||
|
if status.state != AppState.IDLE:
|
||||||
|
self.keep_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
# Duration
|
||||||
|
if status.state == AppState.RECORDING:
|
||||||
|
secs = int(status.current_clip_duration)
|
||||||
|
self.duration_label.configure(text=f"{secs//60}:{secs%60:02d}")
|
||||||
|
elif status.total_duration > 0:
|
||||||
|
secs = int(status.total_duration)
|
||||||
|
self.duration_label.configure(text=f"Total: {secs//60}:{secs%60:02d}")
|
||||||
|
else:
|
||||||
|
self.duration_label.configure(text="")
|
||||||
|
|
||||||
|
# Clips count
|
||||||
|
self.clips_count.configure(
|
||||||
|
text=f"{status.clip_count} clips • {int(status.total_duration//60)}:{int(status.total_duration%60):02d}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update clips list
|
||||||
|
self._update_clips_list()
|
||||||
|
|
||||||
|
def _update_clips_list(self):
|
||||||
|
"""Update the clips list display."""
|
||||||
|
session = self.app.session_manager.current_session
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Clear existing
|
||||||
|
for widget in self.clips_frame.winfo_children():
|
||||||
|
widget.destroy()
|
||||||
|
|
||||||
|
if not session.clips:
|
||||||
|
self.empty_label = CTkLabel(
|
||||||
|
self.clips_frame,
|
||||||
|
text="No clips yet.\nPress Ctrl+Shift+R to start recording.",
|
||||||
|
font=("", 14),
|
||||||
|
text_color=COLORS["text_dim"],
|
||||||
|
justify="center",
|
||||||
|
)
|
||||||
|
self.empty_label.grid(row=0, column=0, pady=40)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add clip cards (reversed for newest first)
|
||||||
|
for i, clip in enumerate(reversed(session.clips)):
|
||||||
|
if clip.status != ClipStatus.DELETED:
|
||||||
|
card = ClipCard(
|
||||||
|
self.clips_frame,
|
||||||
|
clip_id=clip.id,
|
||||||
|
duration=clip.duration_seconds,
|
||||||
|
status=clip.status,
|
||||||
|
note=clip.note,
|
||||||
|
on_delete=self._delete_clip,
|
||||||
|
)
|
||||||
|
card.grid(row=i, column=0, sticky="ew", pady=2)
|
||||||
|
|
||||||
|
def _delete_clip(self, clip_id: str):
|
||||||
|
"""Delete a clip."""
|
||||||
|
self.app.session_manager.delete_clip(clip_id)
|
||||||
|
self._update_clips_list()
|
||||||
|
|
||||||
|
def _on_close(self):
|
||||||
|
"""Handle window close."""
|
||||||
|
self.app.stop()
|
||||||
|
self.window.destroy()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Run the GUI main loop."""
|
||||||
|
self.window.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Entry point for GUI."""
|
||||||
|
if not HAS_CTK:
|
||||||
|
print("Error: CustomTkinter not installed")
|
||||||
|
print("Install with: pip install customtkinter")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Default to user's documents folder
|
||||||
|
if sys.platform == "win32":
|
||||||
|
base = Path.home() / "Documents" / "KB-Capture"
|
||||||
|
else:
|
||||||
|
base = Path.home() / "kb-capture"
|
||||||
|
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
gui = KBCaptureGUI(base_path=base)
|
||||||
|
gui.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
161
src/cad_documenter/hotkeys.py
Normal file
161
src/cad_documenter/hotkeys.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
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")
|
||||||
364
src/cad_documenter/kb_capture.py
Normal file
364
src/cad_documenter/kb_capture.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
"""
|
||||||
|
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!")
|
||||||
257
src/cad_documenter/recorder.py
Normal file
257
src/cad_documenter/recorder.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"""
|
||||||
|
Screen + Audio Recorder using FFmpeg
|
||||||
|
|
||||||
|
Records screen and microphone to video file.
|
||||||
|
Supports Windows (gdigrab) and Linux (x11grab).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Callable
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RecordingConfig:
|
||||||
|
"""Recording configuration."""
|
||||||
|
output_path: Path
|
||||||
|
framerate: int = 30
|
||||||
|
audio_device: Optional[str] = None # None = default mic
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenRecorder:
|
||||||
|
"""
|
||||||
|
FFmpeg-based screen recorder with audio.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
recorder = ScreenRecorder()
|
||||||
|
recorder.start(config)
|
||||||
|
# ... recording ...
|
||||||
|
recorder.stop()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, on_status: Optional[Callable[[str], None]] = None):
|
||||||
|
self.process: Optional[subprocess.Popen] = None
|
||||||
|
self.is_recording = False
|
||||||
|
self.start_time: Optional[float] = None
|
||||||
|
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."""
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# Windows: gdigrab for screen, dshow for audio
|
||||||
|
args = [
|
||||||
|
"-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}",
|
||||||
|
])
|
||||||
|
|
||||||
|
args.extend(["-i", "desktop"])
|
||||||
|
|
||||||
|
# Add audio input
|
||||||
|
if config.audio_device:
|
||||||
|
args.extend([
|
||||||
|
"-f", "dshow",
|
||||||
|
"-i", f"audio={config.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 = [
|
||||||
|
"-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([
|
||||||
|
"-f", "pulse",
|
||||||
|
"-i", "default",
|
||||||
|
])
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
def start(self, config: RecordingConfig) -> bool:
|
||||||
|
"""Start recording."""
|
||||||
|
if self.is_recording:
|
||||||
|
self.on_status("Already recording")
|
||||||
|
return False
|
||||||
|
|
||||||
|
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),
|
||||||
|
])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start FFmpeg process
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.is_recording = True
|
||||||
|
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()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.on_status("FFmpeg not found. Please install FFmpeg.")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.on_status(f"Failed to start recording: {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 stop(self) -> Optional[Path]:
|
||||||
|
"""Stop recording and return output path."""
|
||||||
|
if not self.is_recording or not self.process:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send 'q' to FFmpeg to stop gracefully
|
||||||
|
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.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")
|
||||||
|
|
||||||
|
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
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_audio_devices() -> list[str]:
|
||||||
|
"""List available audio input devices (Windows only)."""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return ["default"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ffmpeg", "-list_devices", "true", "-f", "dshow", "-i", "dummy"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse audio devices from stderr
|
||||||
|
devices = []
|
||||||
|
in_audio = False
|
||||||
|
for line in result.stderr.split("\n"):
|
||||||
|
if "DirectShow audio devices" in line:
|
||||||
|
in_audio = True
|
||||||
|
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"]
|
||||||
|
|
||||||
|
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}")
|
||||||
361
src/cad_documenter/session.py
Normal file
361
src/cad_documenter/session.py
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
"""
|
||||||
|
Session Manager for KB Capture
|
||||||
|
|
||||||
|
Manages recording sessions with multiple clips.
|
||||||
|
Clips can be kept or deleted before finalizing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
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):
|
||||||
|
DESIGN = "design" # CAD/Design KB
|
||||||
|
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", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Session:
|
||||||
|
"""A recording session containing multiple clips."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Session":
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
name=data["name"],
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
"""
|
||||||
|
Manages recording sessions and clips.
|
||||||
|
|
||||||
|
Directory structure:
|
||||||
|
sessions/
|
||||||
|
├── <session-id>/
|
||||||
|
│ ├── session.json # Session metadata
|
||||||
|
│ ├── clips/
|
||||||
|
│ │ ├── clip-001.mp4
|
||||||
|
│ │ ├── clip-002.mp4
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── export/ # Final export for Clawdbot
|
||||||
|
│ ├── merged.mp4
|
||||||
|
│ ├── transcript.json
|
||||||
|
│ └── metadata.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_path: Path):
|
||||||
|
self.base_path = Path(base_path)
|
||||||
|
self.sessions_dir = self.base_path / "sessions"
|
||||||
|
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self.current_session: Optional[Session] = None
|
||||||
|
self.current_clip: Optional[Clip] = None
|
||||||
|
|
||||||
|
def start_session(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
project: str,
|
||||||
|
session_type: SessionType = SessionType.DESIGN,
|
||||||
|
) -> Session:
|
||||||
|
"""Start a new recording session."""
|
||||||
|
session_id = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
|
||||||
|
session = Session(
|
||||||
|
id=session_id,
|
||||||
|
name=name,
|
||||||
|
project=project,
|
||||||
|
session_type=session_type,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create session directory
|
||||||
|
session_dir = self.sessions_dir / 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:
|
||||||
|
raise RuntimeError("No active session")
|
||||||
|
|
||||||
|
clip_num = len(self.current_session.clips) + 1
|
||||||
|
clip_id = f"clip-{clip_num:03d}"
|
||||||
|
filename = f"{clip_id}.mp4"
|
||||||
|
|
||||||
|
clip = Clip(
|
||||||
|
id=clip_id,
|
||||||
|
filename=filename,
|
||||||
|
start_time=datetime.now(),
|
||||||
|
status=ClipStatus.RECORDING,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.current_session.clips.append(clip)
|
||||||
|
self.current_clip = clip
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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._save_session()
|
||||||
|
|
||||||
|
session = self.current_session
|
||||||
|
self.current_session = 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
|
||||||
|
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
|
||||||
|
|
||||||
|
def list_sessions(self) -> List[Session]:
|
||||||
|
"""List all sessions."""
|
||||||
|
sessions = []
|
||||||
|
for session_dir in sorted(self.sessions_dir.iterdir(), reverse=True):
|
||||||
|
if session_dir.is_dir():
|
||||||
|
session_file = session_dir / "session.json"
|
||||||
|
if session_file.exists():
|
||||||
|
with open(session_file) as f:
|
||||||
|
sessions.append(Session.from_dict(json.load(f)))
|
||||||
|
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"
|
||||||
|
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