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,92 +1,43 @@
|
||||
"""
|
||||
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):
|
||||
DESIGN = "design" # CAD/Design KB
|
||||
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", ""),
|
||||
)
|
||||
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")
|
||||
|
||||
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
|
||||
return self._current_project_path / "_capture" / self.current_session.id
|
||||
|
||||
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 get_video_path(self) -> Path:
|
||||
"""Get path for video file."""
|
||||
return self.get_session_dir() / self.current_session.video_file
|
||||
|
||||
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 update_status(self, status: SessionStatus) -> None:
|
||||
"""Update session status."""
|
||||
if self.current_session:
|
||||
self.current_session.status = status
|
||||
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 set_duration(self, duration: float) -> None:
|
||||
"""Set recording duration."""
|
||||
if self.current_session:
|
||||
self.current_session.duration = duration
|
||||
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 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 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
|
||||
if session_dir.exists():
|
||||
shutil.rmtree(session_dir)
|
||||
"""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():
|
||||
with open(session_file) as f:
|
||||
sessions.append(Session.from_dict(json.load(f)))
|
||||
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