some changes
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
"""
|
||||
StreamManager: orchestrates ffmpeg pipelines for receiving, recording,
|
||||
frame extraction, and audio extraction from a muxed mpegts/TCP stream.
|
||||
and frame extraction from a muxed mpegts/TCP stream.
|
||||
|
||||
All data goes to disk. UI reads from disk.
|
||||
All ffmpeg commands go through cht.stream.ffmpeg module.
|
||||
Architecture:
|
||||
sender → TCP:4444 → ffmpeg (writes growing recording.ts)
|
||||
└→ mpv plays recording.ts (DVR: live edge + scrub)
|
||||
└→ ffmpeg scene detection (periodic on recording)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -17,7 +18,6 @@ from threading import Thread
|
||||
from cht.config import (
|
||||
STREAM_HOST,
|
||||
STREAM_PORT,
|
||||
SEGMENT_DURATION,
|
||||
SCENE_THRESHOLD,
|
||||
MAX_FRAME_INTERVAL,
|
||||
SESSIONS_DIR,
|
||||
@@ -52,10 +52,129 @@ class StreamManager:
|
||||
def stream_url(self):
|
||||
return f"tcp://{STREAM_HOST}:{STREAM_PORT}?listen"
|
||||
|
||||
def start_all(self):
|
||||
self.setup_dirs()
|
||||
self.start_recorder()
|
||||
self.start_frame_extractor()
|
||||
@property
|
||||
def recording_path(self):
|
||||
return self.stream_dir / "recording.ts"
|
||||
|
||||
# -- Recording --
|
||||
|
||||
def start_recorder(self):
|
||||
"""Start ffmpeg to receive TCP stream and write to recording.ts."""
|
||||
node = ff.receive_and_record(self.stream_url, self.recording_path)
|
||||
proc = ff.run_async(node, pipe_stderr=True)
|
||||
self._procs["recorder"] = proc
|
||||
log.info("Recorder started: pid=%s url=%s → %s", proc.pid, self.stream_url, self.recording_path)
|
||||
self._start_stderr_reader("recorder", proc)
|
||||
|
||||
# -- Scene detection --
|
||||
|
||||
def start_scene_detector(self):
|
||||
"""Periodically run ffmpeg scene detection on the growing recording.
|
||||
|
||||
Tracks how far we've processed to avoid re-scanning from the start.
|
||||
"""
|
||||
log.info("Starting scene detector (threshold=%.2f, interval=%ds)",
|
||||
SCENE_THRESHOLD, MAX_FRAME_INTERVAL)
|
||||
|
||||
def _detect():
|
||||
last_processed_size = 0
|
||||
processed_duration = 0.0 # seconds already processed
|
||||
frame_count = 0
|
||||
|
||||
while "stop" not in self._stop_flags:
|
||||
time.sleep(10)
|
||||
if not self.recording_path.exists():
|
||||
continue
|
||||
size = self.recording_path.stat().st_size
|
||||
if size <= last_processed_size or size < 100_000:
|
||||
continue
|
||||
|
||||
log.info("Recording grew: %d → %d bytes, scanning from %.1fs",
|
||||
last_processed_size, size, processed_duration)
|
||||
last_processed_size = size
|
||||
|
||||
try:
|
||||
new_count, new_duration = self._extract_new_frames(
|
||||
self.recording_path,
|
||||
start_time=processed_duration,
|
||||
start_number=frame_count + 1,
|
||||
)
|
||||
if new_count > 0:
|
||||
frame_count += new_count
|
||||
log.info("Found %d new frames (total: %d)", new_count, frame_count)
|
||||
if new_duration > processed_duration:
|
||||
processed_duration = new_duration
|
||||
except Exception as e:
|
||||
log.error("Scene detection failed: %s", e)
|
||||
|
||||
log.info("Scene detector stopped")
|
||||
|
||||
t = Thread(target=_detect, daemon=True, name="scene_detector")
|
||||
t.start()
|
||||
self._threads["scene_detector"] = t
|
||||
|
||||
def _extract_new_frames(self, path, start_time=0.0, start_number=1):
|
||||
"""Extract scene-change frames starting from a given timestamp.
|
||||
|
||||
Returns (new_frame_count, max_timestamp_seen).
|
||||
"""
|
||||
existing_before = set(f.name for f in self.frames_dir.glob("F*.jpg"))
|
||||
|
||||
try:
|
||||
_stdout, stderr = ff.extract_scene_frames(
|
||||
path,
|
||||
self.frames_dir,
|
||||
scene_threshold=SCENE_THRESHOLD,
|
||||
max_interval=MAX_FRAME_INTERVAL,
|
||||
start_number=start_number,
|
||||
start_time=start_time,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("ffmpeg scene extraction error: %s", e)
|
||||
return 0, start_time
|
||||
|
||||
if stderr:
|
||||
for line in stderr.splitlines()[:5]:
|
||||
log.debug("[scene_detect:stderr] %s", line)
|
||||
|
||||
# Parse timestamps and update index
|
||||
max_ts = start_time
|
||||
new_count = 0
|
||||
index_path = self.frames_dir / "index.json"
|
||||
if index_path.exists():
|
||||
with open(index_path) as f:
|
||||
index = json.load(f)
|
||||
else:
|
||||
index = []
|
||||
|
||||
frame_num = start_number
|
||||
for line in stderr.splitlines():
|
||||
if "showinfo" not in line:
|
||||
continue
|
||||
pts_match = re.search(r"pts_time:\s*([\d.]+)", line)
|
||||
if pts_match:
|
||||
pts_time = float(pts_match.group(1))
|
||||
frame_id = f"F{frame_num:04d}"
|
||||
frame_path = self.frames_dir / f"{frame_id}.jpg"
|
||||
if frame_path.exists() and frame_path.name not in existing_before:
|
||||
index.append({
|
||||
"id": frame_id,
|
||||
"timestamp": pts_time,
|
||||
"path": str(frame_path),
|
||||
"sent_to_agent": False,
|
||||
})
|
||||
log.info("Indexed frame %s at pts=%.2f", frame_id, pts_time)
|
||||
new_count += 1
|
||||
if pts_time > max_ts:
|
||||
max_ts = pts_time
|
||||
frame_num += 1
|
||||
|
||||
with open(index_path, "w") as f:
|
||||
json.dump(index, f, indent=2)
|
||||
|
||||
return new_count, max_ts
|
||||
|
||||
# -- Lifecycle --
|
||||
|
||||
def stop_all(self):
|
||||
log.info("Stopping all processes...")
|
||||
@@ -66,44 +185,7 @@ class StreamManager:
|
||||
self._procs.clear()
|
||||
log.info("All processes stopped")
|
||||
|
||||
def start_recorder(self):
|
||||
node = ff.receive_and_segment(
|
||||
self.stream_url, self.stream_dir, SEGMENT_DURATION,
|
||||
)
|
||||
proc = ff.run_async(node, pipe_stderr=True)
|
||||
self._procs["recorder"] = proc
|
||||
log.info("Recorder started: pid=%s url=%s", proc.pid, self.stream_url)
|
||||
self._start_stderr_reader("recorder", proc)
|
||||
|
||||
def start_receiver_pipe(self):
|
||||
"""Receive stream, pipe stdout to mpv, save segments to disk."""
|
||||
self.setup_dirs()
|
||||
node = ff.receive_to_pipe(
|
||||
self.stream_url,
|
||||
segment_dir=self.stream_dir,
|
||||
segment_duration=SEGMENT_DURATION,
|
||||
)
|
||||
proc = ff.run_async(node, pipe_stdout=True, pipe_stderr=True)
|
||||
self._procs["receiver"] = proc
|
||||
log.info("Receiver started: pid=%s url=%s (pipe + segments)", proc.pid, self.stream_url)
|
||||
self._start_stderr_reader("receiver", proc)
|
||||
return proc
|
||||
|
||||
def start_recorder_with_monitor(self):
|
||||
self.setup_dirs()
|
||||
fifo_path = self.session_dir / "monitor.pipe"
|
||||
|
||||
node = ff.receive_and_segment_with_monitor(
|
||||
self.stream_url, self.stream_dir, fifo_path, SEGMENT_DURATION,
|
||||
)
|
||||
proc = ff.run_async(node, pipe_stderr=True)
|
||||
self._procs["recorder"] = proc
|
||||
log.info("Recorder+monitor started: pid=%s url=%s fifo=%s", proc.pid, self.stream_url, fifo_path)
|
||||
self._start_stderr_reader("recorder", proc)
|
||||
return fifo_path
|
||||
|
||||
def _start_stderr_reader(self, name, proc):
|
||||
"""Read stderr from a process in a thread and log it."""
|
||||
def _read():
|
||||
try:
|
||||
for line in proc.stderr:
|
||||
@@ -117,116 +199,3 @@ class StreamManager:
|
||||
|
||||
t = Thread(target=_read, daemon=True, name=f"{name}_stderr")
|
||||
t.start()
|
||||
|
||||
def start_frame_extractor_on_recording(self, recording_path):
|
||||
"""Extract frames periodically from a growing recording file."""
|
||||
log.info("Starting frame extractor on recording: %s", recording_path)
|
||||
self._recording_path = recording_path
|
||||
self._start_recording_frame_watcher()
|
||||
|
||||
def _start_recording_frame_watcher(self):
|
||||
def _watch():
|
||||
last_size = 0
|
||||
log.info("Recording frame watcher running, watching %s", self._recording_path)
|
||||
while "stop" not in self._stop_flags:
|
||||
if self._recording_path.exists():
|
||||
size = self._recording_path.stat().st_size
|
||||
if size > last_size and size > 100_000: # wait for some data
|
||||
log.info("Recording grew: %d -> %d bytes, extracting frames", last_size, size)
|
||||
last_size = size
|
||||
self._extract_frames_from_file(self._recording_path)
|
||||
time.sleep(10) # check every 10s
|
||||
log.info("Recording frame watcher stopped")
|
||||
|
||||
t = Thread(target=_watch, daemon=True, name="recording_frame_watcher")
|
||||
t.start()
|
||||
self._threads["recording_frame_watcher"] = t
|
||||
|
||||
def start_frame_extractor(self):
|
||||
log.info("Starting frame watcher...")
|
||||
self._start_frame_watcher()
|
||||
|
||||
def _start_frame_watcher(self):
|
||||
def _watch():
|
||||
seen = set()
|
||||
log.info("Frame watcher running, watching %s", self.stream_dir)
|
||||
while "stop" not in self._stop_flags:
|
||||
segments = sorted(self.stream_dir.glob("segment_*.ts"))
|
||||
for seg in segments:
|
||||
if seg.name not in seen and seg.stat().st_size > 0:
|
||||
seen.add(seg.name)
|
||||
log.info("New segment found: %s (%d bytes)", seg.name, seg.stat().st_size)
|
||||
self._extract_frames_from_file(seg)
|
||||
time.sleep(2)
|
||||
log.info("Frame watcher stopped")
|
||||
|
||||
t = Thread(target=_watch, daemon=True, name="frame_watcher")
|
||||
t.start()
|
||||
self._threads["frame_watcher"] = t
|
||||
|
||||
def _extract_frames_from_file(self, segment_path):
|
||||
existing = list(self.frames_dir.glob("F*.jpg"))
|
||||
start_num = len(existing) + 1
|
||||
log.info("Extracting frames from %s (start_num=%d)", segment_path.name, start_num)
|
||||
|
||||
try:
|
||||
_stdout, stderr = ff.extract_scene_frames(
|
||||
segment_path,
|
||||
self.frames_dir,
|
||||
scene_threshold=SCENE_THRESHOLD,
|
||||
max_interval=MAX_FRAME_INTERVAL,
|
||||
start_number=start_num,
|
||||
)
|
||||
if stderr:
|
||||
for line in stderr.splitlines()[:10]:
|
||||
log.debug("[frame_extract:stderr] %s", line)
|
||||
self._parse_frame_timestamps(stderr, start_num)
|
||||
new_frames = list(self.frames_dir.glob("F*.jpg"))
|
||||
log.info("Frame extraction done: %d new frames", len(new_frames) - len(existing))
|
||||
except Exception as e:
|
||||
log.error("Frame extraction failed for %s: %s", segment_path.name, e)
|
||||
|
||||
def _parse_frame_timestamps(self, stderr_output, start_num):
|
||||
index_path = self.frames_dir / "index.json"
|
||||
if index_path.exists():
|
||||
with open(index_path) as f:
|
||||
index = json.load(f)
|
||||
else:
|
||||
index = []
|
||||
|
||||
frame_num = start_num
|
||||
for line in stderr_output.splitlines():
|
||||
if "showinfo" not in line:
|
||||
continue
|
||||
pts_match = re.search(r"pts_time:\s*([\d.]+)", line)
|
||||
if pts_match:
|
||||
pts_time = float(pts_match.group(1))
|
||||
frame_id = f"F{frame_num:04d}"
|
||||
frame_path = self.frames_dir / f"{frame_id}.jpg"
|
||||
if frame_path.exists():
|
||||
index.append({
|
||||
"id": frame_id,
|
||||
"timestamp": pts_time,
|
||||
"path": str(frame_path),
|
||||
"sent_to_agent": False,
|
||||
})
|
||||
log.info("Indexed frame %s at pts=%.2f", frame_id, pts_time)
|
||||
frame_num += 1
|
||||
|
||||
with open(index_path, "w") as f:
|
||||
json.dump(index, f, indent=2)
|
||||
|
||||
def start_audio_extractor(self):
|
||||
"""Will be implemented in Phase 3."""
|
||||
pass
|
||||
|
||||
def get_ffplay_cmd(self):
|
||||
fifo_path = self.session_dir / "monitor.pipe"
|
||||
return [
|
||||
"ffplay",
|
||||
"-hwaccel", "cuda",
|
||||
"-fflags", "nobuffer",
|
||||
"-flags", "low_delay",
|
||||
"-framedrop",
|
||||
"-i", str(fifo_path),
|
||||
], fifo_path
|
||||
|
||||
Reference in New Issue
Block a user