init commit
This commit is contained in:
0
cht/stream/__init__.py
Normal file
0
cht/stream/__init__.py
Normal file
148
cht/stream/ffmpeg.py
Normal file
148
cht/stream/ffmpeg.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import ffmpeg
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def receive_and_segment(stream_url, segment_dir, segment_duration=60):
|
||||
"""Receive mpegts stream and save as segmented .ts files.
|
||||
|
||||
Returns an ffmpeg-python output node (not yet running).
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Returns an ffmpeg-python merged output node.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def extract_scene_frames(input_path, output_dir, scene_threshold=0.3,
|
||||
max_interval=30, start_number=1):
|
||||
"""Extract frames from a file on scene change.
|
||||
|
||||
Uses ffmpeg select filter with scene detection and a max-interval fallback.
|
||||
Returns (process_result, stderr) for timestamp parsing.
|
||||
"""
|
||||
select_expr = (
|
||||
f"gt(scene\\,{scene_threshold})"
|
||||
f"+gte(t-prev_selected_t\\,{max_interval})"
|
||||
)
|
||||
stream = ffmpeg.input(str(input_path))
|
||||
stream = stream.filter("select", select_expr).filter("showinfo")
|
||||
|
||||
output = ffmpeg.output(
|
||||
stream,
|
||||
str(output_dir / "F%04d.jpg"),
|
||||
vsync="vfr",
|
||||
**{"q:v": "2"},
|
||||
start_number=start_number,
|
||||
)
|
||||
|
||||
return run_sync(output, timeout=120)
|
||||
|
||||
|
||||
def extract_audio_pcm(input_path):
|
||||
"""Extract audio as 16kHz mono PCM wav, returning an output node for piping.
|
||||
|
||||
Use run_async with pipe_stdout=True to stream PCM data.
|
||||
"""
|
||||
stream = ffmpeg.input(str(input_path))
|
||||
return ffmpeg.output(
|
||||
stream.audio,
|
||||
"pipe:",
|
||||
vn=None,
|
||||
acodec="pcm_s16le",
|
||||
ar=16000,
|
||||
ac=1,
|
||||
f="wav",
|
||||
)
|
||||
|
||||
|
||||
def run_async(output_node, pipe_stdout=False, pipe_stderr=False):
|
||||
"""Start an ffmpeg pipeline asynchronously. Returns subprocess.Popen."""
|
||||
cmd = compile_cmd(output_node)
|
||||
log.info("run_async: %s", " ".join(str(c) for c in cmd))
|
||||
return subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE if pipe_stdout else subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE if pipe_stderr else subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def run_sync(output_node, timeout=None):
|
||||
"""Run an ffmpeg pipeline synchronously. Returns (stdout, stderr) as strings."""
|
||||
cmd = compile_cmd(output_node)
|
||||
log.info("run_sync: %s", " ".join(str(c) for c in cmd))
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return result.stdout, result.stderr
|
||||
|
||||
|
||||
def compile_cmd(output_node):
|
||||
"""Compile an ffmpeg-python node to a command list, adding global flags."""
|
||||
cmd = output_node.compile()
|
||||
# Insert global flags after 'ffmpeg'
|
||||
idx = 1
|
||||
for flag in ["-hide_banner", "-loglevel", "warning"]:
|
||||
cmd.insert(idx, flag)
|
||||
idx += 1
|
||||
return cmd
|
||||
|
||||
|
||||
def stop_proc(proc, timeout=5):
|
||||
"""Gracefully stop an ffmpeg subprocess."""
|
||||
if proc and proc.poll() is None:
|
||||
proc.send_signal(signal.SIGINT)
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
194
cht/stream/manager.py
Normal file
194
cht/stream/manager.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
StreamManager: orchestrates ffmpeg pipelines for receiving, recording,
|
||||
frame extraction, and audio 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.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
|
||||
from cht.config import (
|
||||
STREAM_HOST,
|
||||
STREAM_PORT,
|
||||
SEGMENT_DURATION,
|
||||
SCENE_THRESHOLD,
|
||||
MAX_FRAME_INTERVAL,
|
||||
SESSIONS_DIR,
|
||||
)
|
||||
from cht.stream import ffmpeg as ff
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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.agent_dir = self.session_dir / "agent"
|
||||
|
||||
self._procs = {}
|
||||
self._threads = {}
|
||||
self._stop_flags = set()
|
||||
log.info("StreamManager created: session=%s dir=%s", session_id, self.session_dir)
|
||||
|
||||
def setup_dirs(self):
|
||||
for d in (self.stream_dir, self.frames_dir, self.transcript_dir, self.agent_dir):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
log.info("Session directories created")
|
||||
|
||||
@property
|
||||
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()
|
||||
|
||||
def stop_all(self):
|
||||
log.info("Stopping all processes...")
|
||||
self._stop_flags.add("stop")
|
||||
for name, proc in self._procs.items():
|
||||
log.info("Stopping %s (pid=%s)", name, proc.pid if proc else "?")
|
||||
ff.stop_proc(proc)
|
||||
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_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:
|
||||
text = line.decode("utf-8", errors="replace").rstrip()
|
||||
if text:
|
||||
log.info("[%s:stderr] %s", name, text)
|
||||
except Exception as e:
|
||||
log.warning("[%s:stderr] read error: %s", name, e)
|
||||
retcode = proc.poll()
|
||||
log.info("[%s] process exited: code=%s", name, retcode)
|
||||
|
||||
t = Thread(target=_read, daemon=True, name=f"{name}_stderr")
|
||||
t.start()
|
||||
|
||||
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