235 lines
8.3 KiB
Python
235 lines
8.3 KiB
Python
"""StreamManager: coordinates StreamRecorder and SessionProcessor.
|
|
|
|
Thin facade that keeps the existing public API intact while delegating
|
|
to two focused classes:
|
|
|
|
StreamRecorder — ffmpeg network receiver + fMP4 recorder + scene detection
|
|
(to be replaced by cht-server in Rust in a future phase)
|
|
|
|
SessionProcessor — audio extraction from fMP4
|
|
(stays Python; reads files regardless of how they were written)
|
|
|
|
Callers (lifecycle.py, window.py) use StreamManager as before — no changes
|
|
needed there.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from cht.config import SCENE_THRESHOLD, SESSIONS_DIR
|
|
from cht.stream.recorder import StreamRecorder
|
|
from cht.stream.processor import SessionProcessor
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def list_sessions():
|
|
"""Return list of (session_id, session_dir) sorted newest first."""
|
|
if not SESSIONS_DIR.exists():
|
|
return []
|
|
sessions = []
|
|
for d in sorted(SESSIONS_DIR.iterdir(), reverse=True):
|
|
if d.is_dir() and (d / "frames").exists():
|
|
sessions.append((d.name, d))
|
|
return sessions
|
|
|
|
|
|
def delete_sessions(session_ids):
|
|
"""Delete session directories by ID."""
|
|
import shutil
|
|
for sid in session_ids:
|
|
path = SESSIONS_DIR / sid
|
|
if path.exists() and path.is_dir():
|
|
shutil.rmtree(path)
|
|
log.info("Deleted session: %s", sid)
|
|
|
|
|
|
class StreamManager:
|
|
def __init__(self, session_id=None):
|
|
if session_id is None:
|
|
session_id = time.strftime("%Y%m%d_%H%M%S")
|
|
self.session_id = session_id
|
|
self.session_dir = SESSIONS_DIR / session_id
|
|
self.stream_dir = self.session_dir / "stream"
|
|
self.frames_dir = self.session_dir / "frames"
|
|
self.transcript_dir = self.session_dir / "transcript"
|
|
self.audio_dir = self.session_dir / "audio"
|
|
self.agent_dir = self.session_dir / "agent"
|
|
|
|
self.readonly = False
|
|
self._telemetry = None
|
|
|
|
self.recorder = StreamRecorder(self.session_dir)
|
|
self.processor = SessionProcessor(self.session_dir)
|
|
self.processor.attach(
|
|
get_recording_path=lambda: self.recorder.recording_path,
|
|
get_current_global_offset=lambda: self.recorder.current_global_offset,
|
|
)
|
|
# Wire recorder pipe output → processor frame handling
|
|
self.recorder.set_on_raw_frame(self.processor.on_raw_frame)
|
|
|
|
log.info("Session: %s", session_id)
|
|
|
|
@classmethod
|
|
def from_rust_session(cls, session_dir: Path):
|
|
"""Attach to a live session being recorded by cht-server (Rust).
|
|
|
|
No StreamRecorder is started — Rust owns the TCP + fMP4 + UDP relay.
|
|
SessionProcessor handles audio extraction from the growing fMP4.
|
|
Scene detection pipe is also skipped (Rust will handle it eventually).
|
|
"""
|
|
mgr = cls.__new__(cls)
|
|
mgr.session_id = session_dir.name
|
|
mgr.session_dir = session_dir
|
|
mgr.stream_dir = session_dir / "stream"
|
|
mgr.frames_dir = session_dir / "frames"
|
|
mgr.transcript_dir = session_dir / "transcript"
|
|
mgr.audio_dir = session_dir / "audio"
|
|
mgr.agent_dir = session_dir / "agent"
|
|
mgr.readonly = False
|
|
mgr._telemetry = None
|
|
|
|
# No recorder — Rust server owns transport + recording.
|
|
mgr.recorder = None
|
|
mgr.processor = SessionProcessor(session_dir)
|
|
mgr.processor.attach(
|
|
get_recording_path=lambda: next(iter(sorted(mgr.stream_dir.glob("recording_*.mp4"))), None)
|
|
if mgr.stream_dir.exists() else None,
|
|
get_current_global_offset=lambda: 0.0,
|
|
)
|
|
|
|
for d in (mgr.stream_dir, mgr.frames_dir, mgr.transcript_dir,
|
|
mgr.audio_dir, mgr.agent_dir):
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
|
|
log.info("Attached to Rust session: %s", mgr.session_id)
|
|
return mgr
|
|
|
|
@classmethod
|
|
def from_existing(cls, session_id):
|
|
"""Load an existing session without starting any ffmpeg processes."""
|
|
from cht.session import rebuild_manifest
|
|
mgr = cls(session_id=session_id)
|
|
if not mgr.session_dir.exists():
|
|
raise FileNotFoundError(f"Session not found: {session_id}")
|
|
mgr.readonly = True
|
|
mgr.recorder._segment = max(0, len(mgr.recorder.recording_segments) - 1)
|
|
mgr.recorder._rebuild_offsets()
|
|
rebuild_manifest(mgr.session_dir)
|
|
log.info("Loaded existing session: %s (%d segments, %d frames)",
|
|
session_id, len(mgr.recorder.recording_segments), mgr.frame_count)
|
|
return mgr
|
|
|
|
@property
|
|
def telemetry(self):
|
|
return self._telemetry
|
|
|
|
@telemetry.setter
|
|
def telemetry(self, val):
|
|
self._telemetry = val
|
|
if self.processor:
|
|
self.processor._telemetry = val
|
|
|
|
# -- Recorder delegation --
|
|
|
|
@property
|
|
def scene_threshold(self) -> float:
|
|
return self.recorder.scene_threshold if self.recorder else 0.10
|
|
|
|
@property
|
|
def relay_url(self) -> str:
|
|
return self.recorder.relay_url if self.recorder else "udp://127.0.0.1:4445"
|
|
|
|
@property
|
|
def recording_path(self) -> Path:
|
|
if self.recorder:
|
|
return self.recorder.recording_path
|
|
return next(iter(sorted(self.stream_dir.glob("recording_*.mp4"))), None)
|
|
|
|
@property
|
|
def recording_segments(self) -> list[Path]:
|
|
if self.recorder:
|
|
return self.recorder.recording_segments
|
|
return sorted(self.stream_dir.glob("recording_*.mp4"))
|
|
|
|
@property
|
|
def current_global_offset(self) -> float:
|
|
return self.recorder.current_global_offset if self.recorder else 0.0
|
|
|
|
@property
|
|
def frame_count(self) -> int:
|
|
return self.processor.frame_count
|
|
|
|
def setup_dirs(self):
|
|
for d in (self.stream_dir, self.frames_dir, self.transcript_dir,
|
|
self.audio_dir, self.agent_dir):
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
|
|
def start_recorder(self):
|
|
if self.recorder:
|
|
self.recorder.start()
|
|
|
|
def restart_recorder(self):
|
|
if self.recorder:
|
|
self.recorder.restart()
|
|
|
|
def recorder_alive(self) -> bool:
|
|
return self.recorder.alive() if self.recorder else True # Rust owns it
|
|
|
|
def start_scene_detector(self, on_new_frames=None):
|
|
# GUI callback always goes to the processor — it fires on_new_frames
|
|
# after writing the JPEG to disk, regardless of how it got the frame.
|
|
self.processor.set_on_new_frames(on_new_frames)
|
|
if not self.recorder:
|
|
# Rust transport: processor connects to scene.sock and runs its own ffmpeg.
|
|
self.processor.start_scene_detector(threshold=SCENE_THRESHOLD)
|
|
|
|
def capture_now(self, on_new_frames=None):
|
|
self.processor.set_on_new_frames(on_new_frames)
|
|
if self.recorder:
|
|
self.recorder.capture_now(on_raw_frame=self.processor.on_captured_frame)
|
|
else:
|
|
# Rust mode: extract current frame directly from the growing fMP4.
|
|
self.processor.capture_now_from_file()
|
|
|
|
def update_scene_threshold(self, new_threshold: float):
|
|
if self.recorder:
|
|
self.recorder.update_scene_threshold(new_threshold)
|
|
else:
|
|
# Rust mode: restart scene detector with new threshold.
|
|
self.processor.restart_scene_detector(threshold=new_threshold)
|
|
|
|
# -- Processor delegation --
|
|
|
|
def start_audio_extractor(self, on_new_audio=None):
|
|
self.processor.start_audio_extractor(on_new_audio=on_new_audio)
|
|
|
|
# -- Session-level --
|
|
|
|
def total_duration(self) -> float:
|
|
total = 0.0
|
|
for seg in self.recording_segments:
|
|
try:
|
|
import ffmpeg as ffmpeg_lib
|
|
info = ffmpeg_lib.probe(str(seg))
|
|
dur = float(info.get("format", {}).get("duration", 0))
|
|
if dur <= 0:
|
|
for s in info.get("streams", []):
|
|
sdur = float(s.get("duration", 0))
|
|
if sdur > 0:
|
|
dur = sdur
|
|
break
|
|
if dur <= 0:
|
|
dur = seg.stat().st_size / 65_000
|
|
total += dur
|
|
except Exception:
|
|
total += seg.stat().st_size / 65_000
|
|
return total
|
|
|
|
def stop_all(self):
|
|
log.info("Stopping all...")
|
|
self.processor.stop()
|
|
if self.recorder:
|
|
self.recorder.stop()
|