good progress
This commit is contained in:
@@ -40,6 +40,8 @@ class StreamManager:
|
||||
self._procs = {}
|
||||
self._threads = {}
|
||||
self._stop_flags = set()
|
||||
self._segment = 0
|
||||
self.scene_threshold = SCENE_THRESHOLD
|
||||
log.info("Session: %s", session_id)
|
||||
|
||||
def setup_dirs(self):
|
||||
@@ -56,12 +58,36 @@ class StreamManager:
|
||||
|
||||
@property
|
||||
def recording_path(self):
|
||||
return self.stream_dir / "recording.mp4"
|
||||
"""Current recording segment path."""
|
||||
return self.stream_dir / f"recording_{self._segment:03d}.mp4"
|
||||
|
||||
@property
|
||||
def recording_segments(self):
|
||||
"""All recording segments in order."""
|
||||
return sorted(self.stream_dir.glob("recording_*.mp4"))
|
||||
|
||||
# -- Recording --
|
||||
|
||||
def start_recorder(self):
|
||||
"""Start ffmpeg to receive TCP stream, write to MKV, and relay to UDP."""
|
||||
"""Start ffmpeg to receive TCP stream, write to fMP4, and relay to UDP."""
|
||||
self._segment = 0
|
||||
self._launch_recorder()
|
||||
|
||||
def restart_recorder(self):
|
||||
"""Restart recorder into a new segment. Session stays alive."""
|
||||
old = self._procs.pop("recorder", None)
|
||||
if old:
|
||||
ff.stop_proc(old)
|
||||
self._segment += 1
|
||||
log.info("Restarting recorder → segment %d", self._segment)
|
||||
self._launch_recorder()
|
||||
|
||||
def recorder_alive(self):
|
||||
"""Check if the recorder process is still running."""
|
||||
proc = self._procs.get("recorder")
|
||||
return proc is not None and proc.poll() is None
|
||||
|
||||
def _launch_recorder(self):
|
||||
node = ff.receive_record_and_relay(self.stream_url, self.recording_path, self.relay_url)
|
||||
proc = ff.run_async(node, pipe_stderr=True)
|
||||
self._procs["recorder"] = proc
|
||||
@@ -80,27 +106,37 @@ class StreamManager:
|
||||
|
||||
def _detect():
|
||||
processed_time = 0.0
|
||||
frame_count = 0
|
||||
idle_cycles = 0 # consecutive cycles with no new frames
|
||||
idle_cycles = 0
|
||||
current_segment = None
|
||||
|
||||
while "stop" not in self._stop_flags:
|
||||
# Adaptive sleep: 1s after finding frames, then 2→4→8→10s backoff
|
||||
sleep_secs = 1 if idle_cycles == 0 else min(2, 2 ** idle_cycles)
|
||||
# Adaptive sleep: faster at lower thresholds (more sensitive)
|
||||
# threshold 0.01→1s base, 0.10→1s, 0.50→2s
|
||||
base = max(1.0, min(2.0, self.scene_threshold * 10))
|
||||
sleep_secs = base if idle_cycles == 0 else min(base * 2, base * (2 ** idle_cycles))
|
||||
time.sleep(sleep_secs)
|
||||
|
||||
if not self.recording_path.exists():
|
||||
seg = self.recording_path
|
||||
if not seg.exists():
|
||||
continue
|
||||
|
||||
size = self.recording_path.stat().st_size
|
||||
# New segment started — reset per-segment progress
|
||||
if seg != current_segment:
|
||||
current_segment = seg
|
||||
processed_time = 0.0
|
||||
idle_cycles = 0
|
||||
log.info("Scene detector: switched to %s", seg.name)
|
||||
|
||||
size = seg.stat().st_size
|
||||
if size < 100_000:
|
||||
continue
|
||||
|
||||
# 2s safety margin — fragmented MP4 is valid up to last complete
|
||||
# keyframe fragment (~1 keyframe interval); 2s covers worst case.
|
||||
# Probe current segment duration directly (not total across segments)
|
||||
safe_duration = self._estimate_safe_duration()
|
||||
if safe_duration is None:
|
||||
if safe_duration is None or safe_duration <= 0:
|
||||
continue
|
||||
|
||||
# 2s safety margin for incomplete tail fragments
|
||||
process_to = safe_duration - 2
|
||||
if process_to <= processed_time + 0.5:
|
||||
continue
|
||||
@@ -109,17 +145,16 @@ class StreamManager:
|
||||
new_frames = self._detect_scenes(
|
||||
start_time=processed_time,
|
||||
end_time=process_to,
|
||||
start_number=frame_count + 1,
|
||||
)
|
||||
|
||||
if new_frames:
|
||||
frame_count += len(new_frames)
|
||||
idle_cycles = 0 # reset — check again quickly
|
||||
log.info("Found %d new scene frames (total: %d)", len(new_frames), frame_count)
|
||||
idle_cycles = 0
|
||||
log.info("Found %d new scene frames (total: %d)",
|
||||
len(new_frames), self._next_frame_number() - 1)
|
||||
if self._on_new_frames:
|
||||
self._on_new_frames(new_frames)
|
||||
else:
|
||||
idle_cycles += 1 # back off: 2s, 4s, 8s, 10s
|
||||
idle_cycles += 1
|
||||
|
||||
processed_time = process_to
|
||||
|
||||
@@ -157,16 +192,24 @@ class StreamManager:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _detect_scenes(self, start_time, end_time, start_number):
|
||||
def _next_frame_number(self):
|
||||
"""Determine next frame number from the index (source of truth)."""
|
||||
index_path = self.frames_dir / "index.json"
|
||||
if index_path.exists():
|
||||
index = json.loads(index_path.read_text())
|
||||
return len(index) + 1
|
||||
return 1
|
||||
|
||||
def _detect_scenes(self, start_time, end_time):
|
||||
"""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"))
|
||||
start_number = self._next_frame_number()
|
||||
|
||||
try:
|
||||
_stdout, stderr = ff.extract_scene_frames(
|
||||
self.recording_path,
|
||||
self.frames_dir,
|
||||
scene_threshold=SCENE_THRESHOLD,
|
||||
scene_threshold=self.scene_threshold,
|
||||
start_number=start_number,
|
||||
start_time=start_time,
|
||||
duration=duration,
|
||||
@@ -175,7 +218,8 @@ class StreamManager:
|
||||
log.error("Scene detection failed: %s", e)
|
||||
return []
|
||||
|
||||
# Parse new frames from showinfo output
|
||||
# Parse new frames from showinfo output — match each showinfo line
|
||||
# to the corresponding file ffmpeg wrote (sequential from start_number)
|
||||
new_frames = []
|
||||
index_path = self.frames_dir / "index.json"
|
||||
index = json.loads(index_path.read_text()) if index_path.exists() else []
|
||||
@@ -189,7 +233,7 @@ class StreamManager:
|
||||
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:
|
||||
if frame_path.exists():
|
||||
entry = {
|
||||
"id": frame_id,
|
||||
"timestamp": pts_time,
|
||||
@@ -198,11 +242,55 @@ class StreamManager:
|
||||
}
|
||||
index.append(entry)
|
||||
new_frames.append(entry)
|
||||
frame_num += 1
|
||||
frame_num += 1
|
||||
|
||||
index_path.write_text(json.dumps(index, indent=2))
|
||||
return new_frames
|
||||
|
||||
def capture_now(self, on_new_frames=None):
|
||||
"""Capture a single frame from the current recording position.
|
||||
|
||||
Grabs the latest available frame (safe_duration - 1s) and adds it
|
||||
to the index. Runs in a thread to avoid blocking the UI.
|
||||
"""
|
||||
def _capture():
|
||||
safe_duration = self._estimate_safe_duration()
|
||||
if not safe_duration or safe_duration < 1:
|
||||
log.warning("capture_now: recording too short")
|
||||
return
|
||||
|
||||
timestamp = safe_duration - 1
|
||||
index_path = self.frames_dir / "index.json"
|
||||
index = json.loads(index_path.read_text()) if index_path.exists() else []
|
||||
frame_num = len(index) + 1
|
||||
frame_id = f"F{frame_num:04d}"
|
||||
frame_path = self.frames_dir / f"{frame_id}.jpg"
|
||||
|
||||
try:
|
||||
ff.extract_frame_at(self.recording_path, frame_path, timestamp)
|
||||
except Exception as e:
|
||||
log.error("capture_now failed: %s", e)
|
||||
return
|
||||
|
||||
if not frame_path.exists():
|
||||
log.warning("capture_now: frame not written")
|
||||
return
|
||||
|
||||
entry = {
|
||||
"id": frame_id,
|
||||
"timestamp": timestamp,
|
||||
"path": str(frame_path),
|
||||
"sent_to_agent": False,
|
||||
}
|
||||
index.append(entry)
|
||||
index_path.write_text(json.dumps(index, indent=2))
|
||||
log.info("Manual capture: %s at %.1fs", frame_id, timestamp)
|
||||
|
||||
if on_new_frames:
|
||||
on_new_frames([entry])
|
||||
|
||||
Thread(target=_capture, daemon=True, name="capture_now").start()
|
||||
|
||||
# -- Lifecycle --
|
||||
|
||||
def stop_all(self):
|
||||
|
||||
Reference in New Issue
Block a user