some changes

This commit is contained in:
2026-04-01 16:26:25 -03:00
parent bdc5705022
commit 68802db15c
10 changed files with 500 additions and 567 deletions

View File

@@ -1,17 +1,13 @@
"""
Thin wrapper around ffmpeg-python for building and running ffmpeg pipelines.
All ffmpeg command construction goes through this module so manager.py
and other consumers never build raw CLI arg lists.
All ffmpeg command construction goes through this module.
Uses ffmpeg-python's own run/run_async for subprocess management.
"""
import logging
import os
import signal
import subprocess
from pathlib import Path
import ffmpeg
@@ -20,84 +16,37 @@ log = logging.getLogger(__name__)
GLOBAL_ARGS = ("-hide_banner", "-loglevel", "warning")
def receive_to_pipe(stream_url, segment_dir=None, segment_duration=60):
"""Receive mpegts stream and pipe to stdout for mpv.
def receive_and_record(stream_url, output_path):
"""Receive mpegts stream and write to a single growing file.
If segment_dir is provided, also saves segments to disk.
Uses pipe (not fifo) so OS kernel buffers prevent blocking.
mpv reads this file for DVR-style playback.
ffmpeg scene detection runs on this file for frame extraction.
Audio is preserved in the recording (muxed mpegts).
"""
stream = ffmpeg.input(stream_url, fflags="nobuffer", flags="low_delay")
out_pipe = ffmpeg.output(stream, "pipe:", c="copy", f="mpegts")
if segment_dir:
out_segments = ffmpeg.output(
stream,
str(segment_dir / "segment_%04d.ts"),
c="copy",
f="segment",
segment_time=segment_duration,
reset_timestamps=1,
)
return ffmpeg.merge_outputs(out_pipe, out_segments).global_args(*GLOBAL_ARGS)
return out_pipe.global_args(*GLOBAL_ARGS)
def receive_and_segment(stream_url, segment_dir, segment_duration=60):
"""Receive mpegts stream and save as segmented .ts files."""
stream = ffmpeg.input(stream_url, fflags="nobuffer", flags="low_delay")
return (
ffmpeg.output(
stream,
str(segment_dir / "segment_%04d.ts"),
c="copy",
f="segment",
segment_time=segment_duration,
reset_timestamps=1,
)
ffmpeg.output(stream, str(output_path), c="copy", f="mpegts")
.global_args(*GLOBAL_ARGS)
)
def receive_and_segment_with_monitor(stream_url, segment_dir, fifo_path, segment_duration=60):
"""Receive stream, save segments AND tee to a named pipe for monitoring."""
if not fifo_path.exists():
os.mkfifo(str(fifo_path))
stream = ffmpeg.input(stream_url, fflags="nobuffer", flags="low_delay")
out_segments = ffmpeg.output(
stream,
str(segment_dir / "segment_%04d.ts"),
c="copy",
f="segment",
segment_time=segment_duration,
reset_timestamps=1,
)
out_monitor = ffmpeg.output(
stream,
str(fifo_path),
c="copy",
f="mpegts",
)
return ffmpeg.merge_outputs(out_segments, out_monitor).global_args(*GLOBAL_ARGS)
def extract_scene_frames(input_path, output_dir, scene_threshold=0.3,
max_interval=30, start_number=1):
max_interval=30, start_number=1, start_time=0.0):
"""Extract frames from a file on scene change.
Uses ffmpeg select filter with scene detection and a max-interval fallback.
Returns (stdout bytes, stderr bytes) for timestamp parsing.
start_time: skip to this position before processing (avoids re-scanning).
Returns (stdout, stderr) as decoded strings for timestamp parsing.
"""
select_expr = (
f"gt(scene,{scene_threshold})"
f"+gte(t-prev_selected_t,{max_interval})"
)
stream = ffmpeg.input(str(input_path))
input_opts = {}
if start_time > 0:
input_opts["ss"] = str(start_time)
stream = ffmpeg.input(str(input_path), **input_opts)
stream = stream.filter("select", select_expr).filter("showinfo")
output = (
@@ -116,23 +65,6 @@ def extract_scene_frames(input_path, output_dir, scene_threshold=0.3,
return stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
def extract_audio_pcm(input_path):
"""Extract audio as 16kHz mono PCM wav, returning an output node for piping."""
stream = ffmpeg.input(str(input_path))
return (
ffmpeg.output(
stream.audio,
"pipe:",
vn=None,
acodec="pcm_s16le",
ar=16000,
ac=1,
f="wav",
)
.global_args(*GLOBAL_ARGS)
)
def run_async(output_node, pipe_stdout=False, pipe_stderr=False):
"""Start an ffmpeg pipeline asynchronously via ffmpeg-python's run_async."""
log.info("run_async: %s", " ".join(output_node.compile()))

View File

@@ -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