working the player
This commit is contained in:
@@ -5,7 +5,7 @@ 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, Gio
|
from gi.repository import Gtk, Adw, Gio, GLib
|
||||||
|
|
||||||
from cht.config import APP_ID, APP_NAME
|
from cht.config import APP_ID, APP_NAME
|
||||||
from cht.window import ChtWindow
|
from cht.window import ChtWindow
|
||||||
@@ -25,7 +25,14 @@ class ChtApp(Adw.Application):
|
|||||||
win.present()
|
win.present()
|
||||||
|
|
||||||
|
|
||||||
|
def _suppress_egl_warnings(domain, level, message, user_data):
|
||||||
|
if b"eglExportDMABUFImage" in message:
|
||||||
|
return
|
||||||
|
GLib.log_default_handler(domain, level, message, user_data)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
GLib.log_set_handler("Gdk", GLib.LogLevelFlags.LEVEL_WARNING, _suppress_egl_warnings, None)
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.DEBUG,
|
level=logging.DEBUG,
|
||||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ SESSIONS_DIR = DATA_DIR / "sessions"
|
|||||||
# Stream defaults
|
# Stream defaults
|
||||||
STREAM_HOST = "0.0.0.0"
|
STREAM_HOST = "0.0.0.0"
|
||||||
STREAM_PORT = 4444
|
STREAM_PORT = 4444
|
||||||
|
RELAY_PORT = 4445 # UDP loopback relay for live display
|
||||||
|
|
||||||
# Frame extraction
|
# Frame extraction — scene-only, no interval fallback
|
||||||
SCENE_THRESHOLD = 0.3 # 0-1, lower = more sensitive
|
SCENE_THRESHOLD = 0.10 # 0-1, lower = more sensitive; 0.1 catches slide/window changes
|
||||||
MAX_FRAME_INTERVAL = 30 # seconds, fallback if no scene change
|
|
||||||
|
|
||||||
# Segment recording
|
# Segment recording
|
||||||
SEGMENT_DURATION = 60 # seconds per .ts segment
|
SEGMENT_DURATION = 60 # seconds per .ts segment
|
||||||
|
|||||||
@@ -13,40 +13,78 @@ import ffmpeg
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
GLOBAL_ARGS = ("-hide_banner", "-loglevel", "warning")
|
GLOBAL_ARGS = ("-hide_banner",)
|
||||||
|
# Note: scene detection needs -loglevel info for showinfo filter output.
|
||||||
|
# Individual pipelines can override with .global_args()
|
||||||
|
QUIET_ARGS = ("-hide_banner", "-loglevel", "warning")
|
||||||
|
|
||||||
|
|
||||||
def receive_and_record(stream_url, output_path):
|
def receive_and_record(stream_url, output_path):
|
||||||
"""Receive mpegts stream and write to a single growing file.
|
"""Receive mpegts stream and write to MKV file.
|
||||||
|
|
||||||
mpv reads this file for DVR-style playback.
|
MKV (Matroska) is used because:
|
||||||
ffmpeg scene detection runs on this file for frame extraction.
|
- Handles incomplete writes gracefully (like OBS default)
|
||||||
Audio is preserved in the recording (muxed mpegts).
|
- Proper timestamps for seeking and duration detection
|
||||||
|
- mpv plays growing MKV files better than mpegts
|
||||||
"""
|
"""
|
||||||
stream = ffmpeg.input(stream_url, fflags="nobuffer", flags="low_delay")
|
stream = ffmpeg.input(stream_url, fflags="nobuffer", flags="low_delay")
|
||||||
return (
|
return (
|
||||||
ffmpeg.output(stream, str(output_path), c="copy", f="mpegts")
|
ffmpeg.output(
|
||||||
.global_args(*GLOBAL_ARGS)
|
stream, str(output_path),
|
||||||
|
c="copy",
|
||||||
|
f="matroska",
|
||||||
|
flush_packets=1,
|
||||||
|
)
|
||||||
|
.global_args(*QUIET_ARGS)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def extract_scene_frames(input_path, output_dir, scene_threshold=0.3,
|
def receive_record_and_relay(stream_url, output_path, relay_url):
|
||||||
max_interval=30, start_number=1, start_time=0.0):
|
"""Receive TCP stream, write to MKV, and relay to UDP loopback for live display.
|
||||||
"""Extract frames from a file on scene change.
|
|
||||||
|
|
||||||
Uses ffmpeg select filter with scene detection and a max-interval fallback.
|
Uses ffmpeg tee via merge_outputs: one ffmpeg process handles both outputs
|
||||||
start_time: skip to this position before processing (avoids re-scanning).
|
from the same decoded input, keeping them in sync with identical timestamps.
|
||||||
|
"""
|
||||||
|
stream = ffmpeg.input(stream_url, fflags="nobuffer", flags="low_delay")
|
||||||
|
file_out = ffmpeg.output(
|
||||||
|
stream, str(output_path),
|
||||||
|
c="copy", f="matroska", flush_packets=1,
|
||||||
|
)
|
||||||
|
relay_out = ffmpeg.output(
|
||||||
|
stream, relay_url,
|
||||||
|
c="copy", f="mpegts",
|
||||||
|
)
|
||||||
|
return ffmpeg.merge_outputs(file_out, relay_out).global_args(*QUIET_ARGS)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_scene_frames(input_path, output_dir, scene_threshold=0.10,
|
||||||
|
start_number=1, start_time=0.0, duration=None):
|
||||||
|
"""Extract frames from a file on scene change only (no interval fallback).
|
||||||
|
|
||||||
|
Frames are a chronological storyboard — captured whenever content changes
|
||||||
|
meaningfully vs the previous frame. No periodic fallback so static content
|
||||||
|
produces no spurious frames.
|
||||||
|
|
||||||
|
start_time/duration: applied via the select filter expression (NOT as -ss/-t
|
||||||
|
input options, which break h264 scene detection on MKV).
|
||||||
Returns (stdout, stderr) as decoded strings for timestamp parsing.
|
Returns (stdout, stderr) as decoded strings for timestamp parsing.
|
||||||
"""
|
"""
|
||||||
select_expr = (
|
scene_expr = f"gt(scene,{scene_threshold})"
|
||||||
f"gt(scene,{scene_threshold})"
|
|
||||||
f"+gte(t-prev_selected_t,{max_interval})"
|
|
||||||
)
|
|
||||||
input_opts = {}
|
|
||||||
if start_time > 0:
|
|
||||||
input_opts["ss"] = str(start_time)
|
|
||||||
|
|
||||||
stream = ffmpeg.input(str(input_path), **input_opts)
|
# Add time range filter if specified (incremental processing)
|
||||||
|
time_conditions = []
|
||||||
|
if start_time > 0:
|
||||||
|
time_conditions.append(f"gte(t,{start_time})")
|
||||||
|
if duration is not None:
|
||||||
|
time_conditions.append(f"lte(t,{start_time + duration})")
|
||||||
|
|
||||||
|
if time_conditions:
|
||||||
|
time_filter = "*".join(time_conditions)
|
||||||
|
select_expr = f"({scene_expr})*{time_filter}"
|
||||||
|
else:
|
||||||
|
select_expr = scene_expr
|
||||||
|
|
||||||
|
stream = ffmpeg.input(str(input_path))
|
||||||
stream = stream.filter("select", select_expr).filter("showinfo")
|
stream = stream.filter("select", select_expr).filter("showinfo")
|
||||||
|
|
||||||
output = (
|
output = (
|
||||||
@@ -61,7 +99,14 @@ def extract_scene_frames(input_path, output_dir, scene_threshold=0.3,
|
|||||||
)
|
)
|
||||||
|
|
||||||
log.info("extract_scene_frames: %s", " ".join(output.compile()))
|
log.info("extract_scene_frames: %s", " ".join(output.compile()))
|
||||||
stdout, stderr = output.run(capture_stdout=True, capture_stderr=True)
|
try:
|
||||||
|
stdout, stderr = output.run(capture_stdout=True, capture_stderr=True)
|
||||||
|
except ffmpeg.Error as e:
|
||||||
|
# ffmpeg may exit non-zero on growing files (corrupt tail) but still
|
||||||
|
# produce valid frames. Return the stderr for parsing anyway.
|
||||||
|
log.debug("ffmpeg exited with error (may still have valid frames)")
|
||||||
|
stdout = e.stdout or b""
|
||||||
|
stderr = e.stderr or b""
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
StreamManager: orchestrates ffmpeg pipelines for receiving, recording,
|
StreamManager: orchestrates ffmpeg for recording and scene detection.
|
||||||
and frame extraction from a muxed mpegts/TCP stream.
|
|
||||||
|
|
||||||
Architecture:
|
Architecture:
|
||||||
sender → TCP:4444 → ffmpeg (writes growing recording.ts)
|
sender → TCP:4444 → ffmpeg (writes recording.ts)
|
||||||
└→ mpv plays recording.ts (DVR: live edge + scrub)
|
recording.ts → mpv (plays via Timeline)
|
||||||
└→ ffmpeg scene detection (periodic on recording)
|
recording.ts → ffmpeg scene detection (periodic, incremental)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -18,8 +17,8 @@ from threading import Thread
|
|||||||
from cht.config import (
|
from cht.config import (
|
||||||
STREAM_HOST,
|
STREAM_HOST,
|
||||||
STREAM_PORT,
|
STREAM_PORT,
|
||||||
|
RELAY_PORT,
|
||||||
SCENE_THRESHOLD,
|
SCENE_THRESHOLD,
|
||||||
MAX_FRAME_INTERVAL,
|
|
||||||
SESSIONS_DIR,
|
SESSIONS_DIR,
|
||||||
)
|
)
|
||||||
from cht.stream import ffmpeg as ff
|
from cht.stream import ffmpeg as ff
|
||||||
@@ -41,71 +40,83 @@ class StreamManager:
|
|||||||
self._procs = {}
|
self._procs = {}
|
||||||
self._threads = {}
|
self._threads = {}
|
||||||
self._stop_flags = set()
|
self._stop_flags = set()
|
||||||
log.info("StreamManager created: session=%s dir=%s", session_id, self.session_dir)
|
log.info("Session: %s", session_id)
|
||||||
|
|
||||||
def setup_dirs(self):
|
def setup_dirs(self):
|
||||||
for d in (self.stream_dir, self.frames_dir, self.transcript_dir, self.agent_dir):
|
for d in (self.stream_dir, self.frames_dir, self.transcript_dir, self.agent_dir):
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
log.info("Session directories created")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def stream_url(self):
|
def stream_url(self):
|
||||||
return f"tcp://{STREAM_HOST}:{STREAM_PORT}?listen"
|
return f"tcp://{STREAM_HOST}:{STREAM_PORT}?listen"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def relay_url(self):
|
||||||
|
return f"udp://127.0.0.1:{RELAY_PORT}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def recording_path(self):
|
def recording_path(self):
|
||||||
return self.stream_dir / "recording.ts"
|
return self.stream_dir / "recording.mkv"
|
||||||
|
|
||||||
# -- Recording --
|
# -- Recording --
|
||||||
|
|
||||||
def start_recorder(self):
|
def start_recorder(self):
|
||||||
"""Start ffmpeg to receive TCP stream and write to recording.ts."""
|
"""Start ffmpeg to receive TCP stream, write to MKV, and relay to UDP."""
|
||||||
node = ff.receive_and_record(self.stream_url, self.recording_path)
|
node = ff.receive_record_and_relay(self.stream_url, self.recording_path, self.relay_url)
|
||||||
proc = ff.run_async(node, pipe_stderr=True)
|
proc = ff.run_async(node, pipe_stderr=True)
|
||||||
self._procs["recorder"] = proc
|
self._procs["recorder"] = proc
|
||||||
log.info("Recorder started: pid=%s url=%s → %s", proc.pid, self.stream_url, self.recording_path)
|
log.info("Recorder: pid=%s → %s", proc.pid, self.recording_path)
|
||||||
self._start_stderr_reader("recorder", proc)
|
self._start_stderr_reader("recorder", proc)
|
||||||
|
|
||||||
# -- Scene detection --
|
# -- Scene Detection --
|
||||||
|
|
||||||
def start_scene_detector(self):
|
def start_scene_detector(self, on_new_frames=None):
|
||||||
"""Periodically run ffmpeg scene detection on the growing recording.
|
"""Periodically run scene detection on new portions of the recording.
|
||||||
|
|
||||||
Tracks how far we've processed to avoid re-scanning from the start.
|
Args:
|
||||||
|
on_new_frames: callback(list of {id, timestamp, path}) for new frames
|
||||||
"""
|
"""
|
||||||
log.info("Starting scene detector (threshold=%.2f, interval=%ds)",
|
self._on_new_frames = on_new_frames
|
||||||
SCENE_THRESHOLD, MAX_FRAME_INTERVAL)
|
|
||||||
|
|
||||||
def _detect():
|
def _detect():
|
||||||
last_processed_size = 0
|
processed_time = 0.0
|
||||||
processed_duration = 0.0 # seconds already processed
|
|
||||||
frame_count = 0
|
frame_count = 0
|
||||||
|
|
||||||
while "stop" not in self._stop_flags:
|
while "stop" not in self._stop_flags:
|
||||||
time.sleep(10)
|
time.sleep(5)
|
||||||
if not self.recording_path.exists():
|
if not self.recording_path.exists():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
size = self.recording_path.stat().st_size
|
size = self.recording_path.stat().st_size
|
||||||
if size <= last_processed_size or size < 100_000:
|
if size < 100_000:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
log.info("Recording grew: %d → %d bytes, scanning from %.1fs",
|
# Get current duration. Use a 6s safety margin — MKV tail can
|
||||||
last_processed_size, size, processed_duration)
|
# be corrupt for several seconds after the last flush, causing
|
||||||
last_processed_size = size
|
# ffmpeg to crash even with a 3s margin.
|
||||||
|
safe_duration = self._estimate_safe_duration()
|
||||||
|
if safe_duration is None or safe_duration <= processed_time + 8:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
# Process from last checkpoint to safe point
|
||||||
new_count, new_duration = self._extract_new_frames(
|
process_to = safe_duration - 6 # 6s safety margin for MKV tail
|
||||||
self.recording_path,
|
if process_to <= processed_time:
|
||||||
start_time=processed_duration,
|
continue
|
||||||
start_number=frame_count + 1,
|
|
||||||
)
|
log.info("Scene detection: %.1fs → %.1fs", processed_time, process_to)
|
||||||
if new_count > 0:
|
new_frames = self._detect_scenes(
|
||||||
frame_count += new_count
|
start_time=processed_time,
|
||||||
log.info("Found %d new frames (total: %d)", new_count, frame_count)
|
end_time=process_to,
|
||||||
if new_duration > processed_duration:
|
start_number=frame_count + 1,
|
||||||
processed_duration = new_duration
|
)
|
||||||
except Exception as e:
|
|
||||||
log.error("Scene detection failed: %s", e)
|
if new_frames:
|
||||||
|
frame_count += len(new_frames)
|
||||||
|
log.info("Found %d new scene frames (total: %d)", len(new_frames), frame_count)
|
||||||
|
if self._on_new_frames:
|
||||||
|
self._on_new_frames(new_frames)
|
||||||
|
|
||||||
|
processed_time = process_to
|
||||||
|
|
||||||
log.info("Scene detector stopped")
|
log.info("Scene detector stopped")
|
||||||
|
|
||||||
@@ -113,39 +124,46 @@ class StreamManager:
|
|||||||
t.start()
|
t.start()
|
||||||
self._threads["scene_detector"] = t
|
self._threads["scene_detector"] = t
|
||||||
|
|
||||||
def _extract_new_frames(self, path, start_time=0.0, start_number=1):
|
def _estimate_safe_duration(self):
|
||||||
"""Extract scene-change frames starting from a given timestamp.
|
"""Estimate recording duration. Uses ffprobe, falls back to file size."""
|
||||||
|
try:
|
||||||
|
import ffmpeg as ffmpeg_lib
|
||||||
|
info = ffmpeg_lib.probe(str(self.recording_path))
|
||||||
|
dur = float(info.get("format", {}).get("duration", 0))
|
||||||
|
if dur > 0:
|
||||||
|
return dur
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
Returns (new_frame_count, max_timestamp_seen).
|
# Fallback: rough estimate from file size (~500kbit/s typical for this stream)
|
||||||
"""
|
try:
|
||||||
|
size = self.recording_path.stat().st_size
|
||||||
|
return size / 65_000 # ~500kbps → 62.5 KB/s
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _detect_scenes(self, start_time, end_time, start_number):
|
||||||
|
"""Run ffmpeg scene detection on a time range. Returns list of new frame entries."""
|
||||||
|
duration = end_time - start_time
|
||||||
existing_before = set(f.name for f in self.frames_dir.glob("F*.jpg"))
|
existing_before = set(f.name for f in self.frames_dir.glob("F*.jpg"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_stdout, stderr = ff.extract_scene_frames(
|
_stdout, stderr = ff.extract_scene_frames(
|
||||||
path,
|
self.recording_path,
|
||||||
self.frames_dir,
|
self.frames_dir,
|
||||||
scene_threshold=SCENE_THRESHOLD,
|
scene_threshold=SCENE_THRESHOLD,
|
||||||
max_interval=MAX_FRAME_INTERVAL,
|
|
||||||
start_number=start_number,
|
start_number=start_number,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
|
duration=duration,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("ffmpeg scene extraction error: %s", e)
|
log.error("Scene detection failed: %s", e)
|
||||||
return 0, start_time
|
return []
|
||||||
|
|
||||||
if stderr:
|
# Parse new frames from showinfo output
|
||||||
for line in stderr.splitlines()[:5]:
|
new_frames = []
|
||||||
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"
|
index_path = self.frames_dir / "index.json"
|
||||||
if index_path.exists():
|
index = json.loads(index_path.read_text()) if index_path.exists() else []
|
||||||
with open(index_path) as f:
|
|
||||||
index = json.load(f)
|
|
||||||
else:
|
|
||||||
index = []
|
|
||||||
|
|
||||||
frame_num = start_number
|
frame_num = start_number
|
||||||
for line in stderr.splitlines():
|
for line in stderr.splitlines():
|
||||||
@@ -157,45 +175,35 @@ class StreamManager:
|
|||||||
frame_id = f"F{frame_num:04d}"
|
frame_id = f"F{frame_num:04d}"
|
||||||
frame_path = self.frames_dir / f"{frame_id}.jpg"
|
frame_path = self.frames_dir / f"{frame_id}.jpg"
|
||||||
if frame_path.exists() and frame_path.name not in existing_before:
|
if frame_path.exists() and frame_path.name not in existing_before:
|
||||||
index.append({
|
entry = {
|
||||||
"id": frame_id,
|
"id": frame_id,
|
||||||
"timestamp": pts_time,
|
"timestamp": pts_time,
|
||||||
"path": str(frame_path),
|
"path": str(frame_path),
|
||||||
"sent_to_agent": False,
|
"sent_to_agent": False,
|
||||||
})
|
}
|
||||||
log.info("Indexed frame %s at pts=%.2f", frame_id, pts_time)
|
index.append(entry)
|
||||||
new_count += 1
|
new_frames.append(entry)
|
||||||
if pts_time > max_ts:
|
|
||||||
max_ts = pts_time
|
|
||||||
frame_num += 1
|
frame_num += 1
|
||||||
|
|
||||||
with open(index_path, "w") as f:
|
index_path.write_text(json.dumps(index, indent=2))
|
||||||
json.dump(index, f, indent=2)
|
return new_frames
|
||||||
|
|
||||||
return new_count, max_ts
|
|
||||||
|
|
||||||
# -- Lifecycle --
|
# -- Lifecycle --
|
||||||
|
|
||||||
def stop_all(self):
|
def stop_all(self):
|
||||||
log.info("Stopping all processes...")
|
log.info("Stopping all...")
|
||||||
self._stop_flags.add("stop")
|
self._stop_flags.add("stop")
|
||||||
for name, proc in self._procs.items():
|
for name, proc in self._procs.items():
|
||||||
log.info("Stopping %s (pid=%s)", name, proc.pid if proc else "?")
|
log.info("Stopping %s", name)
|
||||||
ff.stop_proc(proc)
|
ff.stop_proc(proc)
|
||||||
self._procs.clear()
|
self._procs.clear()
|
||||||
log.info("All processes stopped")
|
|
||||||
|
|
||||||
def _start_stderr_reader(self, name, proc):
|
def _start_stderr_reader(self, name, proc):
|
||||||
def _read():
|
def _read():
|
||||||
try:
|
for line in proc.stderr:
|
||||||
for line in proc.stderr:
|
text = line.decode("utf-8", errors="replace").rstrip()
|
||||||
text = line.decode("utf-8", errors="replace").rstrip()
|
if text:
|
||||||
if text:
|
log.debug("[%s] %s", name, text)
|
||||||
log.info("[%s:stderr] %s", name, text)
|
log.info("[%s] exited: %s", name, proc.poll())
|
||||||
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")
|
Thread(target=_read, daemon=True, name=f"{name}_stderr").start()
|
||||||
t.start()
|
|
||||||
|
|||||||
83
cht/stream/tracker.py
Normal file
83
cht/stream/tracker.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
RecordingTracker: monitors the growing recording file and estimates duration.
|
||||||
|
|
||||||
|
Polls file size periodically. Uses ffprobe occasionally for accurate
|
||||||
|
duration calibration. Feeds duration updates to the Timeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
import ffmpeg as ffmpeg_lib
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingTracker:
|
||||||
|
"""Tracks a growing recording file and estimates its duration."""
|
||||||
|
|
||||||
|
def __init__(self, recording_path, on_duration_update=None):
|
||||||
|
self._path = recording_path
|
||||||
|
self._on_duration = on_duration_update
|
||||||
|
self._duration = 0.0
|
||||||
|
self._avg_bitrate = None # bytes per second, calibrated by ffprobe
|
||||||
|
self._stop = False
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self):
|
||||||
|
return self._duration
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._stop = False
|
||||||
|
self._thread = Thread(target=self._poll_loop, daemon=True, name="rec_tracker")
|
||||||
|
self._thread.start()
|
||||||
|
log.info("RecordingTracker started: %s", self._path)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop = True
|
||||||
|
log.info("RecordingTracker stopped")
|
||||||
|
|
||||||
|
def _poll_loop(self):
|
||||||
|
probe_interval = 0 # probe on first data
|
||||||
|
cycles = 0
|
||||||
|
|
||||||
|
while not self._stop:
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
if not self._path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
size = self._path.stat().st_size
|
||||||
|
if size < 10_000:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calibrate with ffprobe every ~30s or on first data
|
||||||
|
cycles += 1
|
||||||
|
if self._avg_bitrate is None or cycles % 15 == 0:
|
||||||
|
probed = self._probe_duration()
|
||||||
|
if probed and probed > 0 and size > 0:
|
||||||
|
self._avg_bitrate = size / probed
|
||||||
|
self._duration = probed
|
||||||
|
log.info("Probed duration: %.1fs (bitrate: %.0f B/s)",
|
||||||
|
probed, self._avg_bitrate)
|
||||||
|
elif self._avg_bitrate:
|
||||||
|
# Estimate from file size between probes
|
||||||
|
self._duration = size / self._avg_bitrate
|
||||||
|
|
||||||
|
if self._on_duration and self._duration > 0:
|
||||||
|
self._on_duration(self._duration)
|
||||||
|
|
||||||
|
def _probe_duration(self):
|
||||||
|
"""Use ffprobe to get accurate duration of the recording."""
|
||||||
|
try:
|
||||||
|
info = ffmpeg_lib.probe(str(self._path))
|
||||||
|
duration = float(info.get("format", {}).get("duration", 0))
|
||||||
|
return duration
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("ffprobe failed (file still growing): %s", e)
|
||||||
|
return None
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
MonitorWidget: mpv-based stream monitor embedded in GTK4 via OpenGL.
|
MonitorWidget: dual-player video display embedded in GTK4 via OpenGL.
|
||||||
|
|
||||||
Supports DVR-style playback of a growing recording file:
|
Two players share the same position via a Gtk.Stack:
|
||||||
- Follows live edge by default
|
- "live" player: mpv reads UDP relay (low latency, always streaming)
|
||||||
- Slider scrubs video + audio together
|
- "review" player: mpv reads local MKV file (full seek support)
|
||||||
- Can capture frame at current cursor position
|
|
||||||
|
Driven by a single Timeline "changed" signal. Reads timeline.state directly:
|
||||||
|
state.live=True → show live stack, live player streams
|
||||||
|
state.live=False → show review stack, apply state.paused
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
@@ -18,179 +21,187 @@ from cht.ui.mpv import Player
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Cache libGL reference
|
|
||||||
_libGL = ctypes.cdll.LoadLibrary("libGL.so.1")
|
_libGL = ctypes.cdll.LoadLibrary("libGL.so.1")
|
||||||
GL_DRAW_FRAMEBUFFER_BINDING = 0x8CA6
|
GL_DRAW_FRAMEBUFFER_BINDING = 0x8CA6
|
||||||
|
|
||||||
|
|
||||||
|
def _make_gl_area(on_realize, on_unrealize, on_render):
|
||||||
|
gl_area = Gtk.GLArea()
|
||||||
|
gl_area.set_hexpand(True)
|
||||||
|
gl_area.set_vexpand(True)
|
||||||
|
gl_area.set_auto_render(False)
|
||||||
|
gl_area.set_has_depth_buffer(False)
|
||||||
|
gl_area.set_has_stencil_buffer(False)
|
||||||
|
gl_area.connect("realize", on_realize)
|
||||||
|
gl_area.connect("unrealize", on_unrealize)
|
||||||
|
gl_area.connect("render", on_render)
|
||||||
|
return gl_area
|
||||||
|
|
||||||
|
|
||||||
class MonitorWidget(Gtk.Box):
|
class MonitorWidget(Gtk.Box):
|
||||||
"""Embedded mpv video player with DVR controls."""
|
"""Dual-player mpv display, driven by Timeline "changed" signal."""
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, timeline, **kwargs):
|
||||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs)
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs)
|
||||||
self._player = None
|
self._timeline = timeline
|
||||||
self._following_live = True
|
self._live_source_url = None
|
||||||
self._slider_updating = False
|
self._recording_path = None
|
||||||
|
|
||||||
# GL area for video
|
self._live_player = None
|
||||||
self._gl_area = Gtk.GLArea()
|
self._live_loaded = False
|
||||||
self._gl_area.set_hexpand(True)
|
|
||||||
self._gl_area.set_vexpand(True)
|
|
||||||
self._gl_area.set_auto_render(False)
|
|
||||||
self._gl_area.set_has_depth_buffer(False)
|
|
||||||
self._gl_area.set_has_stencil_buffer(False)
|
|
||||||
self._gl_area.connect("realize", self._on_realize)
|
|
||||||
self._gl_area.connect("unrealize", self._on_unrealize)
|
|
||||||
self._gl_area.connect("render", self._on_render)
|
|
||||||
self.append(self._gl_area)
|
|
||||||
|
|
||||||
# Slider for scrubbing (shared timeline for video + audio)
|
self._review_player = None
|
||||||
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._stack = Gtk.Stack()
|
||||||
self._time_label.set_width_chars(6)
|
self._stack.set_hexpand(True)
|
||||||
slider_box.append(self._time_label)
|
self._stack.set_vexpand(True)
|
||||||
|
self._stack.set_transition_type(Gtk.StackTransitionType.NONE)
|
||||||
|
|
||||||
self._slider = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL)
|
self._live_gl = _make_gl_area(
|
||||||
self._slider.set_hexpand(True)
|
self._on_live_realize, self._on_live_unrealize, self._on_live_render,
|
||||||
self._slider.set_range(0, 1)
|
)
|
||||||
self._slider.set_draw_value(False)
|
self._stack.add_named(self._live_gl, "live")
|
||||||
self._slider.connect("value-changed", self._on_slider_changed)
|
|
||||||
slider_box.append(self._slider)
|
|
||||||
|
|
||||||
self._duration_label = Gtk.Label(label="00:00")
|
self._review_gl = _make_gl_area(
|
||||||
self._duration_label.set_width_chars(6)
|
self._on_review_realize, self._on_review_unrealize, self._on_review_render,
|
||||||
slider_box.append(self._duration_label)
|
)
|
||||||
|
self._stack.add_named(self._review_gl, "review")
|
||||||
|
|
||||||
self._live_btn = Gtk.Button(label="LIVE")
|
self.append(self._stack)
|
||||||
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)
|
timeline.connect("changed", self._on_changed)
|
||||||
|
GLib.timeout_add(500, self._sync_cursor_from_player)
|
||||||
# Update slider position periodically
|
log.info("MonitorWidget initialized")
|
||||||
GLib.timeout_add(500, self._update_slider)
|
|
||||||
|
|
||||||
log.info("MonitorWidget initialized (GLArea + slider)")
|
|
||||||
|
|
||||||
# -- GL callbacks --
|
|
||||||
|
|
||||||
def _on_realize(self, gl_area):
|
|
||||||
log.info("GLArea realized")
|
|
||||||
gl_area.make_current()
|
|
||||||
if gl_area.get_error():
|
|
||||||
log.error("GLArea error: %s", gl_area.get_error())
|
|
||||||
|
|
||||||
def _on_unrealize(self, gl_area):
|
|
||||||
log.info("GLArea unrealized")
|
|
||||||
self.stop()
|
|
||||||
|
|
||||||
def _on_render(self, gl_area, gl_context):
|
|
||||||
if not self._player:
|
|
||||||
return True
|
|
||||||
|
|
||||||
width = gl_area.get_width()
|
|
||||||
height = gl_area.get_height()
|
|
||||||
|
|
||||||
fbo_id = ctypes.c_int(0)
|
|
||||||
_libGL.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, ctypes.byref(fbo_id))
|
|
||||||
|
|
||||||
self._player.render(fbo_id.value, width, height)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _on_mpv_update(self):
|
|
||||||
GLib.idle_add(self._gl_area.queue_render)
|
|
||||||
|
|
||||||
# -- Slider --
|
|
||||||
|
|
||||||
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 --
|
# -- Public API --
|
||||||
|
|
||||||
def _seek_to_end_once(self):
|
def set_live_source(self, url):
|
||||||
if self._player and self._player.duration:
|
self._live_source_url = url
|
||||||
self._player.seek(self._player.duration - 0.5)
|
log.info("Live source: %s", url)
|
||||||
return False # don't repeat
|
if self._live_player and not self._live_loaded:
|
||||||
|
self._live_player.load_live(url)
|
||||||
|
self._live_player.play()
|
||||||
|
self._live_loaded = True
|
||||||
|
|
||||||
def start_recording(self, recording_path):
|
def set_recording(self, path):
|
||||||
"""Start DVR-style playback of a growing recording file.
|
self._recording_path = path
|
||||||
|
log.info("Recording path: %s", path)
|
||||||
|
|
||||||
Args:
|
def get_live_position(self):
|
||||||
recording_path: path to the .ts file being written by ffmpeg
|
"""Return the live player's current time_pos, or None."""
|
||||||
"""
|
if self._live_player:
|
||||||
self._recording_path = recording_path
|
return self._live_player.time_pos
|
||||||
self._gl_area.make_current()
|
return None
|
||||||
|
|
||||||
self._player = Player()
|
|
||||||
self._player.init_gl(update_callback=self._on_mpv_update)
|
|
||||||
self._player.play_file(recording_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):
|
def screenshot(self, path):
|
||||||
"""Capture frame at current cursor position."""
|
if self._timeline.state.live and self._live_player:
|
||||||
if self._player:
|
self._live_player.screenshot(path)
|
||||||
self._player.screenshot(path)
|
elif self._review_player:
|
||||||
|
self._review_player.screenshot(path)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
if self._player:
|
log.info("Stopping monitor")
|
||||||
log.info("Stopping monitor")
|
if self._live_player:
|
||||||
self._player.terminate()
|
self._live_player.terminate()
|
||||||
self._player = None
|
self._live_player = None
|
||||||
|
self._live_loaded = False
|
||||||
|
if self._review_player:
|
||||||
|
self._review_player.terminate()
|
||||||
|
self._review_player = None
|
||||||
|
|
||||||
|
# -- Live GLArea --
|
||||||
|
|
||||||
|
def _on_live_realize(self, gl_area):
|
||||||
|
gl_area.make_current()
|
||||||
|
self._live_player = Player()
|
||||||
|
self._live_player.init_gl(
|
||||||
|
update_callback=lambda: GLib.idle_add(self._live_gl.queue_render)
|
||||||
|
)
|
||||||
|
log.info("Live player created")
|
||||||
|
if self._live_source_url and not self._live_loaded:
|
||||||
|
self._live_player.load_live(self._live_source_url)
|
||||||
|
self._live_player.play()
|
||||||
|
self._live_loaded = True
|
||||||
|
|
||||||
|
def _on_live_unrealize(self, gl_area):
|
||||||
|
if self._live_player:
|
||||||
|
self._live_player.terminate()
|
||||||
|
self._live_player = None
|
||||||
|
self._live_loaded = False
|
||||||
|
|
||||||
|
def _on_live_render(self, gl_area, _ctx):
|
||||||
|
if not self._live_player:
|
||||||
|
return True
|
||||||
|
fbo = ctypes.c_int(0)
|
||||||
|
_libGL.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, ctypes.byref(fbo))
|
||||||
|
self._live_player.render(fbo.value, gl_area.get_width(), gl_area.get_height())
|
||||||
|
return True
|
||||||
|
|
||||||
|
# -- Review GLArea --
|
||||||
|
|
||||||
|
def _on_review_realize(self, gl_area):
|
||||||
|
gl_area.make_current()
|
||||||
|
self._review_player = Player()
|
||||||
|
self._review_player.init_gl(
|
||||||
|
update_callback=lambda: GLib.idle_add(self._review_gl.queue_render)
|
||||||
|
)
|
||||||
|
log.info("Review player created")
|
||||||
|
|
||||||
|
def _on_review_unrealize(self, gl_area):
|
||||||
|
if self._review_player:
|
||||||
|
self._review_player.terminate()
|
||||||
|
self._review_player = None
|
||||||
|
|
||||||
|
def _on_review_render(self, gl_area, _ctx):
|
||||||
|
if not self._review_player:
|
||||||
|
return True
|
||||||
|
fbo = ctypes.c_int(0)
|
||||||
|
_libGL.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, ctypes.byref(fbo))
|
||||||
|
self._review_player.render(fbo.value, gl_area.get_width(), gl_area.get_height())
|
||||||
|
return True
|
||||||
|
|
||||||
|
# -- Timeline response --
|
||||||
|
|
||||||
|
def _on_changed(self, timeline):
|
||||||
|
s = timeline.state
|
||||||
|
current = self._stack.get_visible_child_name()
|
||||||
|
|
||||||
|
if s.live:
|
||||||
|
# Ensure live player is loaded and playing
|
||||||
|
if self._live_player and not self._live_loaded and self._live_source_url:
|
||||||
|
self._live_player.load_live(self._live_source_url)
|
||||||
|
self._live_player.play()
|
||||||
|
self._live_loaded = True
|
||||||
|
elif self._live_player and self._live_loaded:
|
||||||
|
self._live_player.play()
|
||||||
|
if current != "live":
|
||||||
|
self._stack.set_visible_child_name("live")
|
||||||
else:
|
else:
|
||||||
log.info("Monitor already stopped")
|
# Scrub mode
|
||||||
|
if current == "live":
|
||||||
|
# Transitioning from live: seek review player to live position
|
||||||
|
pos = s.cursor # already set by toggle_live()
|
||||||
|
if self._review_player and self._recording_path:
|
||||||
|
self._review_player.load(self._recording_path)
|
||||||
|
if s.paused:
|
||||||
|
self._review_player.show_frame_at(pos)
|
||||||
|
else:
|
||||||
|
self._review_player.seek(pos)
|
||||||
|
self._review_player.play()
|
||||||
|
self._stack.set_visible_child_name("review")
|
||||||
|
else:
|
||||||
|
# Already in review: just apply paused state
|
||||||
|
if self._review_player:
|
||||||
|
if s.paused:
|
||||||
|
self._review_player.pause()
|
||||||
|
else:
|
||||||
|
self._review_player.play()
|
||||||
|
|
||||||
|
def _sync_cursor_from_player(self):
|
||||||
|
s = self._timeline.state
|
||||||
|
if not s.live and not s.paused and self._review_player:
|
||||||
|
pos = self._review_player.time_pos
|
||||||
|
if pos is not None and pos > 0:
|
||||||
|
self._timeline.set_cursor(pos)
|
||||||
|
# Live mode: cursor driven by tick_live() in window.py
|
||||||
|
return True
|
||||||
|
|||||||
123
cht/ui/mpv.py
123
cht/ui/mpv.py
@@ -2,10 +2,7 @@
|
|||||||
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.
|
||||||
Supports DVR-style playback of a growing recording file:
|
Driven by the Timeline state machine — does not manage its own state.
|
||||||
- Follow live edge (default)
|
|
||||||
- Scrub back to any point
|
|
||||||
- Audio + video synced via single slider
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
@@ -35,10 +32,8 @@ _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.
|
||||||
|
|
||||||
Designed for DVR-style playback of a growing file:
|
Does not manage playback state — that's the Timeline's job.
|
||||||
- play_file() opens the recording and seeks to end (live edge)
|
Provides: load, play, pause, seek, show_frame_at, render.
|
||||||
- seek() scrubs to any position (audio + video move together)
|
|
||||||
- time_pos / duration track playback state for the slider
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -48,17 +43,12 @@ class Player:
|
|||||||
"osc": False,
|
"osc": False,
|
||||||
"vo": "libmpv",
|
"vo": "libmpv",
|
||||||
"hwdec": "auto",
|
"hwdec": "auto",
|
||||||
"video_sync": "display-desync",
|
# Keep open at EOF so LIVE mode can wait for more data
|
||||||
# DVR: keep alive at EOF, wait for more data
|
|
||||||
"keep_open": "yes",
|
"keep_open": "yes",
|
||||||
"demuxer_max_bytes": "500MiB",
|
"keep_open_pause": "no",
|
||||||
"demuxer_readahead_secs": "5",
|
|
||||||
# Allow re-reading growing file
|
|
||||||
"demuxer_cache_wait": True,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Creating mpv player (OpenGL render, DVR mode)")
|
self._player = libmpv.MPV(log_handler=self._mpv_log, loglevel="warn", **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")
|
||||||
@@ -70,14 +60,9 @@ class Player:
|
|||||||
log.error(msg)
|
log.error(msg)
|
||||||
elif loglevel == "warn":
|
elif loglevel == "warn":
|
||||||
log.warning(msg)
|
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. Call with active GL context."""
|
||||||
|
|
||||||
Must be called with an active GL context.
|
|
||||||
"""
|
|
||||||
self._update_callback = update_callback
|
self._update_callback = update_callback
|
||||||
self._ctx = libmpv.MpvRenderContext(
|
self._ctx = libmpv.MpvRenderContext(
|
||||||
self._player,
|
self._player,
|
||||||
@@ -88,7 +73,7 @@ class Player:
|
|||||||
)
|
)
|
||||||
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 GL render context initialized")
|
||||||
|
|
||||||
def _on_mpv_update(self):
|
def _on_mpv_update(self):
|
||||||
if self._update_callback:
|
if self._update_callback:
|
||||||
@@ -102,51 +87,63 @@ class Player:
|
|||||||
opengl_fbo={"fbo": fbo, "w": width, "h": height},
|
opengl_fbo={"fbo": fbo, "w": width, "h": height},
|
||||||
)
|
)
|
||||||
|
|
||||||
def play(self, source):
|
def load(self, path):
|
||||||
"""Play from any source (URL, file path)."""
|
"""Load a recording file. Does not start playback."""
|
||||||
log.info("mpv play: %s", source)
|
log.info("mpv load: %s", path)
|
||||||
self._player.play(str(source))
|
self._player.loadfile(str(path), mode="replace")
|
||||||
|
|
||||||
def play_file(self, path):
|
def load_live(self, url):
|
||||||
"""Play a recording file, seeking to the end (live edge)."""
|
"""Load a live stream URL with low-latency options."""
|
||||||
log.info("mpv play_file (DVR): %s", path)
|
self._player["cache"] = "no"
|
||||||
self._player.play(str(path))
|
self._player["demuxer-max-bytes"] = "512KiB"
|
||||||
# Seek to end once playback starts
|
self._player["audio-buffer"] = 0.2
|
||||||
self._player.observe_property("duration", self._seek_to_live_once)
|
log.info("mpv load_live: %s", url)
|
||||||
|
self._player.loadfile(str(url), mode="replace")
|
||||||
|
|
||||||
def _seek_to_live_once(self, name, value):
|
def play(self):
|
||||||
"""Seek to live edge once duration is known, then stop observing."""
|
"""Resume/start playback."""
|
||||||
if value and value > 1:
|
self._player.pause = False
|
||||||
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):
|
||||||
|
"""Pause playback."""
|
||||||
self._player.pause = True
|
self._player.pause = True
|
||||||
|
|
||||||
def resume(self):
|
def seek(self, seconds):
|
||||||
self._player.pause = False
|
"""Seek to absolute position."""
|
||||||
|
try:
|
||||||
|
self._player.seek(seconds, reference="absolute")
|
||||||
|
except Exception:
|
||||||
|
pass # seek may fail if file not loaded yet
|
||||||
|
|
||||||
|
def show_frame_at(self, seconds):
|
||||||
|
"""Pause and show the frame at the given timestamp."""
|
||||||
|
self._player.pause = True
|
||||||
|
try:
|
||||||
|
self._player.seek(seconds, reference="absolute")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time_pos(self):
|
||||||
|
try:
|
||||||
|
return self._player.time_pos
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def paused(self):
|
def paused(self):
|
||||||
return self._player.pause
|
return self._player.pause
|
||||||
|
|
||||||
def seek(self, seconds):
|
@property
|
||||||
"""Seek to absolute position. Audio + video move together."""
|
def idle(self):
|
||||||
self._player.seek(seconds, reference="absolute")
|
return self._player.core_idle
|
||||||
|
|
||||||
def seek_relative(self, seconds):
|
|
||||||
"""Seek relative to current position."""
|
|
||||||
self._player.seek(seconds, reference="relative")
|
|
||||||
|
|
||||||
def screenshot(self, path):
|
def screenshot(self, path):
|
||||||
"""Save current frame as an image file."""
|
"""Save current frame as an image file."""
|
||||||
self._player.screenshot_to_file(str(path), includes="video")
|
try:
|
||||||
log.debug("Screenshot saved: %s", path)
|
self._player.screenshot_to_file(str(path), includes="video")
|
||||||
|
except Exception as e:
|
||||||
def stop(self):
|
log.warning("Screenshot failed: %s", e)
|
||||||
log.info("mpv stop")
|
|
||||||
self._player.stop()
|
|
||||||
|
|
||||||
def terminate(self):
|
def terminate(self):
|
||||||
log.info("mpv terminate")
|
log.info("mpv terminate")
|
||||||
@@ -157,21 +154,3 @@ class Player:
|
|||||||
self._player.terminate()
|
self._player.terminate()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("mpv terminate error: %s", e)
|
log.warning("mpv terminate error: %s", e)
|
||||||
|
|
||||||
@property
|
|
||||||
def idle(self):
|
|
||||||
return self._player.core_idle
|
|
||||||
|
|
||||||
@property
|
|
||||||
def duration(self):
|
|
||||||
try:
|
|
||||||
return self._player.duration
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def time_pos(self):
|
|
||||||
try:
|
|
||||||
return self._player.time_pos
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|||||||
273
cht/ui/timeline.py
Normal file
273
cht/ui/timeline.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"""
|
||||||
|
Timeline: state machine + shared slider for the recording.
|
||||||
|
|
||||||
|
State is a plain ViewState dataclass — two orthogonal flags replace the old
|
||||||
|
4-state enum. A single "changed" GObject signal is emitted on any mutation;
|
||||||
|
consumers read timeline.state directly.
|
||||||
|
|
||||||
|
live=True — watching UDP relay; play/pause and seek are disabled
|
||||||
|
live=False — scrub mode; paused controls play/pause
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import gi
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gtk, GLib, GObject
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ViewState:
|
||||||
|
live: bool = False # True = watching live UDP relay
|
||||||
|
paused: bool = False # scrub mode: is playback paused?
|
||||||
|
cursor: float = 0.0 # current position in recording (seconds)
|
||||||
|
duration: float = 0.0 # recording duration (seconds)
|
||||||
|
scene_markers: list[float] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class Timeline(GObject.Object):
|
||||||
|
"""State machine for recording playback.
|
||||||
|
|
||||||
|
Single signal: "changed" — emitted on any state mutation.
|
||||||
|
Consumers read timeline.state directly (no signal arguments).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__gsignals__ = {
|
||||||
|
"changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.state = ViewState()
|
||||||
|
log.info("Timeline created")
|
||||||
|
|
||||||
|
def _emit(self):
|
||||||
|
self.emit("changed")
|
||||||
|
|
||||||
|
# -- Duration / cursor (called from background threads via GLib.idle_add) --
|
||||||
|
|
||||||
|
def set_duration(self, duration):
|
||||||
|
"""Update recording duration. In live mode, cursor follows end."""
|
||||||
|
if duration <= self.state.duration:
|
||||||
|
return
|
||||||
|
self.state.duration = duration
|
||||||
|
if self.state.live:
|
||||||
|
self.state.cursor = duration
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def set_cursor(self, position):
|
||||||
|
"""Set cursor position (from slider drag or playback sync)."""
|
||||||
|
position = max(0.0, min(position, self.state.duration))
|
||||||
|
if abs(position - self.state.cursor) < 0.05:
|
||||||
|
return
|
||||||
|
self.state.cursor = position
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def tick_live(self):
|
||||||
|
"""Advance cursor by 1s in live mode (smooth progression between probes)."""
|
||||||
|
if not self.state.live:
|
||||||
|
return
|
||||||
|
self.state.cursor += 1.0
|
||||||
|
# Only cap once duration is known — avoids clamping to 0 before first probe
|
||||||
|
if self.state.duration > 0:
|
||||||
|
self.state.cursor = min(self.state.cursor, self.state.duration)
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def add_scene_marker(self, timestamp):
|
||||||
|
"""Add a scene change marker (does not emit — no UI update needed)."""
|
||||||
|
self.state.scene_markers.append(timestamp)
|
||||||
|
self.state.scene_markers.sort()
|
||||||
|
|
||||||
|
# -- User actions --
|
||||||
|
|
||||||
|
def go_live(self):
|
||||||
|
"""Go to live mode at the recording end."""
|
||||||
|
self.state.live = True
|
||||||
|
self.state.paused = False
|
||||||
|
self.state.cursor = self.state.duration
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def toggle_live(self, live_player_pos=None):
|
||||||
|
"""Toggle between live and scrub mode.
|
||||||
|
|
||||||
|
When leaving live, cursor stays at the live player's actual position
|
||||||
|
if provided, otherwise stays at current cursor.
|
||||||
|
"""
|
||||||
|
if self.state.live:
|
||||||
|
# Enter scrub mode at the live player's current position
|
||||||
|
self.state.live = False
|
||||||
|
self.state.paused = True
|
||||||
|
if live_player_pos is not None and live_player_pos > 0:
|
||||||
|
pos = max(0.0, min(live_player_pos, self.state.duration))
|
||||||
|
self.state.cursor = pos
|
||||||
|
else:
|
||||||
|
self.state.live = True
|
||||||
|
self.state.paused = False
|
||||||
|
self.state.cursor = self.state.duration
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def play(self):
|
||||||
|
if self.state.live:
|
||||||
|
return
|
||||||
|
self.state.paused = False
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
if self.state.live:
|
||||||
|
return
|
||||||
|
self.state.paused = True
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def seek(self, position):
|
||||||
|
"""Seek to position — enters scrub mode, pauses."""
|
||||||
|
self.state.live = False
|
||||||
|
self.state.paused = True
|
||||||
|
position = max(0.0, min(position, self.state.duration))
|
||||||
|
if abs(position - self.state.cursor) >= 0.05:
|
||||||
|
self.state.cursor = position
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Reset all state (called on disconnect)."""
|
||||||
|
self.state = ViewState()
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineControls(Gtk.Box):
|
||||||
|
"""Shared slider + play/pause/live controls.
|
||||||
|
|
||||||
|
Play/Pause and slider are insensitive in live mode.
|
||||||
|
LIVE button is a toggle — active style when live=True.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, timeline, **kwargs):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, **kwargs)
|
||||||
|
self._timeline = timeline
|
||||||
|
self._updating_slider = False
|
||||||
|
self._dragging = False
|
||||||
|
self._wall_clock_start = None
|
||||||
|
|
||||||
|
self.set_margin_start(4)
|
||||||
|
self.set_margin_end(4)
|
||||||
|
self.set_margin_top(2)
|
||||||
|
self.set_margin_bottom(4)
|
||||||
|
|
||||||
|
# Play/Pause button
|
||||||
|
self._play_btn = Gtk.Button(label="Play")
|
||||||
|
self._play_btn.connect("clicked", self._on_play_clicked)
|
||||||
|
self.append(self._play_btn)
|
||||||
|
|
||||||
|
# Current time label
|
||||||
|
self._time_label = Gtk.Label(label="00:00")
|
||||||
|
self._time_label.set_width_chars(6)
|
||||||
|
self.append(self._time_label)
|
||||||
|
|
||||||
|
# Slider
|
||||||
|
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_value_changed)
|
||||||
|
|
||||||
|
press_ctrl = Gtk.GestureClick()
|
||||||
|
press_ctrl.connect("pressed", self._on_slider_pressed)
|
||||||
|
press_ctrl.connect("released", self._on_slider_released)
|
||||||
|
press_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
|
||||||
|
self._slider.add_controller(press_ctrl)
|
||||||
|
self.append(self._slider)
|
||||||
|
|
||||||
|
# Duration label
|
||||||
|
self._duration_label = Gtk.Label(label="00:00 / 00:00")
|
||||||
|
self._duration_label.set_width_chars(14)
|
||||||
|
self.append(self._duration_label)
|
||||||
|
|
||||||
|
# LIVE toggle button
|
||||||
|
self._live_btn = Gtk.Button(label="LIVE")
|
||||||
|
self._live_btn.connect("clicked", self._on_live_clicked)
|
||||||
|
self.append(self._live_btn)
|
||||||
|
|
||||||
|
timeline.connect("changed", self._on_changed)
|
||||||
|
GLib.timeout_add(1000, self._tick_total)
|
||||||
|
|
||||||
|
def _on_play_clicked(self, btn):
|
||||||
|
s = self._timeline.state
|
||||||
|
if s.paused:
|
||||||
|
self._timeline.play()
|
||||||
|
else:
|
||||||
|
self._timeline.pause()
|
||||||
|
|
||||||
|
def set_live_toggle_callback(self, cb):
|
||||||
|
"""Override the LIVE button handler. cb() should return the live player
|
||||||
|
position (float or None) and call timeline.toggle_live() itself."""
|
||||||
|
self._live_toggle_cb = cb
|
||||||
|
|
||||||
|
def _on_live_clicked(self, btn):
|
||||||
|
if hasattr(self, "_live_toggle_cb"):
|
||||||
|
self._live_toggle_cb()
|
||||||
|
else:
|
||||||
|
self._timeline.toggle_live()
|
||||||
|
|
||||||
|
def _on_slider_value_changed(self, slider):
|
||||||
|
if self._dragging:
|
||||||
|
self._time_label.set_text(self._fmt_time(slider.get_value()))
|
||||||
|
|
||||||
|
def _on_slider_pressed(self, gesture, n_press, x, y):
|
||||||
|
self._dragging = True
|
||||||
|
|
||||||
|
def _on_slider_released(self, gesture, n_press, x, y):
|
||||||
|
if self._dragging:
|
||||||
|
self._dragging = False
|
||||||
|
self._timeline.seek(self._slider.get_value())
|
||||||
|
|
||||||
|
def _on_changed(self, timeline):
|
||||||
|
s = timeline.state
|
||||||
|
|
||||||
|
# Start wall clock when first going live (not on duration, which arrives ~30s later)
|
||||||
|
if s.live and self._wall_clock_start is None:
|
||||||
|
self._wall_clock_start = time.monotonic()
|
||||||
|
|
||||||
|
# Live mode: disable scrub controls
|
||||||
|
self._play_btn.set_sensitive(not s.live)
|
||||||
|
self._slider.set_sensitive(not s.live)
|
||||||
|
|
||||||
|
if s.live:
|
||||||
|
self._live_btn.add_css_class("suggested-action")
|
||||||
|
else:
|
||||||
|
self._live_btn.remove_css_class("suggested-action")
|
||||||
|
|
||||||
|
# Play button label (only relevant in scrub mode)
|
||||||
|
self._play_btn.set_label("Pause" if not s.paused else "Play")
|
||||||
|
|
||||||
|
# Slider position
|
||||||
|
if not self._dragging:
|
||||||
|
self._updating_slider = True
|
||||||
|
self._slider.set_range(0, max(s.duration, 0.1))
|
||||||
|
self._slider.set_value(s.cursor)
|
||||||
|
self._updating_slider = False
|
||||||
|
|
||||||
|
self._time_label.set_text(self._fmt_time(s.cursor))
|
||||||
|
self._update_duration_label()
|
||||||
|
|
||||||
|
def _tick_total(self):
|
||||||
|
self._update_duration_label()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _update_duration_label(self):
|
||||||
|
s = self._timeline.state
|
||||||
|
loaded = s.duration
|
||||||
|
total = (time.monotonic() - self._wall_clock_start) if self._wall_clock_start else loaded
|
||||||
|
self._duration_label.set_text(
|
||||||
|
f"{self._fmt_time(loaded)} / {self._fmt_time(total)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@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}"
|
||||||
272
cht/window.py
272
cht/window.py
@@ -1,3 +1,5 @@
|
|||||||
|
"""Main application window — wires Timeline to all components."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -9,8 +11,10 @@ gi.require_version("GdkPixbuf", "2.0")
|
|||||||
from gi.repository import Gtk, Adw, GLib, Pango, GdkPixbuf
|
from gi.repository import Gtk, Adw, GLib, Pango, GdkPixbuf
|
||||||
|
|
||||||
from cht.config import APP_NAME
|
from cht.config import APP_NAME
|
||||||
|
from cht.ui.timeline import Timeline, TimelineControls
|
||||||
from cht.ui.monitor import MonitorWidget
|
from cht.ui.monitor import MonitorWidget
|
||||||
from cht.stream.manager import StreamManager
|
from cht.stream.manager import StreamManager
|
||||||
|
from cht.stream.tracker import RecordingTracker
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -21,22 +25,25 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self.set_title(APP_NAME)
|
self.set_title(APP_NAME)
|
||||||
self.set_default_size(1400, 900)
|
self.set_default_size(1400, 900)
|
||||||
self._streaming = False
|
self._streaming = False
|
||||||
|
self._stream_mgr = None
|
||||||
|
self._tracker = None
|
||||||
|
self._known_frames = set()
|
||||||
|
|
||||||
# Main horizontal paned: agent output (left) | right panels
|
# Timeline is the central state machine
|
||||||
|
self._timeline = Timeline()
|
||||||
|
|
||||||
|
# Main layout
|
||||||
self._main_paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
|
self._main_paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
|
||||||
self._main_paned.set_shrink_start_child(False)
|
self._main_paned.set_shrink_start_child(False)
|
||||||
self._main_paned.set_shrink_end_child(False)
|
self._main_paned.set_shrink_end_child(False)
|
||||||
self._main_paned.set_position(450)
|
self._main_paned.set_position(450)
|
||||||
|
|
||||||
# Left: Agent output panel
|
self._main_paned.set_start_child(self._build_agent_output())
|
||||||
self._agent_output = self._build_agent_output()
|
|
||||||
self._main_paned.set_start_child(self._agent_output)
|
|
||||||
|
|
||||||
# Right: vertical stack of panels
|
|
||||||
right_box = self._build_right_panels()
|
right_box = self._build_right_panels()
|
||||||
self._main_paned.set_end_child(right_box)
|
self._main_paned.set_end_child(right_box)
|
||||||
|
|
||||||
# Wrap in toolbar view with header
|
# Header
|
||||||
toolbar = Adw.ToolbarView()
|
toolbar = Adw.ToolbarView()
|
||||||
header = Adw.HeaderBar()
|
header = Adw.HeaderBar()
|
||||||
header.set_title_widget(Gtk.Label(label=APP_NAME))
|
header.set_title_widget(Gtk.Label(label=APP_NAME))
|
||||||
@@ -48,13 +55,8 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
toolbar.add_top_bar(header)
|
toolbar.add_top_bar(header)
|
||||||
toolbar.set_content(self._main_paned)
|
toolbar.set_content(self._main_paned)
|
||||||
|
|
||||||
self.set_content(toolbar)
|
self.set_content(toolbar)
|
||||||
|
|
||||||
# Stream manager
|
|
||||||
self._stream_mgr = None
|
|
||||||
|
|
||||||
# Connect window close to cleanup
|
|
||||||
self.connect("close-request", self._on_close)
|
self.connect("close-request", self._on_close)
|
||||||
log.info("Window initialized")
|
log.info("Window initialized")
|
||||||
|
|
||||||
@@ -71,124 +73,134 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._connect_btn.add_css_class("destructive-action")
|
self._connect_btn.add_css_class("destructive-action")
|
||||||
self._streaming = True
|
self._streaming = True
|
||||||
|
|
||||||
|
# Create session
|
||||||
self._stream_mgr = StreamManager()
|
self._stream_mgr = StreamManager()
|
||||||
log.info("Session: %s", self._stream_mgr.session_id)
|
|
||||||
self._stream_mgr.setup_dirs()
|
self._stream_mgr.setup_dirs()
|
||||||
|
|
||||||
# 1. ffmpeg receives TCP and writes growing recording.ts
|
# Start ffmpeg recorder (listens for sender, relays to UDP)
|
||||||
self._stream_mgr.start_recorder()
|
self._stream_mgr.start_recorder()
|
||||||
log.info("Recorder started, waiting for sender...")
|
|
||||||
|
|
||||||
# 2. mpv plays the recording file (DVR: live edge + scrub)
|
# Tell monitor where the recording will be and what URL to stream live from
|
||||||
# Small delay to let ffmpeg create the file
|
self._monitor.set_recording(self._stream_mgr.recording_path)
|
||||||
GLib.timeout_add(2000, self._start_playback)
|
self._monitor.set_live_source(self._stream_mgr.relay_url)
|
||||||
|
|
||||||
# 3. ffmpeg scene detection runs periodically on the recording
|
# Start tracking recording duration
|
||||||
self._stream_mgr.start_scene_detector()
|
self._tracker = RecordingTracker(
|
||||||
log.info("Scene detector started")
|
self._stream_mgr.recording_path,
|
||||||
|
on_duration_update=self._on_duration_update,
|
||||||
|
)
|
||||||
|
self._tracker.start()
|
||||||
|
|
||||||
# 4. Poll for new frames and show thumbnails
|
# Go LIVE after a short delay — ffmpeg needs time to establish TCP
|
||||||
self._known_frames = set()
|
# and begin writing both outputs. UDP relay starts immediately after.
|
||||||
|
GLib.timeout_add(4000, self._go_live_once)
|
||||||
|
|
||||||
|
# Start scene detection
|
||||||
|
self._stream_mgr.start_scene_detector(on_new_frames=self._on_new_scene_frames)
|
||||||
|
|
||||||
|
# Start polling for frame thumbnails
|
||||||
GLib.timeout_add(3000, self._poll_frames)
|
GLib.timeout_add(3000, self._poll_frames)
|
||||||
|
|
||||||
def _start_playback(self):
|
# Tick the LIVE cursor every second
|
||||||
"""Start mpv playback once recording file exists."""
|
GLib.timeout_add(1000, self._tick_live)
|
||||||
if self._stream_mgr and self._stream_mgr.recording_path.exists():
|
|
||||||
size = self._stream_mgr.recording_path.stat().st_size
|
log.info("Waiting for sender...")
|
||||||
if size > 10_000:
|
|
||||||
self._monitor.start_recording(self._stream_mgr.recording_path)
|
def _go_live_once(self):
|
||||||
log.info("Playback started")
|
"""Called once after startup delay — go LIVE."""
|
||||||
return False # stop timer
|
if self._stream_mgr:
|
||||||
log.info("Waiting for recording data...")
|
log.info("Going LIVE (startup delay elapsed)")
|
||||||
return True # retry
|
self._timeline.go_live()
|
||||||
|
return False # one-shot
|
||||||
|
|
||||||
|
def _tick_live(self):
|
||||||
|
"""Tick cursor in LIVE mode so timer advances smoothly."""
|
||||||
|
if not self._streaming:
|
||||||
|
return False
|
||||||
|
self._timeline.tick_live()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _on_duration_update(self, duration):
|
||||||
|
"""Called from RecordingTracker thread."""
|
||||||
|
GLib.idle_add(self._timeline.set_duration, duration)
|
||||||
|
|
||||||
|
def _on_new_scene_frames(self, frames):
|
||||||
|
"""Called from scene detector thread when new frames are found."""
|
||||||
|
for f in frames:
|
||||||
|
GLib.idle_add(self._timeline.add_scene_marker, f["timestamp"])
|
||||||
|
|
||||||
|
def _on_live_toggle(self):
|
||||||
|
"""LIVE button handler — passes the live player's current position."""
|
||||||
|
pos = self._monitor.get_live_position()
|
||||||
|
self._timeline.toggle_live(live_player_pos=pos)
|
||||||
|
|
||||||
def _stop_stream(self):
|
def _stop_stream(self):
|
||||||
log.info("Stopping stream...")
|
log.info("Stopping stream...")
|
||||||
|
self._timeline.reset()
|
||||||
self._monitor.stop()
|
self._monitor.stop()
|
||||||
log.info("Monitor stopped")
|
if self._tracker:
|
||||||
|
self._tracker.stop()
|
||||||
|
self._tracker = None
|
||||||
if self._stream_mgr:
|
if self._stream_mgr:
|
||||||
self._stream_mgr.stop_all()
|
self._stream_mgr.stop_all()
|
||||||
log.info("Stream manager stopped")
|
|
||||||
self._stream_mgr = None
|
self._stream_mgr = None
|
||||||
|
self._known_frames = set()
|
||||||
|
|
||||||
self._connect_btn.set_label("Connect")
|
self._connect_btn.set_label("Connect")
|
||||||
self._connect_btn.remove_css_class("destructive-action")
|
self._connect_btn.remove_css_class("destructive-action")
|
||||||
self._connect_btn.add_css_class("suggested-action")
|
self._connect_btn.add_css_class("suggested-action")
|
||||||
self._streaming = False
|
self._streaming = False
|
||||||
log.info("Stream stopped, ready to reconnect")
|
|
||||||
|
|
||||||
def _on_close(self, *args):
|
def _on_close(self, *args):
|
||||||
log.info("Window closing, cleaning up...")
|
self._stop_stream()
|
||||||
self._monitor.stop()
|
|
||||||
if self._stream_mgr:
|
|
||||||
self._stream_mgr.stop_all()
|
|
||||||
|
|
||||||
def _build_agent_output(self):
|
# -- Right panels --
|
||||||
"""Left panel: agent output log."""
|
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
||||||
|
|
||||||
label = Gtk.Label(label="Agent Output")
|
|
||||||
label.add_css_class("heading")
|
|
||||||
label.set_margin_top(8)
|
|
||||||
label.set_margin_bottom(8)
|
|
||||||
box.append(label)
|
|
||||||
|
|
||||||
self._agent_output_view = Gtk.TextView()
|
|
||||||
self._agent_output_view.set_editable(False)
|
|
||||||
self._agent_output_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
|
||||||
self._agent_output_view.set_cursor_visible(False)
|
|
||||||
self._agent_output_view.set_left_margin(8)
|
|
||||||
self._agent_output_view.set_right_margin(8)
|
|
||||||
self._agent_output_view.set_top_margin(4)
|
|
||||||
self._agent_output_view.set_bottom_margin(4)
|
|
||||||
|
|
||||||
scroll = Gtk.ScrolledWindow()
|
|
||||||
scroll.set_vexpand(True)
|
|
||||||
scroll.set_child(self._agent_output_view)
|
|
||||||
box.append(scroll)
|
|
||||||
|
|
||||||
frame = Gtk.Frame()
|
|
||||||
frame.set_child(box)
|
|
||||||
return frame
|
|
||||||
|
|
||||||
def _build_right_panels(self):
|
def _build_right_panels(self):
|
||||||
right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||||
|
|
||||||
|
# Top row: player + waveform placeholder
|
||||||
top_paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
|
top_paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
|
||||||
top_paned.set_shrink_start_child(False)
|
top_paned.set_shrink_start_child(False)
|
||||||
top_paned.set_shrink_end_child(False)
|
top_paned.set_shrink_end_child(False)
|
||||||
|
|
||||||
self._monitor = MonitorWidget()
|
self._monitor = MonitorWidget(self._timeline)
|
||||||
self._monitor.set_hexpand(True)
|
self._monitor.set_hexpand(True)
|
||||||
stream_frame = Gtk.Frame()
|
stream_frame = Gtk.Frame()
|
||||||
stream_frame.set_child(self._monitor)
|
stream_frame.set_child(self._monitor)
|
||||||
top_paned.set_start_child(stream_frame)
|
top_paned.set_start_child(stream_frame)
|
||||||
|
|
||||||
self._waveform_area = self._build_panel("Waveform", height=250, width=200)
|
self._waveform_area = self._build_placeholder("Waveform", height=250, width=200)
|
||||||
top_paned.set_end_child(self._waveform_area)
|
top_paned.set_end_child(self._waveform_area)
|
||||||
|
|
||||||
top_paned.set_position(650)
|
top_paned.set_position(650)
|
||||||
right_box.append(top_paned)
|
right_box.append(top_paned)
|
||||||
|
|
||||||
|
# Shared timeline slider (spans under player + waveform)
|
||||||
|
self._timeline_controls = TimelineControls(self._timeline)
|
||||||
|
self._timeline_controls.set_live_toggle_callback(self._on_live_toggle)
|
||||||
|
right_box.append(self._timeline_controls)
|
||||||
|
|
||||||
|
# Frames extracted
|
||||||
self._frames_panel = self._build_frames_panel()
|
self._frames_panel = self._build_frames_panel()
|
||||||
right_box.append(self._frames_panel)
|
right_box.append(self._frames_panel)
|
||||||
|
|
||||||
|
# Transcript
|
||||||
self._transcript_panel = self._build_transcript_panel()
|
self._transcript_panel = self._build_transcript_panel()
|
||||||
right_box.append(self._transcript_panel)
|
right_box.append(self._transcript_panel)
|
||||||
|
|
||||||
|
# Agent input
|
||||||
self._agent_input = self._build_agent_input()
|
self._agent_input = self._build_agent_input()
|
||||||
right_box.append(self._agent_input)
|
right_box.append(self._agent_input)
|
||||||
|
|
||||||
return right_box
|
return right_box
|
||||||
|
|
||||||
def _build_panel(self, title, height=200, width=-1):
|
def _build_placeholder(self, title, height=200, width=-1):
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
label = Gtk.Label(label=title)
|
label = Gtk.Label(label=title)
|
||||||
label.add_css_class("heading")
|
label.add_css_class("heading")
|
||||||
label.set_margin_top(4)
|
label.set_margin_top(4)
|
||||||
label.set_margin_bottom(4)
|
label.set_margin_bottom(4)
|
||||||
box.append(label)
|
box.append(label)
|
||||||
|
|
||||||
area = Gtk.DrawingArea()
|
area = Gtk.DrawingArea()
|
||||||
area.set_content_height(height)
|
area.set_content_height(height)
|
||||||
if width > 0:
|
if width > 0:
|
||||||
@@ -196,7 +208,6 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
area.set_vexpand(False)
|
area.set_vexpand(False)
|
||||||
area.set_hexpand(True)
|
area.set_hexpand(True)
|
||||||
box.append(area)
|
box.append(area)
|
||||||
|
|
||||||
frame = Gtk.Frame()
|
frame = Gtk.Frame()
|
||||||
frame.set_child(box)
|
frame.set_child(box)
|
||||||
return frame
|
return frame
|
||||||
@@ -209,18 +220,18 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
label.set_margin_bottom(4)
|
label.set_margin_bottom(4)
|
||||||
box.append(label)
|
box.append(label)
|
||||||
|
|
||||||
self._frames_flow = Gtk.FlowBox()
|
# Horizontal scrolling strip — storyboard style
|
||||||
self._frames_flow.set_orientation(Gtk.Orientation.HORIZONTAL)
|
self._frames_scroll = Gtk.ScrolledWindow()
|
||||||
self._frames_flow.set_max_children_per_line(20)
|
self._frames_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER)
|
||||||
self._frames_flow.set_min_children_per_line(1)
|
self._frames_scroll.set_min_content_height(180) # 144px thumb + label + padding
|
||||||
self._frames_flow.set_selection_mode(Gtk.SelectionMode.MULTIPLE)
|
|
||||||
self._frames_flow.set_homogeneous(True)
|
|
||||||
|
|
||||||
scroll = Gtk.ScrolledWindow()
|
self._frames_strip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||||
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
self._frames_strip.set_margin_start(4)
|
||||||
scroll.set_min_content_height(120)
|
self._frames_strip.set_margin_end(4)
|
||||||
scroll.set_child(self._frames_flow)
|
self._frames_strip.set_margin_top(4)
|
||||||
box.append(scroll)
|
self._frames_strip.set_margin_bottom(4)
|
||||||
|
self._frames_scroll.set_child(self._frames_strip)
|
||||||
|
box.append(self._frames_scroll)
|
||||||
|
|
||||||
frame = Gtk.Frame()
|
frame = Gtk.Frame()
|
||||||
frame.set_child(box)
|
frame.set_child(box)
|
||||||
@@ -251,6 +262,32 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
frame.set_child(box)
|
frame.set_child(box)
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
|
# -- Agent panels --
|
||||||
|
|
||||||
|
def _build_agent_output(self):
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||||
|
label = Gtk.Label(label="Agent Output")
|
||||||
|
label.add_css_class("heading")
|
||||||
|
label.set_margin_top(8)
|
||||||
|
label.set_margin_bottom(8)
|
||||||
|
box.append(label)
|
||||||
|
|
||||||
|
self._agent_output_view = Gtk.TextView()
|
||||||
|
self._agent_output_view.set_editable(False)
|
||||||
|
self._agent_output_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
||||||
|
self._agent_output_view.set_cursor_visible(False)
|
||||||
|
self._agent_output_view.set_left_margin(8)
|
||||||
|
self._agent_output_view.set_right_margin(8)
|
||||||
|
|
||||||
|
scroll = Gtk.ScrolledWindow()
|
||||||
|
scroll.set_vexpand(True)
|
||||||
|
scroll.set_child(self._agent_output_view)
|
||||||
|
box.append(scroll)
|
||||||
|
|
||||||
|
frame = Gtk.Frame()
|
||||||
|
frame.set_child(box)
|
||||||
|
return frame
|
||||||
|
|
||||||
def _build_agent_input(self):
|
def _build_agent_input(self):
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||||
box.set_margin_start(4)
|
box.set_margin_start(4)
|
||||||
@@ -261,45 +298,29 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._input_entry = Gtk.Entry()
|
self._input_entry = Gtk.Entry()
|
||||||
self._input_entry.set_hexpand(True)
|
self._input_entry.set_hexpand(True)
|
||||||
self._input_entry.set_placeholder_text("Message agent... (use @ to reference frames/transcripts)")
|
self._input_entry.set_placeholder_text("Message agent... (use @ to reference frames/transcripts)")
|
||||||
self._input_entry.connect("activate", self._on_input_activate)
|
self._input_entry.connect("activate", lambda e: self._send_message())
|
||||||
box.append(self._input_entry)
|
box.append(self._input_entry)
|
||||||
|
|
||||||
send_btn = Gtk.Button(label="Send")
|
send_btn = Gtk.Button(label="Send")
|
||||||
send_btn.add_css_class("suggested-action")
|
send_btn.add_css_class("suggested-action")
|
||||||
send_btn.connect("clicked", self._on_send_clicked)
|
send_btn.connect("clicked", lambda b: self._send_message())
|
||||||
box.append(send_btn)
|
box.append(send_btn)
|
||||||
|
|
||||||
frame = Gtk.Frame()
|
frame = Gtk.Frame()
|
||||||
frame.set_child(box)
|
frame.set_child(box)
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
def _on_input_activate(self, entry):
|
|
||||||
self._send_message()
|
|
||||||
|
|
||||||
def _on_send_clicked(self, button):
|
|
||||||
self._send_message()
|
|
||||||
|
|
||||||
def _send_message(self):
|
def _send_message(self):
|
||||||
text = self._input_entry.get_text().strip()
|
text = self._input_entry.get_text().strip()
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
buf = self._agent_output_view.get_buffer()
|
buf = self._agent_output_view.get_buffer()
|
||||||
end_iter = buf.get_end_iter()
|
buf.insert(buf.get_end_iter(), f"\n> {text}\n")
|
||||||
buf.insert(end_iter, f"\n> {text}\n")
|
|
||||||
self._input_entry.set_text("")
|
self._input_entry.set_text("")
|
||||||
|
|
||||||
def append_agent_output(self, text):
|
# -- Frame thumbnails --
|
||||||
buf = self._agent_output_view.get_buffer()
|
|
||||||
end_iter = buf.get_end_iter()
|
|
||||||
buf.insert(end_iter, text + "\n")
|
|
||||||
|
|
||||||
def append_transcript(self, entry_id, text):
|
|
||||||
buf = self._transcript_view.get_buffer()
|
|
||||||
end_iter = buf.get_end_iter()
|
|
||||||
buf.insert(end_iter, f"[{entry_id}] {text}\n")
|
|
||||||
|
|
||||||
def _poll_frames(self):
|
def _poll_frames(self):
|
||||||
"""Check for new extracted frames and add thumbnails."""
|
|
||||||
if not self._stream_mgr:
|
if not self._stream_mgr:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -308,8 +329,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(index_path) as f:
|
index = json.loads(index_path.read_text())
|
||||||
index = json.load(f)
|
|
||||||
except (json.JSONDecodeError, IOError):
|
except (json.JSONDecodeError, IOError):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -322,31 +342,39 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
self._known_frames.add(fid)
|
self._known_frames.add(fid)
|
||||||
|
timestamp = entry.get("timestamp", 0)
|
||||||
try:
|
try:
|
||||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(fpath), 256, 144, True)
|
||||||
str(fpath), 160, 90, True
|
self._add_frame_thumbnail(fid, pixbuf, timestamp)
|
||||||
)
|
|
||||||
self._add_frame_thumbnail(fid, pixbuf, entry.get("timestamp"))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Failed to load thumbnail for %s: %s", fid, e)
|
log.warning("Thumbnail load failed for %s: %s", fid, e)
|
||||||
|
|
||||||
return True # keep polling
|
return True
|
||||||
|
|
||||||
def _add_frame_thumbnail(self, frame_id, pixbuf, timestamp=None):
|
def _add_frame_thumbnail(self, frame_id, pixbuf, timestamp):
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||||
|
box.set_size_request(256, -1)
|
||||||
|
|
||||||
img = Gtk.Image.new_from_pixbuf(pixbuf)
|
img = Gtk.Image.new_from_pixbuf(pixbuf)
|
||||||
|
img.set_size_request(256, 144)
|
||||||
|
img.set_vexpand(False)
|
||||||
box.append(img)
|
box.append(img)
|
||||||
|
|
||||||
label_text = frame_id
|
m, s = divmod(int(timestamp), 60)
|
||||||
if timestamp is not None:
|
label = Gtk.Label(label=f"{frame_id} [{m:02d}:{s:02d}]")
|
||||||
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")
|
||||||
label.set_ellipsize(Pango.EllipsizeMode.END)
|
label.set_ellipsize(Pango.EllipsizeMode.END)
|
||||||
box.append(label)
|
box.append(label)
|
||||||
|
|
||||||
self._frames_flow.append(box)
|
# Click to seek into scrub mode
|
||||||
log.info("Added thumbnail: %s", frame_id)
|
gesture = Gtk.GestureClick()
|
||||||
|
gesture.connect("released", lambda g, n, x, y: self._timeline.seek(timestamp))
|
||||||
|
box.add_controller(gesture)
|
||||||
|
|
||||||
|
self._frames_strip.append(box)
|
||||||
|
|
||||||
|
# Auto-scroll to show the latest frame
|
||||||
|
adj = self._frames_scroll.get_hadjustment()
|
||||||
|
GLib.idle_add(lambda: adj.set_value(adj.get_upper()) or False)
|
||||||
|
|
||||||
|
log.info("Thumbnail: %s at %.1fs", frame_id, timestamp)
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ class TestReceiveAndRecord:
|
|||||||
)
|
)
|
||||||
assert "-hide_banner" in node.compile()
|
assert "-hide_banner" in node.compile()
|
||||||
|
|
||||||
def test_mpegts_format(self, tmp_path):
|
def test_matroska_format(self, tmp_path):
|
||||||
node = ff.receive_and_record(
|
node = ff.receive_and_record(
|
||||||
"tcp://0.0.0.0:4444?listen", tmp_path / "rec.ts"
|
"tcp://0.0.0.0:4444?listen", tmp_path / "rec.mkv"
|
||||||
)
|
)
|
||||||
assert "mpegts" in node.compile()
|
assert "matroska" in node.compile()
|
||||||
|
|
||||||
|
|
||||||
class TestExtractSceneFrames:
|
class TestExtractSceneFrames:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for cht.stream.manager — StreamManager orchestration."""
|
"""Tests for cht.stream.manager — StreamManager."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
@@ -18,14 +18,12 @@ def manager(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
class TestInit:
|
class TestInit:
|
||||||
def test_session_id_default(self, tmp_path):
|
|
||||||
with patch("cht.stream.manager.SESSIONS_DIR", tmp_path):
|
|
||||||
mgr = StreamManager()
|
|
||||||
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"
|
||||||
|
|
||||||
|
def test_recording_path(self, manager):
|
||||||
|
assert manager.recording_path.name == "recording.mkv"
|
||||||
|
|
||||||
def test_dirs_not_created_on_init(self, manager):
|
def test_dirs_not_created_on_init(self, manager):
|
||||||
assert not manager.stream_dir.exists()
|
assert not manager.stream_dir.exists()
|
||||||
|
|
||||||
@@ -38,52 +36,25 @@ class TestSetupDirs:
|
|||||||
assert manager.transcript_dir.is_dir()
|
assert manager.transcript_dir.is_dir()
|
||||||
assert manager.agent_dir.is_dir()
|
assert manager.agent_dir.is_dir()
|
||||||
|
|
||||||
def test_idempotent(self, manager):
|
|
||||||
manager.setup_dirs()
|
|
||||||
manager.setup_dirs()
|
|
||||||
assert manager.stream_dir.is_dir()
|
|
||||||
|
|
||||||
|
|
||||||
class TestStreamUrl:
|
|
||||||
def test_default_url(self, manager):
|
|
||||||
assert "0.0.0.0" in manager.stream_url
|
|
||||||
assert "4444" 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_record")
|
@patch("cht.stream.manager.ff.receive_and_record")
|
||||||
def test_calls_ffmpeg_module(self, mock_record, mock_async, manager):
|
def test_starts_ffmpeg(self, mock_record, mock_async, manager):
|
||||||
manager.setup_dirs()
|
manager.setup_dirs()
|
||||||
mock_node = MagicMock()
|
mock_record.return_value = MagicMock()
|
||||||
mock_record.return_value = mock_node
|
|
||||||
|
|
||||||
manager.start_recorder()
|
manager.start_recorder()
|
||||||
|
mock_record.assert_called_once_with(manager.stream_url, manager.recording_path)
|
||||||
mock_record.assert_called_once_with(
|
|
||||||
manager.stream_url, manager.recording_path,
|
|
||||||
)
|
|
||||||
mock_async.assert_called_once_with(mock_node, pipe_stderr=True)
|
|
||||||
assert "recorder" in manager._procs
|
assert "recorder" in manager._procs
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
proc1, proc2 = MagicMock(), MagicMock()
|
proc = MagicMock()
|
||||||
manager._procs = {"a": proc1, "b": proc2}
|
manager._procs = {"recorder": proc}
|
||||||
|
|
||||||
manager.stop_all()
|
manager.stop_all()
|
||||||
|
mock_stop.assert_called_with(proc)
|
||||||
mock_stop.assert_any_call(proc1)
|
|
||||||
mock_stop.assert_any_call(proc2)
|
|
||||||
assert len(manager._procs) == 0
|
assert len(manager._procs) == 0
|
||||||
|
|
||||||
def test_sets_stop_flag(self, manager):
|
def test_sets_stop_flag(self, manager):
|
||||||
@@ -91,89 +62,41 @@ class TestStopAll:
|
|||||||
assert "stop" in manager._stop_flags
|
assert "stop" in manager._stop_flags
|
||||||
|
|
||||||
|
|
||||||
class TestExtractNewFrames:
|
class TestDetectScenes:
|
||||||
@patch("cht.stream.manager.ff.extract_scene_frames")
|
@patch("cht.stream.manager.ff.extract_scene_frames")
|
||||||
def test_calls_ffmpeg_with_start_time(self, mock_extract, manager):
|
def test_returns_new_frames(self, mock_extract, manager):
|
||||||
manager.setup_dirs()
|
manager.setup_dirs()
|
||||||
rec = manager.recording_path
|
rec = manager.recording_path
|
||||||
rec.touch()
|
rec.touch()
|
||||||
|
|
||||||
mock_extract.return_value = ("", "")
|
def create_frame(*args, **kwargs):
|
||||||
manager._extract_new_frames(rec, start_time=10.0, start_number=5)
|
|
||||||
|
|
||||||
mock_extract.assert_called_once_with(
|
|
||||||
rec,
|
|
||||||
manager.frames_dir,
|
|
||||||
scene_threshold=0.3,
|
|
||||||
max_interval=30,
|
|
||||||
start_number=5,
|
|
||||||
start_time=10.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("cht.stream.manager.ff.extract_scene_frames")
|
|
||||||
def test_indexes_new_frames(self, mock_extract, manager):
|
|
||||||
manager.setup_dirs()
|
|
||||||
rec = manager.recording_path
|
|
||||||
rec.touch()
|
|
||||||
|
|
||||||
# Simulate ffmpeg creating a frame file during extraction
|
|
||||||
def create_frame_and_return(*args, **kwargs):
|
|
||||||
(manager.frames_dir / "F0001.jpg").touch()
|
(manager.frames_dir / "F0001.jpg").touch()
|
||||||
return ("", "[Parsed_showinfo_1 @ 0x1] n:0 pts:1000 pts_time:10.5 stuff\n")
|
return ("", "[Parsed_showinfo_1 @ 0x1] n:0 pts:100 pts_time:10.5 stuff\n")
|
||||||
|
|
||||||
mock_extract.side_effect = create_frame_and_return
|
mock_extract.side_effect = create_frame
|
||||||
|
|
||||||
count, max_ts = manager._extract_new_frames(rec, start_number=1)
|
frames = manager._detect_scenes(start_time=0, end_time=15, start_number=1)
|
||||||
assert count == 1
|
assert len(frames) == 1
|
||||||
assert max_ts == 10.5
|
assert frames[0]["id"] == "F0001"
|
||||||
|
assert frames[0]["timestamp"] == 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_passes_duration(self, mock_extract, manager):
|
||||||
manager.setup_dirs()
|
|
||||||
rec = manager.recording_path
|
|
||||||
rec.touch()
|
|
||||||
|
|
||||||
mock_extract.side_effect = RuntimeError("ffmpeg died")
|
|
||||||
count, max_ts = manager._extract_new_frames(rec)
|
|
||||||
assert count == 0
|
|
||||||
|
|
||||||
@patch("cht.stream.manager.ff.extract_scene_frames")
|
|
||||||
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()
|
||||||
|
manager.recording_path.touch()
|
||||||
mock_extract.return_value = ("", "")
|
mock_extract.return_value = ("", "")
|
||||||
|
|
||||||
# Create recording with some data
|
manager._detect_scenes(start_time=10, end_time=25, start_number=1)
|
||||||
rec = manager.recording_path
|
|
||||||
rec.write_bytes(b"\x00" * 200_000)
|
|
||||||
|
|
||||||
manager.start_scene_detector()
|
call_kwargs = mock_extract.call_args
|
||||||
time.sleep(12) # wait for one cycle
|
assert call_kwargs.kwargs["start_time"] == 10
|
||||||
manager.stop_all()
|
assert call_kwargs.kwargs["duration"] == 15
|
||||||
|
|
||||||
mock_extract.assert_called()
|
@patch("cht.stream.manager.ff.extract_scene_frames")
|
||||||
|
def test_handles_failure(self, mock_extract, manager):
|
||||||
|
manager.setup_dirs()
|
||||||
|
manager.recording_path.touch()
|
||||||
|
mock_extract.side_effect = RuntimeError("boom")
|
||||||
|
|
||||||
|
frames = manager._detect_scenes(start_time=0, end_time=10, start_number=1)
|
||||||
|
assert frames == []
|
||||||
|
|||||||
47
tests/test_timeline.py
Normal file
47
tests/test_timeline.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Tests for cht.ui.timeline — Timeline state machine."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# Skip GTK import for headless testing
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock as _MM
|
||||||
|
|
||||||
|
# Mock gi modules for headless testing
|
||||||
|
gi_mock = _MM()
|
||||||
|
gi_mock.require_version = _MM()
|
||||||
|
gtk_mock = _MM()
|
||||||
|
gobject_mock = _MM()
|
||||||
|
|
||||||
|
# GObject.Object needs __gsignals__ support
|
||||||
|
class FakeGObject:
|
||||||
|
def __init__(self):
|
||||||
|
self._signals = {}
|
||||||
|
def emit(self, signal, *args):
|
||||||
|
for cb in self._signals.get(signal, []):
|
||||||
|
cb(self, *args)
|
||||||
|
def connect(self, signal, cb):
|
||||||
|
self._signals.setdefault(signal, []).append(cb)
|
||||||
|
|
||||||
|
# We test the logic, not the GTK widgets
|
||||||
|
# Import after mocking would be complex, so test the state logic directly
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cht.ui.timeline import State
|
||||||
|
HAS_GI = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_GI = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not HAS_GI, reason="GTK/gi not available")
|
||||||
|
class TestTimelineState:
|
||||||
|
def test_initial_state(self):
|
||||||
|
assert State.WAITING.name == "WAITING"
|
||||||
|
assert State.LIVE.name == "LIVE"
|
||||||
|
assert State.PLAYING.name == "PLAYING"
|
||||||
|
assert State.PAUSED.name == "PAUSED"
|
||||||
|
|
||||||
|
def test_state_values_are_unique(self):
|
||||||
|
values = [s.value for s in State]
|
||||||
|
assert len(values) == len(set(values))
|
||||||
38
tests/test_tracker.py
Normal file
38
tests/test_tracker.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Tests for cht.stream.tracker — RecordingTracker."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cht.stream.tracker import RecordingTracker
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecordingTracker:
|
||||||
|
def test_initial_duration_is_zero(self, tmp_path):
|
||||||
|
tracker = RecordingTracker(tmp_path / "rec.ts")
|
||||||
|
assert tracker.duration == 0.0
|
||||||
|
|
||||||
|
def test_callback_called_on_update(self, tmp_path):
|
||||||
|
rec = tmp_path / "rec.ts"
|
||||||
|
rec.write_bytes(b"\x00" * 100_000)
|
||||||
|
|
||||||
|
cb = MagicMock()
|
||||||
|
tracker = RecordingTracker(rec, on_duration_update=cb)
|
||||||
|
|
||||||
|
with patch.object(tracker, "_probe_duration", return_value=10.0):
|
||||||
|
tracker.start()
|
||||||
|
time.sleep(3)
|
||||||
|
tracker.stop()
|
||||||
|
|
||||||
|
cb.assert_called()
|
||||||
|
assert cb.call_args[0][0] > 0
|
||||||
|
|
||||||
|
def test_no_callback_if_file_missing(self, tmp_path):
|
||||||
|
cb = MagicMock()
|
||||||
|
tracker = RecordingTracker(tmp_path / "nonexistent.ts", on_duration_update=cb)
|
||||||
|
tracker.start()
|
||||||
|
time.sleep(3)
|
||||||
|
tracker.stop()
|
||||||
|
cb.assert_not_called()
|
||||||
Reference in New Issue
Block a user