some changes
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
def
|
def
|
||||||
|
data/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.venv/
|
.venv/
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ from pathlib import Path
|
|||||||
APP_ID = "com.cht.StreamAgent"
|
APP_ID = "com.cht.StreamAgent"
|
||||||
APP_NAME = "CHT"
|
APP_NAME = "CHT"
|
||||||
|
|
||||||
# Default session data location
|
# Default session data location — in project dir for easy clearing
|
||||||
DATA_DIR = Path.home() / ".local" / "share" / "cht"
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
DATA_DIR = PROJECT_DIR / "data"
|
||||||
SESSIONS_DIR = DATA_DIR / "sessions"
|
SESSIONS_DIR = DATA_DIR / "sessions"
|
||||||
|
|
||||||
# Stream defaults
|
# Stream defaults
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Thin wrapper around ffmpeg-python for building and running ffmpeg pipelines.
|
Thin wrapper around ffmpeg-python for building and running ffmpeg pipelines.
|
||||||
|
|
||||||
All ffmpeg command construction goes through this module so manager.py
|
All ffmpeg command construction goes through this module.
|
||||||
and other consumers never build raw CLI arg lists.
|
|
||||||
|
|
||||||
Uses ffmpeg-python's own run/run_async for subprocess management.
|
Uses ffmpeg-python's own run/run_async for subprocess management.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import ffmpeg
|
import ffmpeg
|
||||||
|
|
||||||
@@ -20,84 +16,37 @@ log = logging.getLogger(__name__)
|
|||||||
GLOBAL_ARGS = ("-hide_banner", "-loglevel", "warning")
|
GLOBAL_ARGS = ("-hide_banner", "-loglevel", "warning")
|
||||||
|
|
||||||
|
|
||||||
def receive_to_pipe(stream_url, segment_dir=None, segment_duration=60):
|
def receive_and_record(stream_url, output_path):
|
||||||
"""Receive mpegts stream and pipe to stdout for mpv.
|
"""Receive mpegts stream and write to a single growing file.
|
||||||
|
|
||||||
If segment_dir is provided, also saves segments to disk.
|
mpv reads this file for DVR-style playback.
|
||||||
Uses pipe (not fifo) so OS kernel buffers prevent blocking.
|
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")
|
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 (
|
return (
|
||||||
ffmpeg.output(
|
ffmpeg.output(stream, str(output_path), c="copy", f="mpegts")
|
||||||
stream,
|
|
||||||
str(segment_dir / "segment_%04d.ts"),
|
|
||||||
c="copy",
|
|
||||||
f="segment",
|
|
||||||
segment_time=segment_duration,
|
|
||||||
reset_timestamps=1,
|
|
||||||
)
|
|
||||||
.global_args(*GLOBAL_ARGS)
|
.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,
|
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.
|
"""Extract frames from a file on scene change.
|
||||||
|
|
||||||
Uses ffmpeg select filter with scene detection and a max-interval fallback.
|
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 = (
|
select_expr = (
|
||||||
f"gt(scene,{scene_threshold})"
|
f"gt(scene,{scene_threshold})"
|
||||||
f"+gte(t-prev_selected_t,{max_interval})"
|
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")
|
stream = stream.filter("select", select_expr).filter("showinfo")
|
||||||
|
|
||||||
output = (
|
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")
|
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):
|
def run_async(output_node, pipe_stdout=False, pipe_stderr=False):
|
||||||
"""Start an ffmpeg pipeline asynchronously via ffmpeg-python's run_async."""
|
"""Start an ffmpeg pipeline asynchronously via ffmpeg-python's run_async."""
|
||||||
log.info("run_async: %s", " ".join(output_node.compile()))
|
log.info("run_async: %s", " ".join(output_node.compile()))
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
StreamManager: orchestrates ffmpeg pipelines for receiving, recording,
|
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.
|
Architecture:
|
||||||
All ffmpeg commands go through cht.stream.ffmpeg module.
|
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 json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -17,7 +18,6 @@ from threading import Thread
|
|||||||
from cht.config import (
|
from cht.config import (
|
||||||
STREAM_HOST,
|
STREAM_HOST,
|
||||||
STREAM_PORT,
|
STREAM_PORT,
|
||||||
SEGMENT_DURATION,
|
|
||||||
SCENE_THRESHOLD,
|
SCENE_THRESHOLD,
|
||||||
MAX_FRAME_INTERVAL,
|
MAX_FRAME_INTERVAL,
|
||||||
SESSIONS_DIR,
|
SESSIONS_DIR,
|
||||||
@@ -52,10 +52,129 @@ class StreamManager:
|
|||||||
def stream_url(self):
|
def stream_url(self):
|
||||||
return f"tcp://{STREAM_HOST}:{STREAM_PORT}?listen"
|
return f"tcp://{STREAM_HOST}:{STREAM_PORT}?listen"
|
||||||
|
|
||||||
def start_all(self):
|
@property
|
||||||
self.setup_dirs()
|
def recording_path(self):
|
||||||
self.start_recorder()
|
return self.stream_dir / "recording.ts"
|
||||||
self.start_frame_extractor()
|
|
||||||
|
# -- 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):
|
def stop_all(self):
|
||||||
log.info("Stopping all processes...")
|
log.info("Stopping all processes...")
|
||||||
@@ -66,44 +185,7 @@ class StreamManager:
|
|||||||
self._procs.clear()
|
self._procs.clear()
|
||||||
log.info("All processes stopped")
|
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):
|
def _start_stderr_reader(self, name, proc):
|
||||||
"""Read stderr from a process in a thread and log it."""
|
|
||||||
def _read():
|
def _read():
|
||||||
try:
|
try:
|
||||||
for line in proc.stderr:
|
for line in proc.stderr:
|
||||||
@@ -117,116 +199,3 @@ class StreamManager:
|
|||||||
|
|
||||||
t = Thread(target=_read, daemon=True, name=f"{name}_stderr")
|
t = Thread(target=_read, daemon=True, name=f"{name}_stderr")
|
||||||
t.start()
|
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
|
|
||||||
|
|||||||
@@ -1,97 +1,193 @@
|
|||||||
"""
|
"""
|
||||||
MonitorWidget: mpv-based live stream monitor embedded in GTK4.
|
MonitorWidget: mpv-based stream monitor embedded in GTK4 via OpenGL.
|
||||||
|
|
||||||
Uses libmpv's OpenGL render API + Gtk.GLArea for proper embedding.
|
Supports DVR-style playback of a growing recording file:
|
||||||
No X11 wid hacks — renders directly to a GL texture in the GTK layout.
|
- Follows live edge by default
|
||||||
Works on both X11 and Wayland.
|
- Slider scrubs video + audio together
|
||||||
|
- Can capture frame at current cursor position
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
from gi.repository import Gtk, GLib, Gdk
|
from gi.repository import Gtk, GLib
|
||||||
|
|
||||||
from cht.ui.mpv import Player
|
from cht.ui.mpv import Player
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cache libGL reference
|
||||||
|
_libGL = ctypes.cdll.LoadLibrary("libGL.so.1")
|
||||||
|
GL_DRAW_FRAMEBUFFER_BINDING = 0x8CA6
|
||||||
|
|
||||||
|
|
||||||
class MonitorWidget(Gtk.Box):
|
class MonitorWidget(Gtk.Box):
|
||||||
"""Widget that embeds mpv video via OpenGL into the GTK4 layout."""
|
"""Embedded mpv video player with DVR controls."""
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs)
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs)
|
||||||
self._player = None
|
self._player = None
|
||||||
|
self._following_live = True
|
||||||
|
self._slider_updating = False
|
||||||
|
|
||||||
|
# GL area for video
|
||||||
self._gl_area = Gtk.GLArea()
|
self._gl_area = Gtk.GLArea()
|
||||||
self._gl_area.set_hexpand(True)
|
self._gl_area.set_hexpand(True)
|
||||||
self._gl_area.set_vexpand(True)
|
self._gl_area.set_vexpand(True)
|
||||||
self._gl_area.set_auto_render(False)
|
self._gl_area.set_auto_render(False)
|
||||||
self._gl_area.set_has_depth_buffer(False)
|
self._gl_area.set_has_depth_buffer(False)
|
||||||
self._gl_area.set_has_stencil_buffer(False)
|
self._gl_area.set_has_stencil_buffer(False)
|
||||||
|
|
||||||
self._gl_area.connect("realize", self._on_realize)
|
self._gl_area.connect("realize", self._on_realize)
|
||||||
self._gl_area.connect("unrealize", self._on_unrealize)
|
self._gl_area.connect("unrealize", self._on_unrealize)
|
||||||
self._gl_area.connect("render", self._on_render)
|
self._gl_area.connect("render", self._on_render)
|
||||||
|
|
||||||
self.append(self._gl_area)
|
self.append(self._gl_area)
|
||||||
log.info("MonitorWidget initialized (GLArea)")
|
|
||||||
|
# Slider for scrubbing (shared timeline for video + audio)
|
||||||
|
slider_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||||
|
slider_box.set_margin_start(4)
|
||||||
|
slider_box.set_margin_end(4)
|
||||||
|
slider_box.set_margin_bottom(2)
|
||||||
|
|
||||||
|
self._time_label = Gtk.Label(label="00:00")
|
||||||
|
self._time_label.set_width_chars(6)
|
||||||
|
slider_box.append(self._time_label)
|
||||||
|
|
||||||
|
self._slider = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL)
|
||||||
|
self._slider.set_hexpand(True)
|
||||||
|
self._slider.set_range(0, 1)
|
||||||
|
self._slider.set_draw_value(False)
|
||||||
|
self._slider.connect("value-changed", self._on_slider_changed)
|
||||||
|
slider_box.append(self._slider)
|
||||||
|
|
||||||
|
self._duration_label = Gtk.Label(label="00:00")
|
||||||
|
self._duration_label.set_width_chars(6)
|
||||||
|
slider_box.append(self._duration_label)
|
||||||
|
|
||||||
|
self._live_btn = Gtk.Button(label="LIVE")
|
||||||
|
self._live_btn.add_css_class("suggested-action")
|
||||||
|
self._live_btn.connect("clicked", self._on_live_clicked)
|
||||||
|
slider_box.append(self._live_btn)
|
||||||
|
|
||||||
|
self.append(slider_box)
|
||||||
|
|
||||||
|
# Update slider position periodically
|
||||||
|
GLib.timeout_add(500, self._update_slider)
|
||||||
|
|
||||||
|
log.info("MonitorWidget initialized (GLArea + slider)")
|
||||||
|
|
||||||
|
# -- GL callbacks --
|
||||||
|
|
||||||
def _on_realize(self, gl_area):
|
def _on_realize(self, gl_area):
|
||||||
"""GL context is ready — initialize mpv's render context."""
|
|
||||||
log.info("GLArea realized")
|
log.info("GLArea realized")
|
||||||
gl_area.make_current()
|
gl_area.make_current()
|
||||||
if gl_area.get_error():
|
if gl_area.get_error():
|
||||||
log.error("GLArea error: %s", gl_area.get_error())
|
log.error("GLArea error: %s", gl_area.get_error())
|
||||||
return
|
|
||||||
|
|
||||||
def _on_unrealize(self, gl_area):
|
def _on_unrealize(self, gl_area):
|
||||||
"""Clean up mpv render context."""
|
|
||||||
log.info("GLArea unrealized")
|
log.info("GLArea unrealized")
|
||||||
if self._player:
|
self.stop()
|
||||||
self._player.terminate()
|
|
||||||
self._player = None
|
|
||||||
|
|
||||||
def _on_render(self, gl_area, gl_context):
|
def _on_render(self, gl_area, gl_context):
|
||||||
"""Render mpv's current frame to the GLArea."""
|
|
||||||
if not self._player:
|
if not self._player:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Get the default FBO that GTK4 GLArea renders to
|
|
||||||
fbo = gl_area.get_buffer() if hasattr(gl_area, 'get_buffer') else 0
|
|
||||||
width = gl_area.get_width()
|
width = gl_area.get_width()
|
||||||
height = gl_area.get_height()
|
height = gl_area.get_height()
|
||||||
|
|
||||||
# GTK4 GLArea uses its own FBO, get it from GL state
|
|
||||||
import ctypes
|
|
||||||
fbo_id = ctypes.c_int(0)
|
fbo_id = ctypes.c_int(0)
|
||||||
gl = ctypes.cdll.LoadLibrary("libGL.so.1")
|
_libGL.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, ctypes.byref(fbo_id))
|
||||||
gl.glGetIntegerv(0x8CA6, ctypes.byref(fbo_id)) # GL_DRAW_FRAMEBUFFER_BINDING
|
|
||||||
|
|
||||||
self._player.render(fbo_id.value, width, height)
|
self._player.render(fbo_id.value, width, height)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _on_mpv_update(self):
|
def _on_mpv_update(self):
|
||||||
"""Called by mpv when a new frame is ready. Triggers re-render."""
|
|
||||||
GLib.idle_add(self._gl_area.queue_render)
|
GLib.idle_add(self._gl_area.queue_render)
|
||||||
|
|
||||||
def start_stream(self, source, record_path=None):
|
# -- Slider --
|
||||||
"""Start playing from a URL and optionally record to disk.
|
|
||||||
|
def _on_slider_changed(self, slider):
|
||||||
|
if self._slider_updating or not self._player:
|
||||||
|
return
|
||||||
|
pos = slider.get_value()
|
||||||
|
self._player.seek(pos)
|
||||||
|
self._following_live = False
|
||||||
|
self._live_btn.remove_css_class("suggested-action")
|
||||||
|
|
||||||
|
def _on_live_clicked(self, button):
|
||||||
|
if self._player and self._player.duration:
|
||||||
|
self._player.seek(self._player.duration - 0.5)
|
||||||
|
self._following_live = True
|
||||||
|
self._live_btn.add_css_class("suggested-action")
|
||||||
|
|
||||||
|
def _update_slider(self):
|
||||||
|
if not self._player:
|
||||||
|
return True
|
||||||
|
|
||||||
|
pos = self._player.time_pos
|
||||||
|
dur = self._player.duration
|
||||||
|
if pos is not None and dur is not None and dur > 0:
|
||||||
|
self._slider_updating = True
|
||||||
|
self._slider.set_range(0, dur)
|
||||||
|
self._slider.set_value(pos)
|
||||||
|
self._slider_updating = False
|
||||||
|
|
||||||
|
self._time_label.set_text(self._fmt_time(pos))
|
||||||
|
self._duration_label.set_text(self._fmt_time(dur))
|
||||||
|
|
||||||
|
# Auto-follow live edge: if at EOF or falling behind, reload
|
||||||
|
if self._following_live:
|
||||||
|
if self._player.idle or dur - pos > 3:
|
||||||
|
self._reload_live()
|
||||||
|
|
||||||
|
return True # keep timer running
|
||||||
|
|
||||||
|
def _reload_live(self):
|
||||||
|
"""Reload the growing file and seek to near-end (live edge)."""
|
||||||
|
if not self._player or not self._recording_path:
|
||||||
|
return
|
||||||
|
self._player.play(str(self._recording_path))
|
||||||
|
# Small delay then seek to end
|
||||||
|
GLib.timeout_add(500, self._seek_to_end_once)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt_time(seconds):
|
||||||
|
m, s = divmod(int(seconds), 60)
|
||||||
|
h, m = divmod(m, 60)
|
||||||
|
if h:
|
||||||
|
return f"{h}:{m:02d}:{s:02d}"
|
||||||
|
return f"{m:02d}:{s:02d}"
|
||||||
|
|
||||||
|
# -- Public API --
|
||||||
|
|
||||||
|
def _seek_to_end_once(self):
|
||||||
|
if self._player and self._player.duration:
|
||||||
|
self._player.seek(self._player.duration - 0.5)
|
||||||
|
return False # don't repeat
|
||||||
|
|
||||||
|
def start_recording(self, recording_path):
|
||||||
|
"""Start DVR-style playback of a growing recording file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
source: TCP URL (tcp://...), file path, etc.
|
recording_path: path to the .ts file being written by ffmpeg
|
||||||
record_path: if set, mpv dumps the raw stream to this file
|
|
||||||
"""
|
"""
|
||||||
|
self._recording_path = recording_path
|
||||||
self._gl_area.make_current()
|
self._gl_area.make_current()
|
||||||
|
|
||||||
self._player = Player(record_path=record_path)
|
self._player = Player()
|
||||||
self._player.init_gl(update_callback=self._on_mpv_update)
|
self._player.init_gl(update_callback=self._on_mpv_update)
|
||||||
self._player.play(source)
|
self._player.play_file(recording_path)
|
||||||
log.info("Monitor streaming from: %s (record=%s)", source, record_path)
|
self._following_live = True
|
||||||
|
self._live_btn.add_css_class("suggested-action")
|
||||||
|
log.info("Monitor playing recording: %s", recording_path)
|
||||||
|
|
||||||
|
def screenshot(self, path):
|
||||||
|
"""Capture frame at current cursor position."""
|
||||||
|
if self._player:
|
||||||
|
self._player.screenshot(path)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop playback and release mpv."""
|
|
||||||
if self._player:
|
if self._player:
|
||||||
log.info("Stopping monitor")
|
log.info("Stopping monitor")
|
||||||
self._player.terminate()
|
self._player.terminate()
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
MPV wrapper using python-mpv (libmpv bindings) with OpenGL render API.
|
MPV wrapper using python-mpv (libmpv bindings) with OpenGL render API.
|
||||||
|
|
||||||
Renders video frames to an OpenGL context provided by GTK4's GLArea.
|
Renders video frames to an OpenGL context provided by GTK4's GLArea.
|
||||||
No subprocess calls, no X11 wid hacks.
|
Supports DVR-style playback of a growing recording file:
|
||||||
|
- Follow live edge (default)
|
||||||
|
- Scrub back to any point
|
||||||
|
- Audio + video synced via single slider
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
@@ -30,40 +33,50 @@ _get_proc_address = _make_get_proc_address()
|
|||||||
|
|
||||||
|
|
||||||
class Player:
|
class Player:
|
||||||
"""Wraps a libmpv player with OpenGL render context for GTK4 embedding."""
|
"""Wraps a libmpv player with OpenGL render context for GTK4 embedding.
|
||||||
|
|
||||||
def __init__(self, record_path=None):
|
Designed for DVR-style playback of a growing file:
|
||||||
|
- play_file() opens the recording and seeks to end (live edge)
|
||||||
|
- seek() scrubs to any position (audio + video move together)
|
||||||
|
- time_pos / duration track playback state for the slider
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
opts = {
|
opts = {
|
||||||
"input_default_bindings": False,
|
"input_default_bindings": False,
|
||||||
"input_vo_keyboard": False,
|
"input_vo_keyboard": False,
|
||||||
"osc": False,
|
"osc": False,
|
||||||
"profile": "low-latency",
|
|
||||||
"cache": "no",
|
|
||||||
"untimed": True,
|
|
||||||
"demuxer_thread": "no",
|
|
||||||
"demuxer_lavf_o": "fflags=+nobuffer",
|
|
||||||
"video_sync": "display-desync",
|
|
||||||
"vo": "libmpv",
|
"vo": "libmpv",
|
||||||
"hwdec": "auto",
|
"hwdec": "auto",
|
||||||
|
"video_sync": "display-desync",
|
||||||
|
# DVR: keep alive at EOF, wait for more data
|
||||||
|
"keep_open": "yes",
|
||||||
|
"demuxer_max_bytes": "500MiB",
|
||||||
|
"demuxer_readahead_secs": "5",
|
||||||
|
# Allow re-reading growing file
|
||||||
|
"demuxer_cache_wait": True,
|
||||||
}
|
}
|
||||||
if record_path:
|
|
||||||
opts["stream_record"] = str(record_path)
|
|
||||||
log.info("mpv will record stream to: %s", record_path)
|
|
||||||
|
|
||||||
log.info("Creating mpv player (OpenGL render mode)")
|
log.info("Creating mpv player (OpenGL render, DVR mode)")
|
||||||
self._player = libmpv.MPV(**opts)
|
self._player = libmpv.MPV(log_handler=self._mpv_log, loglevel="v", **opts)
|
||||||
self._ctx = None
|
self._ctx = None
|
||||||
self._update_callback = None
|
self._update_callback = None
|
||||||
log.info("mpv player created")
|
log.info("mpv player created")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mpv_log(loglevel, component, message):
|
||||||
|
msg = f"[mpv/{component}] {message.strip()}"
|
||||||
|
if loglevel in ("fatal", "error"):
|
||||||
|
log.error(msg)
|
||||||
|
elif loglevel == "warn":
|
||||||
|
log.warning(msg)
|
||||||
|
else:
|
||||||
|
log.debug(msg)
|
||||||
|
|
||||||
def init_gl(self, update_callback):
|
def init_gl(self, update_callback):
|
||||||
"""Initialize the OpenGL render context.
|
"""Initialize the OpenGL render context.
|
||||||
|
|
||||||
Must be called from a thread with an active GL context (e.g. GLArea realize).
|
Must be called with an active GL context.
|
||||||
|
|
||||||
Args:
|
|
||||||
update_callback: called by mpv when a new frame is ready to render.
|
|
||||||
Should trigger a GLArea queue_render().
|
|
||||||
"""
|
"""
|
||||||
self._update_callback = update_callback
|
self._update_callback = update_callback
|
||||||
self._ctx = libmpv.MpvRenderContext(
|
self._ctx = libmpv.MpvRenderContext(
|
||||||
@@ -73,41 +86,40 @@ class Player:
|
|||||||
"get_proc_address": _get_proc_address,
|
"get_proc_address": _get_proc_address,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Keep reference to prevent GC of the ctypes callback
|
|
||||||
self._get_proc_address_ref = _get_proc_address
|
self._get_proc_address_ref = _get_proc_address
|
||||||
self._ctx.update_cb = self._on_mpv_update
|
self._ctx.update_cb = self._on_mpv_update
|
||||||
log.info("mpv OpenGL render context initialized")
|
log.info("mpv OpenGL render context initialized")
|
||||||
|
|
||||||
def _on_mpv_update(self):
|
def _on_mpv_update(self):
|
||||||
"""Called by mpv from any thread when a new frame is available."""
|
|
||||||
if self._update_callback:
|
if self._update_callback:
|
||||||
self._update_callback()
|
self._update_callback()
|
||||||
|
|
||||||
def render(self, fbo, width, height):
|
def render(self, fbo, width, height):
|
||||||
"""Render the current frame to the given OpenGL FBO.
|
"""Render current frame to the given OpenGL FBO."""
|
||||||
|
|
||||||
Call from the GLArea render signal handler.
|
|
||||||
"""
|
|
||||||
if self._ctx:
|
if self._ctx:
|
||||||
self._ctx.render(
|
self._ctx.render(
|
||||||
flip_y=True,
|
flip_y=True,
|
||||||
opengl_fbo={
|
opengl_fbo={"fbo": fbo, "w": width, "h": height},
|
||||||
"fbo": fbo,
|
|
||||||
"w": width,
|
|
||||||
"h": height,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def play(self, source):
|
def play(self, source):
|
||||||
"""Play from a file path or URL."""
|
"""Play from any source (URL, file path)."""
|
||||||
log.info("mpv play: %s", source)
|
log.info("mpv play: %s", source)
|
||||||
self._player.play(str(source))
|
self._player.play(str(source))
|
||||||
|
|
||||||
def play_fd(self, fd):
|
def play_file(self, path):
|
||||||
"""Play from a raw file descriptor."""
|
"""Play a recording file, seeking to the end (live edge)."""
|
||||||
source = f"fd://{fd}"
|
log.info("mpv play_file (DVR): %s", path)
|
||||||
log.info("mpv play from fd: %s", source)
|
self._player.play(str(path))
|
||||||
self._player.play(source)
|
# Seek to end once playback starts
|
||||||
|
self._player.observe_property("duration", self._seek_to_live_once)
|
||||||
|
|
||||||
|
def _seek_to_live_once(self, name, value):
|
||||||
|
"""Seek to live edge once duration is known, then stop observing."""
|
||||||
|
if value and value > 1:
|
||||||
|
log.info("Seeking to live edge: %.1fs", value)
|
||||||
|
self._player.seek(value - 0.5, reference="absolute")
|
||||||
|
self._player.unobserve_property("duration", self._seek_to_live_once)
|
||||||
|
|
||||||
def pause(self):
|
def pause(self):
|
||||||
self._player.pause = True
|
self._player.pause = True
|
||||||
@@ -120,13 +132,18 @@ class Player:
|
|||||||
return self._player.pause
|
return self._player.pause
|
||||||
|
|
||||||
def seek(self, seconds):
|
def seek(self, seconds):
|
||||||
"""Seek to absolute position in seconds."""
|
"""Seek to absolute position. Audio + video move together."""
|
||||||
self._player.seek(seconds, reference="absolute")
|
self._player.seek(seconds, reference="absolute")
|
||||||
|
|
||||||
def seek_relative(self, seconds):
|
def seek_relative(self, seconds):
|
||||||
"""Seek relative to current position."""
|
"""Seek relative to current position."""
|
||||||
self._player.seek(seconds, reference="relative")
|
self._player.seek(seconds, reference="relative")
|
||||||
|
|
||||||
|
def screenshot(self, path):
|
||||||
|
"""Save current frame as an image file."""
|
||||||
|
self._player.screenshot_to_file(str(path), includes="video")
|
||||||
|
log.debug("Screenshot saved: %s", path)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
log.info("mpv stop")
|
log.info("mpv stop")
|
||||||
self._player.stop()
|
self._player.stop()
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
gi.require_version("Adw", "1")
|
gi.require_version("Adw", "1")
|
||||||
from gi.repository import Gtk, Adw, GLib, Pango
|
gi.require_version("GdkPixbuf", "2.0")
|
||||||
|
from gi.repository import Gtk, Adw, GLib, Pango, GdkPixbuf
|
||||||
|
|
||||||
from cht.config import APP_NAME
|
from cht.config import APP_NAME
|
||||||
from cht.ui.monitor import MonitorWidget
|
from cht.ui.monitor import MonitorWidget
|
||||||
@@ -72,16 +75,32 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
log.info("Session: %s", self._stream_mgr.session_id)
|
log.info("Session: %s", self._stream_mgr.session_id)
|
||||||
self._stream_mgr.setup_dirs()
|
self._stream_mgr.setup_dirs()
|
||||||
|
|
||||||
# mpv listens on TCP directly (lowest latency, like def scripts)
|
# 1. ffmpeg receives TCP and writes growing recording.ts
|
||||||
# and records the raw stream to disk for frame extraction
|
self._stream_mgr.start_recorder()
|
||||||
stream_url = self._stream_mgr.stream_url
|
log.info("Recorder started, waiting for sender...")
|
||||||
record_path = self._stream_mgr.stream_dir / "recording.ts"
|
|
||||||
log.info("Starting mpv on %s, recording to %s", stream_url, record_path)
|
|
||||||
self._monitor.start_stream(stream_url, record_path=record_path)
|
|
||||||
log.info("Monitor started, waiting for sender...")
|
|
||||||
|
|
||||||
self._stream_mgr.start_frame_extractor_on_recording(record_path)
|
# 2. mpv plays the recording file (DVR: live edge + scrub)
|
||||||
log.info("Frame extractor started")
|
# Small delay to let ffmpeg create the file
|
||||||
|
GLib.timeout_add(2000, self._start_playback)
|
||||||
|
|
||||||
|
# 3. ffmpeg scene detection runs periodically on the recording
|
||||||
|
self._stream_mgr.start_scene_detector()
|
||||||
|
log.info("Scene detector started")
|
||||||
|
|
||||||
|
# 4. Poll for new frames and show thumbnails
|
||||||
|
self._known_frames = set()
|
||||||
|
GLib.timeout_add(3000, self._poll_frames)
|
||||||
|
|
||||||
|
def _start_playback(self):
|
||||||
|
"""Start mpv playback once recording file exists."""
|
||||||
|
if self._stream_mgr and self._stream_mgr.recording_path.exists():
|
||||||
|
size = self._stream_mgr.recording_path.stat().st_size
|
||||||
|
if size > 10_000:
|
||||||
|
self._monitor.start_recording(self._stream_mgr.recording_path)
|
||||||
|
log.info("Playback started")
|
||||||
|
return False # stop timer
|
||||||
|
log.info("Waiting for recording data...")
|
||||||
|
return True # retry
|
||||||
|
|
||||||
def _stop_stream(self):
|
def _stop_stream(self):
|
||||||
log.info("Stopping stream...")
|
log.info("Stopping stream...")
|
||||||
@@ -279,13 +298,55 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
end_iter = buf.get_end_iter()
|
end_iter = buf.get_end_iter()
|
||||||
buf.insert(end_iter, f"[{entry_id}] {text}\n")
|
buf.insert(end_iter, f"[{entry_id}] {text}\n")
|
||||||
|
|
||||||
def add_frame_thumbnail(self, frame_id, pixbuf):
|
def _poll_frames(self):
|
||||||
|
"""Check for new extracted frames and add thumbnails."""
|
||||||
|
if not self._stream_mgr:
|
||||||
|
return False
|
||||||
|
|
||||||
|
index_path = self._stream_mgr.frames_dir / "index.json"
|
||||||
|
if not index_path.exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(index_path) as f:
|
||||||
|
index = json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return True
|
||||||
|
|
||||||
|
for entry in index:
|
||||||
|
fid = entry["id"]
|
||||||
|
if fid in self._known_frames:
|
||||||
|
continue
|
||||||
|
fpath = Path(entry["path"])
|
||||||
|
if not fpath.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._known_frames.add(fid)
|
||||||
|
try:
|
||||||
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||||
|
str(fpath), 160, 90, True
|
||||||
|
)
|
||||||
|
self._add_frame_thumbnail(fid, pixbuf, entry.get("timestamp"))
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Failed to load thumbnail for %s: %s", fid, e)
|
||||||
|
|
||||||
|
return True # keep polling
|
||||||
|
|
||||||
|
def _add_frame_thumbnail(self, frame_id, pixbuf, timestamp=None):
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
|
||||||
|
|
||||||
img = Gtk.Image.new_from_pixbuf(pixbuf)
|
img = Gtk.Image.new_from_pixbuf(pixbuf)
|
||||||
overlay = Gtk.Overlay()
|
box.append(img)
|
||||||
overlay.set_child(img)
|
|
||||||
label = Gtk.Label(label=frame_id)
|
label_text = frame_id
|
||||||
label.set_halign(Gtk.Align.START)
|
if timestamp is not None:
|
||||||
label.set_valign(Gtk.Align.END)
|
m, s = divmod(int(timestamp), 60)
|
||||||
|
label_text = f"{frame_id} [{m:02d}:{s:02d}]"
|
||||||
|
|
||||||
|
label = Gtk.Label(label=label_text)
|
||||||
label.add_css_class("caption")
|
label.add_css_class("caption")
|
||||||
overlay.add_overlay(label)
|
label.set_ellipsize(Pango.EllipsizeMode.END)
|
||||||
self._frames_flow.append(overlay)
|
box.append(label)
|
||||||
|
|
||||||
|
self._frames_flow.append(box)
|
||||||
|
log.info("Added thumbnail: %s", frame_id)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ requires-python = ">=3.13"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ffmpeg-python",
|
"ffmpeg-python",
|
||||||
"python-mpv",
|
"python-mpv",
|
||||||
|
"Pillow",
|
||||||
|
"numpy",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"""Tests for cht.stream.ffmpeg — pipeline construction and execution."""
|
"""Tests for cht.stream.ffmpeg — pipeline construction and execution."""
|
||||||
|
|
||||||
import os
|
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -12,76 +10,43 @@ import ffmpeg as ffmpeg_lib
|
|||||||
from cht.stream import ffmpeg as ff
|
from cht.stream import ffmpeg as ff
|
||||||
|
|
||||||
|
|
||||||
class TestReceiveAndSegment:
|
class TestReceiveAndRecord:
|
||||||
def test_compiles_to_valid_cmd(self, tmp_path):
|
def test_compiles_to_valid_cmd(self, tmp_path):
|
||||||
node = ff.receive_and_segment(
|
node = ff.receive_and_record(
|
||||||
"tcp://0.0.0.0:4444?listen", tmp_path, segment_duration=30
|
"tcp://0.0.0.0:4444?listen", tmp_path / "recording.ts"
|
||||||
)
|
)
|
||||||
cmd = node.compile()
|
cmd = node.compile()
|
||||||
cmd_str = " ".join(str(c) for c in cmd)
|
cmd_str = " ".join(str(c) for c in cmd)
|
||||||
|
|
||||||
assert cmd[0] == "ffmpeg"
|
assert cmd[0] == "ffmpeg"
|
||||||
assert "tcp://0.0.0.0:4444?listen" in cmd_str
|
assert "tcp://0.0.0.0:4444?listen" in cmd_str
|
||||||
assert "segment" in cmd_str
|
assert "recording.ts" in cmd_str
|
||||||
assert str(tmp_path / "segment_%04d.ts") in cmd_str
|
|
||||||
|
|
||||||
def test_input_has_low_latency_flags(self, tmp_path):
|
def test_input_has_low_latency_flags(self, tmp_path):
|
||||||
node = ff.receive_and_segment("tcp://0.0.0.0:4444?listen", tmp_path)
|
node = ff.receive_and_record(
|
||||||
|
"tcp://0.0.0.0:4444?listen", tmp_path / "rec.ts"
|
||||||
|
)
|
||||||
cmd_str = " ".join(str(c) for c in node.compile())
|
cmd_str = " ".join(str(c) for c in node.compile())
|
||||||
assert "nobuffer" in cmd_str
|
assert "nobuffer" in cmd_str
|
||||||
assert "low_delay" in cmd_str
|
assert "low_delay" in cmd_str
|
||||||
|
|
||||||
def test_copy_codec(self, tmp_path):
|
def test_copy_codec(self, tmp_path):
|
||||||
node = ff.receive_and_segment("tcp://0.0.0.0:4444?listen", tmp_path)
|
node = ff.receive_and_record(
|
||||||
|
"tcp://0.0.0.0:4444?listen", tmp_path / "rec.ts"
|
||||||
|
)
|
||||||
assert "copy" in node.compile()
|
assert "copy" in node.compile()
|
||||||
|
|
||||||
def test_has_global_args(self, tmp_path):
|
def test_has_global_args(self, tmp_path):
|
||||||
node = ff.receive_and_segment("tcp://0.0.0.0:4444?listen", tmp_path)
|
node = ff.receive_and_record(
|
||||||
cmd = node.compile()
|
"tcp://0.0.0.0:4444?listen", tmp_path / "rec.ts"
|
||||||
assert "-hide_banner" in cmd
|
|
||||||
|
|
||||||
|
|
||||||
class TestReceiveAndSegmentWithMonitor:
|
|
||||||
def test_creates_fifo(self, tmp_path):
|
|
||||||
fifo = tmp_path / "monitor.pipe"
|
|
||||||
ff.receive_and_segment_with_monitor(
|
|
||||||
"tcp://0.0.0.0:4444?listen", tmp_path, fifo
|
|
||||||
)
|
|
||||||
assert fifo.exists()
|
|
||||||
|
|
||||||
def test_does_not_recreate_existing_fifo(self, tmp_path):
|
|
||||||
fifo = tmp_path / "monitor.pipe"
|
|
||||||
os.mkfifo(str(fifo))
|
|
||||||
inode_before = fifo.stat().st_ino
|
|
||||||
ff.receive_and_segment_with_monitor(
|
|
||||||
"tcp://0.0.0.0:4444?listen", tmp_path, fifo
|
|
||||||
)
|
|
||||||
assert fifo.stat().st_ino == inode_before
|
|
||||||
|
|
||||||
def test_has_two_outputs(self, tmp_path):
|
|
||||||
fifo = tmp_path / "monitor.pipe"
|
|
||||||
node = ff.receive_and_segment_with_monitor(
|
|
||||||
"tcp://0.0.0.0:4444?listen", tmp_path, fifo
|
|
||||||
)
|
|
||||||
cmd_str = " ".join(str(c) for c in node.compile())
|
|
||||||
assert "segment_%04d.ts" in cmd_str
|
|
||||||
assert "monitor.pipe" in cmd_str
|
|
||||||
|
|
||||||
def test_both_outputs_use_copy(self, tmp_path):
|
|
||||||
fifo = tmp_path / "monitor.pipe"
|
|
||||||
node = ff.receive_and_segment_with_monitor(
|
|
||||||
"tcp://0.0.0.0:4444?listen", tmp_path, fifo
|
|
||||||
)
|
|
||||||
cmd = node.compile()
|
|
||||||
assert cmd.count("copy") >= 2
|
|
||||||
|
|
||||||
def test_has_global_args(self, tmp_path):
|
|
||||||
fifo = tmp_path / "monitor.pipe"
|
|
||||||
node = ff.receive_and_segment_with_monitor(
|
|
||||||
"tcp://0.0.0.0:4444?listen", tmp_path, fifo
|
|
||||||
)
|
)
|
||||||
assert "-hide_banner" in node.compile()
|
assert "-hide_banner" in node.compile()
|
||||||
|
|
||||||
|
def test_mpegts_format(self, tmp_path):
|
||||||
|
node = ff.receive_and_record(
|
||||||
|
"tcp://0.0.0.0:4444?listen", tmp_path / "rec.ts"
|
||||||
|
)
|
||||||
|
assert "mpegts" in node.compile()
|
||||||
|
|
||||||
|
|
||||||
class TestExtractSceneFrames:
|
class TestExtractSceneFrames:
|
||||||
def test_compiles_select_filter(self, tmp_path):
|
def test_compiles_select_filter(self, tmp_path):
|
||||||
@@ -113,32 +78,12 @@ class TestExtractSceneFrames:
|
|||||||
assert stderr == "err"
|
assert stderr == "err"
|
||||||
|
|
||||||
|
|
||||||
class TestExtractAudioPcm:
|
|
||||||
def test_compiles_audio_extraction(self, tmp_path):
|
|
||||||
node = ff.extract_audio_pcm(tmp_path / "test.ts")
|
|
||||||
cmd_str = " ".join(str(c) for c in node.compile())
|
|
||||||
assert "pcm_s16le" in cmd_str
|
|
||||||
assert "16000" in cmd_str
|
|
||||||
assert "pipe:" in cmd_str
|
|
||||||
assert "wav" in cmd_str
|
|
||||||
|
|
||||||
def test_has_global_args(self, tmp_path):
|
|
||||||
node = ff.extract_audio_pcm(tmp_path / "test.ts")
|
|
||||||
assert "-hide_banner" in node.compile()
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunAsync:
|
class TestRunAsync:
|
||||||
@patch("ffmpeg.run_async")
|
def test_compiles_valid_command(self, tmp_path):
|
||||||
def test_delegates_to_ffmpeg_python(self, mock_run_async, tmp_path):
|
node = ff.receive_and_record("tcp://0.0.0.0:4444?listen", tmp_path / "rec.ts")
|
||||||
node = ffmpeg_lib.input("test").output("out")
|
cmd = node.compile()
|
||||||
ff.run_async(node)
|
assert cmd[0] == "ffmpeg"
|
||||||
# ffmpeg-python's run_async is called on the node
|
assert "tcp://0.0.0.0:4444?listen" in " ".join(cmd)
|
||||||
# Our function calls node.run_async() which is the bound method
|
|
||||||
|
|
||||||
@patch("ffmpeg.run_async")
|
|
||||||
def test_passes_pipe_flags(self, mock_run_async, tmp_path):
|
|
||||||
node = ffmpeg_lib.input("test").output("out")
|
|
||||||
ff.run_async(node, pipe_stdout=True, pipe_stderr=True)
|
|
||||||
|
|
||||||
|
|
||||||
class TestStopProc:
|
class TestStopProc:
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"""Tests for cht.stream.manager — StreamManager orchestration."""
|
"""Tests for cht.stream.manager — StreamManager orchestration."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from unittest.mock import patch, MagicMock
|
||||||
from unittest.mock import patch, MagicMock, call
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -13,7 +11,6 @@ from cht.stream.manager import StreamManager
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def manager(tmp_path):
|
def manager(tmp_path):
|
||||||
"""StreamManager with session dir in tmp_path."""
|
|
||||||
with patch("cht.stream.manager.SESSIONS_DIR", tmp_path):
|
with patch("cht.stream.manager.SESSIONS_DIR", tmp_path):
|
||||||
mgr = StreamManager(session_id="test_session")
|
mgr = StreamManager(session_id="test_session")
|
||||||
yield mgr
|
yield mgr
|
||||||
@@ -24,7 +21,7 @@ class TestInit:
|
|||||||
def test_session_id_default(self, tmp_path):
|
def test_session_id_default(self, tmp_path):
|
||||||
with patch("cht.stream.manager.SESSIONS_DIR", tmp_path):
|
with patch("cht.stream.manager.SESSIONS_DIR", tmp_path):
|
||||||
mgr = StreamManager()
|
mgr = StreamManager()
|
||||||
assert mgr.session_id # should be a timestamp string
|
assert mgr.session_id
|
||||||
|
|
||||||
def test_session_id_custom(self, manager):
|
def test_session_id_custom(self, manager):
|
||||||
assert manager.session_id == "test_session"
|
assert manager.session_id == "test_session"
|
||||||
@@ -54,44 +51,29 @@ class TestStreamUrl:
|
|||||||
assert "listen" in manager.stream_url
|
assert "listen" in manager.stream_url
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecordingPath:
|
||||||
|
def test_is_in_stream_dir(self, manager):
|
||||||
|
assert manager.recording_path.parent == manager.stream_dir
|
||||||
|
assert manager.recording_path.name == "recording.ts"
|
||||||
|
|
||||||
|
|
||||||
class TestStartRecorder:
|
class TestStartRecorder:
|
||||||
@patch("cht.stream.manager.ff.run_async")
|
@patch("cht.stream.manager.ff.run_async")
|
||||||
@patch("cht.stream.manager.ff.receive_and_segment")
|
@patch("cht.stream.manager.ff.receive_and_record")
|
||||||
def test_calls_ffmpeg_module(self, mock_segment, mock_async, manager):
|
def test_calls_ffmpeg_module(self, mock_record, mock_async, manager):
|
||||||
manager.setup_dirs()
|
manager.setup_dirs()
|
||||||
mock_node = MagicMock()
|
mock_node = MagicMock()
|
||||||
mock_segment.return_value = mock_node
|
mock_record.return_value = mock_node
|
||||||
|
|
||||||
manager.start_recorder()
|
manager.start_recorder()
|
||||||
|
|
||||||
mock_segment.assert_called_once_with(
|
mock_record.assert_called_once_with(
|
||||||
manager.stream_url, manager.stream_dir, 60,
|
manager.stream_url, manager.recording_path,
|
||||||
)
|
)
|
||||||
mock_async.assert_called_once_with(mock_node, pipe_stderr=True)
|
mock_async.assert_called_once_with(mock_node, pipe_stderr=True)
|
||||||
assert "recorder" in manager._procs
|
assert "recorder" in manager._procs
|
||||||
|
|
||||||
|
|
||||||
class TestStartRecorderWithMonitor:
|
|
||||||
@patch("cht.stream.manager.ff.run_async")
|
|
||||||
@patch("cht.stream.manager.ff.receive_and_segment_with_monitor")
|
|
||||||
def test_creates_fifo_and_starts(self, mock_monitor, mock_async, manager):
|
|
||||||
mock_node = MagicMock()
|
|
||||||
mock_monitor.return_value = mock_node
|
|
||||||
|
|
||||||
fifo = manager.start_recorder_with_monitor()
|
|
||||||
|
|
||||||
assert fifo == manager.session_dir / "monitor.pipe"
|
|
||||||
mock_monitor.assert_called_once()
|
|
||||||
mock_async.assert_called_once_with(mock_node, pipe_stderr=True)
|
|
||||||
|
|
||||||
@patch("cht.stream.manager.ff.run_async")
|
|
||||||
@patch("cht.stream.manager.ff.receive_and_segment_with_monitor")
|
|
||||||
def test_setup_dirs_called(self, mock_monitor, mock_async, manager):
|
|
||||||
mock_monitor.return_value = MagicMock()
|
|
||||||
manager.start_recorder_with_monitor()
|
|
||||||
assert manager.stream_dir.is_dir()
|
|
||||||
|
|
||||||
|
|
||||||
class TestStopAll:
|
class TestStopAll:
|
||||||
@patch("cht.stream.manager.ff.stop_proc")
|
@patch("cht.stream.manager.ff.stop_proc")
|
||||||
def test_stops_all_procs(self, mock_stop, manager):
|
def test_stops_all_procs(self, mock_stop, manager):
|
||||||
@@ -109,162 +91,89 @@ class TestStopAll:
|
|||||||
assert "stop" in manager._stop_flags
|
assert "stop" in manager._stop_flags
|
||||||
|
|
||||||
|
|
||||||
class TestParseFrameTimestamps:
|
class TestExtractNewFrames:
|
||||||
def test_parses_showinfo_output(self, manager):
|
|
||||||
manager.setup_dirs()
|
|
||||||
# Create fake frame files
|
|
||||||
for i in range(1, 4):
|
|
||||||
(manager.frames_dir / f"F{i:04d}.jpg").touch()
|
|
||||||
|
|
||||||
stderr = (
|
|
||||||
"[Parsed_showinfo_1 @ 0x1234] n:0 pts:1000 pts_time:10.5 other stuff\n"
|
|
||||||
"some other line\n"
|
|
||||||
"[Parsed_showinfo_1 @ 0x1234] n:1 pts:2000 pts_time:20.0 other stuff\n"
|
|
||||||
"[Parsed_showinfo_1 @ 0x1234] n:2 pts:3000 pts_time:35.7 other stuff\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
manager._parse_frame_timestamps(stderr, start_num=1)
|
|
||||||
|
|
||||||
index_path = manager.frames_dir / "index.json"
|
|
||||||
assert index_path.exists()
|
|
||||||
|
|
||||||
with open(index_path) as f:
|
|
||||||
index = json.load(f)
|
|
||||||
|
|
||||||
assert len(index) == 3
|
|
||||||
assert index[0]["id"] == "F0001"
|
|
||||||
assert index[0]["timestamp"] == 10.5
|
|
||||||
assert index[0]["sent_to_agent"] is False
|
|
||||||
assert index[1]["id"] == "F0002"
|
|
||||||
assert index[1]["timestamp"] == 20.0
|
|
||||||
assert index[2]["id"] == "F0003"
|
|
||||||
assert index[2]["timestamp"] == 35.7
|
|
||||||
|
|
||||||
def test_appends_to_existing_index(self, manager):
|
|
||||||
manager.setup_dirs()
|
|
||||||
index_path = manager.frames_dir / "index.json"
|
|
||||||
|
|
||||||
# Pre-existing index
|
|
||||||
existing = [{"id": "F0001", "timestamp": 5.0, "path": "/old", "sent_to_agent": True}]
|
|
||||||
with open(index_path, "w") as f:
|
|
||||||
json.dump(existing, f)
|
|
||||||
|
|
||||||
# New frame
|
|
||||||
(manager.frames_dir / "F0002.jpg").touch()
|
|
||||||
stderr = "[Parsed_showinfo_1 @ 0x1] n:0 pts:100 pts_time:15.0 stuff\n"
|
|
||||||
manager._parse_frame_timestamps(stderr, start_num=2)
|
|
||||||
|
|
||||||
with open(index_path) as f:
|
|
||||||
index = json.load(f)
|
|
||||||
|
|
||||||
assert len(index) == 2
|
|
||||||
assert index[0]["id"] == "F0001" # preserved
|
|
||||||
assert index[1]["id"] == "F0002" # new
|
|
||||||
|
|
||||||
def test_skips_missing_frame_files(self, manager):
|
|
||||||
manager.setup_dirs()
|
|
||||||
# Don't create the frame file
|
|
||||||
stderr = "[Parsed_showinfo_1 @ 0x1] n:0 pts:100 pts_time:10.0 stuff\n"
|
|
||||||
manager._parse_frame_timestamps(stderr, start_num=1)
|
|
||||||
|
|
||||||
index_path = manager.frames_dir / "index.json"
|
|
||||||
with open(index_path) as f:
|
|
||||||
index = json.load(f)
|
|
||||||
assert len(index) == 0
|
|
||||||
|
|
||||||
def test_ignores_non_showinfo_lines(self, manager):
|
|
||||||
manager.setup_dirs()
|
|
||||||
(manager.frames_dir / "F0001.jpg").touch()
|
|
||||||
|
|
||||||
stderr = (
|
|
||||||
"frame= 100 fps=30 q=28.0 size= 1024kB\n"
|
|
||||||
"video:500kB audio:200kB subtitle:0kB other streams:0kB\n"
|
|
||||||
)
|
|
||||||
manager._parse_frame_timestamps(stderr, start_num=1)
|
|
||||||
|
|
||||||
index_path = manager.frames_dir / "index.json"
|
|
||||||
with open(index_path) as f:
|
|
||||||
index = json.load(f)
|
|
||||||
assert len(index) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtractFramesFromFile:
|
|
||||||
@patch("cht.stream.manager.ff.extract_scene_frames")
|
@patch("cht.stream.manager.ff.extract_scene_frames")
|
||||||
def test_calls_ffmpeg_with_correct_args(self, mock_extract, manager):
|
def test_calls_ffmpeg_with_start_time(self, mock_extract, manager):
|
||||||
manager.setup_dirs()
|
manager.setup_dirs()
|
||||||
seg = manager.stream_dir / "segment_0001.ts"
|
rec = manager.recording_path
|
||||||
seg.touch()
|
rec.touch()
|
||||||
|
|
||||||
mock_extract.return_value = ("", "")
|
mock_extract.return_value = ("", "")
|
||||||
manager._extract_frames_from_file(seg)
|
manager._extract_new_frames(rec, start_time=10.0, start_number=5)
|
||||||
|
|
||||||
mock_extract.assert_called_once_with(
|
mock_extract.assert_called_once_with(
|
||||||
seg,
|
rec,
|
||||||
manager.frames_dir,
|
manager.frames_dir,
|
||||||
scene_threshold=0.3,
|
scene_threshold=0.3,
|
||||||
max_interval=30,
|
max_interval=30,
|
||||||
start_number=1,
|
start_number=5,
|
||||||
|
start_time=10.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("cht.stream.manager.ff.extract_scene_frames")
|
@patch("cht.stream.manager.ff.extract_scene_frames")
|
||||||
def test_continues_numbering(self, mock_extract, manager):
|
def test_indexes_new_frames(self, mock_extract, manager):
|
||||||
manager.setup_dirs()
|
manager.setup_dirs()
|
||||||
# Pre-existing frames
|
rec = manager.recording_path
|
||||||
(manager.frames_dir / "F0001.jpg").touch()
|
rec.touch()
|
||||||
(manager.frames_dir / "F0002.jpg").touch()
|
|
||||||
|
|
||||||
seg = manager.stream_dir / "segment_0002.ts"
|
# Simulate ffmpeg creating a frame file during extraction
|
||||||
seg.touch()
|
def create_frame_and_return(*args, **kwargs):
|
||||||
|
(manager.frames_dir / "F0001.jpg").touch()
|
||||||
|
return ("", "[Parsed_showinfo_1 @ 0x1] n:0 pts:1000 pts_time:10.5 stuff\n")
|
||||||
|
|
||||||
mock_extract.return_value = ("", "")
|
mock_extract.side_effect = create_frame_and_return
|
||||||
manager._extract_frames_from_file(seg)
|
|
||||||
|
|
||||||
assert mock_extract.call_args.kwargs["start_number"] == 3
|
count, max_ts = manager._extract_new_frames(rec, start_number=1)
|
||||||
|
assert count == 1
|
||||||
|
assert max_ts == 10.5
|
||||||
|
|
||||||
|
index_path = manager.frames_dir / "index.json"
|
||||||
|
with open(index_path) as f:
|
||||||
|
index = json.load(f)
|
||||||
|
assert len(index) == 1
|
||||||
|
assert index[0]["id"] == "F0001"
|
||||||
|
assert index[0]["timestamp"] == 10.5
|
||||||
|
|
||||||
@patch("cht.stream.manager.ff.extract_scene_frames")
|
@patch("cht.stream.manager.ff.extract_scene_frames")
|
||||||
def test_handles_ffmpeg_failure(self, mock_extract, manager):
|
def test_handles_ffmpeg_failure(self, mock_extract, manager):
|
||||||
manager.setup_dirs()
|
manager.setup_dirs()
|
||||||
seg = manager.stream_dir / "segment_0001.ts"
|
rec = manager.recording_path
|
||||||
seg.touch()
|
rec.touch()
|
||||||
|
|
||||||
mock_extract.side_effect = RuntimeError("ffmpeg died")
|
mock_extract.side_effect = RuntimeError("ffmpeg died")
|
||||||
# Should not raise
|
count, max_ts = manager._extract_new_frames(rec)
|
||||||
manager._extract_frames_from_file(seg)
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
class TestFrameWatcher:
|
|
||||||
@patch("cht.stream.manager.ff.extract_scene_frames")
|
@patch("cht.stream.manager.ff.extract_scene_frames")
|
||||||
def test_detects_new_segments(self, mock_extract, manager):
|
def test_skips_preexisting_frames(self, mock_extract, manager):
|
||||||
|
manager.setup_dirs()
|
||||||
|
rec = manager.recording_path
|
||||||
|
rec.touch()
|
||||||
|
|
||||||
|
# Pre-existing frame
|
||||||
|
(manager.frames_dir / "F0001.jpg").touch()
|
||||||
|
|
||||||
|
# ffmpeg "creates" no new files, just returns showinfo for existing
|
||||||
|
stderr = "[Parsed_showinfo_1 @ 0x1] n:0 pts:100 pts_time:5.0 stuff\n"
|
||||||
|
mock_extract.return_value = ("", stderr)
|
||||||
|
|
||||||
|
count, _ = manager._extract_new_frames(rec, start_number=1)
|
||||||
|
# F0001 already existed before extraction, should not be counted
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSceneDetector:
|
||||||
|
@patch("cht.stream.manager.ff.extract_scene_frames")
|
||||||
|
def test_detects_growing_file(self, mock_extract, manager):
|
||||||
manager.setup_dirs()
|
manager.setup_dirs()
|
||||||
mock_extract.return_value = ("", "")
|
mock_extract.return_value = ("", "")
|
||||||
|
|
||||||
manager.start_frame_extractor()
|
# Create recording with some data
|
||||||
|
rec = manager.recording_path
|
||||||
|
rec.write_bytes(b"\x00" * 200_000)
|
||||||
|
|
||||||
# Create a segment file
|
manager.start_scene_detector()
|
||||||
seg = manager.stream_dir / "segment_0001.ts"
|
time.sleep(12) # wait for one cycle
|
||||||
seg.write_bytes(b"\x00" * 100)
|
|
||||||
|
|
||||||
# Wait for watcher to pick it up
|
|
||||||
time.sleep(3)
|
|
||||||
manager.stop_all()
|
manager.stop_all()
|
||||||
|
|
||||||
mock_extract.assert_called()
|
mock_extract.assert_called()
|
||||||
|
|
||||||
@patch("cht.stream.manager.ff.extract_scene_frames")
|
|
||||||
def test_skips_already_seen(self, mock_extract, manager):
|
|
||||||
manager.setup_dirs()
|
|
||||||
mock_extract.return_value = ("", "")
|
|
||||||
|
|
||||||
# Pre-create segment
|
|
||||||
seg = manager.stream_dir / "segment_0001.ts"
|
|
||||||
seg.write_bytes(b"\x00" * 100)
|
|
||||||
|
|
||||||
manager.start_frame_extractor()
|
|
||||||
time.sleep(3)
|
|
||||||
call_count = mock_extract.call_count
|
|
||||||
|
|
||||||
# Wait another cycle — should not re-process
|
|
||||||
time.sleep(3)
|
|
||||||
manager.stop_all()
|
|
||||||
|
|
||||||
assert mock_extract.call_count == call_count
|
|
||||||
|
|||||||
Reference in New Issue
Block a user