This commit is contained in:
2026-04-01 21:14:30 -03:00
parent 0f7e4424bc
commit 172f21a845
9 changed files with 139 additions and 106 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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