berarr
This commit is contained in:
@@ -40,15 +40,22 @@ def receive_and_record(stream_url, output_path):
|
||||
|
||||
|
||||
def receive_record_and_relay(stream_url, output_path, relay_url):
|
||||
"""Receive TCP stream, write to MKV, and relay to UDP loopback for live display.
|
||||
"""Receive TCP stream, write to fragmented MP4, and relay to UDP loopback.
|
||||
|
||||
Uses ffmpeg tee via merge_outputs: one ffmpeg process handles both outputs
|
||||
from the same decoded input, keeping them in sync with identical timestamps.
|
||||
Fragmented MP4 (frag_keyframe+empty_moov) avoids MKV tail corruption:
|
||||
each keyframe boundary closes a self-contained fragment, so the file is
|
||||
always valid up to the last complete fragment (~1 keyframe interval ≈ 2s).
|
||||
This allows the scene detector to use a 2s safety margin instead of 6s.
|
||||
|
||||
Uses ffmpeg tee via merge_outputs: one process, 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,
|
||||
c="copy", f="mp4",
|
||||
movflags="frag_keyframe+empty_moov+default_base_moof",
|
||||
flush_packets=1,
|
||||
**{"bsf:a": "aac_adtstoasc"},
|
||||
)
|
||||
relay_out = ffmpeg.output(
|
||||
stream, relay_url,
|
||||
|
||||
@@ -56,7 +56,7 @@ class StreamManager:
|
||||
|
||||
@property
|
||||
def recording_path(self):
|
||||
return self.stream_dir / "recording.mkv"
|
||||
return self.stream_dir / "recording.mp4"
|
||||
|
||||
# -- Recording --
|
||||
|
||||
@@ -81,9 +81,13 @@ class StreamManager:
|
||||
def _detect():
|
||||
processed_time = 0.0
|
||||
frame_count = 0
|
||||
idle_cycles = 0 # consecutive cycles with no new frames
|
||||
|
||||
while "stop" not in self._stop_flags:
|
||||
time.sleep(5)
|
||||
# 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)
|
||||
time.sleep(sleep_secs)
|
||||
|
||||
if not self.recording_path.exists():
|
||||
continue
|
||||
|
||||
@@ -91,16 +95,14 @@ class StreamManager:
|
||||
if size < 100_000:
|
||||
continue
|
||||
|
||||
# Get current duration. Use a 6s safety margin — MKV tail can
|
||||
# be corrupt for several seconds after the last flush, causing
|
||||
# ffmpeg to crash even with a 3s margin.
|
||||
# 2s safety margin — fragmented MP4 is valid up to last complete
|
||||
# keyframe fragment (~1 keyframe interval); 2s covers worst case.
|
||||
safe_duration = self._estimate_safe_duration()
|
||||
if safe_duration is None or safe_duration <= processed_time + 8:
|
||||
if safe_duration is None:
|
||||
continue
|
||||
|
||||
# Process from last checkpoint to safe point
|
||||
process_to = safe_duration - 6 # 6s safety margin for MKV tail
|
||||
if process_to <= processed_time:
|
||||
process_to = safe_duration - 2
|
||||
if process_to <= processed_time + 0.5:
|
||||
continue
|
||||
|
||||
log.info("Scene detection: %.1fs → %.1fs", processed_time, process_to)
|
||||
@@ -112,9 +114,12 @@ class StreamManager:
|
||||
|
||||
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)
|
||||
if self._on_new_frames:
|
||||
self._on_new_frames(new_frames)
|
||||
else:
|
||||
idle_cycles += 1 # back off: 2s, 4s, 8s, 10s
|
||||
|
||||
processed_time = process_to
|
||||
|
||||
@@ -125,13 +130,23 @@ class StreamManager:
|
||||
self._threads["scene_detector"] = t
|
||||
|
||||
def _estimate_safe_duration(self):
|
||||
"""Estimate recording duration. Uses ffprobe, falls back to file size."""
|
||||
"""Estimate recording duration. Uses ffprobe, falls back to file size.
|
||||
|
||||
For fragmented MP4 (empty_moov), format-level duration is 0 so we
|
||||
check stream duration from the last video stream instead.
|
||||
"""
|
||||
try:
|
||||
import ffmpeg as ffmpeg_lib
|
||||
info = ffmpeg_lib.probe(str(self.recording_path))
|
||||
# Format duration works for non-fragmented; 0 for empty_moov fMP4
|
||||
dur = float(info.get("format", {}).get("duration", 0))
|
||||
if dur > 0:
|
||||
return dur
|
||||
# Fragmented MP4: check video stream duration
|
||||
for stream in info.get("streams", []):
|
||||
sdur = float(stream.get("duration", 0))
|
||||
if sdur > 0:
|
||||
return sdur
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"""
|
||||
RecordingTracker: monitors the growing recording file and estimates duration.
|
||||
RecordingTracker: monitors the growing recording file and reports duration.
|
||||
|
||||
Polls file size periodically. Uses ffprobe occasionally for accurate
|
||||
duration calibration. Feeds duration updates to the Timeline.
|
||||
Probes with ffprobe every cycle. No bitrate estimation — initial burst frames
|
||||
make calibration unreliable. Falls back to file-size heuristic only when
|
||||
ffprobe returns nothing (e.g. file too new).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
|
||||
import ffmpeg as ffmpeg_lib
|
||||
@@ -18,13 +16,12 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecordingTracker:
|
||||
"""Tracks a growing recording file and estimates its duration."""
|
||||
"""Tracks a growing recording file and reports 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
|
||||
|
||||
@@ -43,9 +40,6 @@ class RecordingTracker:
|
||||
log.info("RecordingTracker stopped")
|
||||
|
||||
def _poll_loop(self):
|
||||
probe_interval = 0 # probe on first data
|
||||
cycles = 0
|
||||
|
||||
while not self._stop:
|
||||
time.sleep(2)
|
||||
|
||||
@@ -56,28 +50,31 @@ class RecordingTracker:
|
||||
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)
|
||||
duration = self._probe_duration()
|
||||
if duration and duration > self._duration:
|
||||
self._duration = duration
|
||||
log.info("Duration: %.1fs", duration)
|
||||
if self._on_duration:
|
||||
self._on_duration(self._duration)
|
||||
|
||||
def _probe_duration(self):
|
||||
"""Use ffprobe to get accurate duration of the recording."""
|
||||
"""Probe recording duration via ffprobe."""
|
||||
try:
|
||||
info = ffmpeg_lib.probe(str(self._path))
|
||||
duration = float(info.get("format", {}).get("duration", 0))
|
||||
return duration
|
||||
# Format-level duration is 0 for fragmented MP4 (empty_moov)
|
||||
dur = float(info.get("format", {}).get("duration", 0))
|
||||
if dur > 0:
|
||||
return dur
|
||||
# Fragmented MP4: check video stream duration
|
||||
for stream in info.get("streams", []):
|
||||
sdur = float(stream.get("duration", 0))
|
||||
if sdur > 0:
|
||||
return sdur
|
||||
except Exception as e:
|
||||
log.debug("ffprobe failed (file still growing): %s", e)
|
||||
log.debug("ffprobe failed: %s", e)
|
||||
|
||||
# Last resort: file size heuristic (~500kbps for this stream type)
|
||||
try:
|
||||
return self._path.stat().st_size / 65_000
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user