continuous stream
Build and Push Docker Images / build-and-push (push) Successful in 27s

This commit is contained in:
2026-02-11 21:21:04 +01:00
parent 9f87297d35
commit c49574cb93
+73 -7
View File
@@ -2,6 +2,7 @@
import json
import logging
import os
import subprocess
import tempfile
from pathlib import Path
@@ -142,6 +143,53 @@ def _escape_path(p: Path) -> str:
return str(p.resolve()).replace("\\", "\\\\").replace("'", "'\\''")
def _get_keyframe_at_or_before(path: Path, time_sec: float) -> float | None:
"""
Return the PTS (seconds) of the keyframe at or just before time_sec.
Returns None on failure (probe error, timeout, no keyframes).
Aligning cuts to keyframes avoids corrupt output at concat transitions.
"""
try:
out = subprocess.run(
[
"ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-skip_frame", "nokey",
"-show_entries", "frame=pkt_pts_time",
"-of", "csv=p=0",
str(path),
],
capture_output=True,
text=True,
timeout=15,
)
if out.returncode != 0 or not out.stdout:
return None
keyframes = []
for line in out.stdout.strip().splitlines():
line = line.strip()
if line:
try:
t = float(line)
keyframes.append(t)
except ValueError:
continue
if not keyframes:
return None
keyframes = sorted(set(keyframes))
# Last keyframe at or before time_sec
best = 0.0
for kf in keyframes:
if kf <= time_sec:
best = kf
else:
break
return best
except (subprocess.TimeoutExpired, ValueError, FileNotFoundError):
return None
def write_concat_list(paths: list[Path], fd) -> None:
"""Write FFmpeg concat demuxer list to file."""
for p in paths:
@@ -179,20 +227,38 @@ def _current_position_in_cycle(durations: list[float]) -> tuple[int, float]:
def write_concat_list_from_current(paths: list[Path], durations: list[float], fd) -> None:
"""
Write a concat list that starts at the current position in the schedule and
loops seamlessly. Uses inpoint/outpoint so we join mid-file, then repeat.
loops seamlessly. Uses inpoint/outpoint aligned to keyframes so we never
cut mid-GOP (which causes corrupt packets and stream crashes at transitions).
"""
n = len(paths)
if n == 0:
return
idx, offset_in_file = _current_position_in_cycle(durations)
# For testing: start first episode at last N minutes (env TEST_START_LAST_MINUTES=3)
try:
test_min = int(os.environ.get("TEST_START_LAST_MINUTES", "0") or "0")
if test_min > 0 and idx < len(durations):
offset_in_file = max(0.0, durations[idx] - test_min * 60)
except (ValueError, TypeError):
pass
# Align cuts to keyframes to avoid corrupt output at file boundaries
inpoint_sec = None
outpoint_sec = None
if offset_in_file >= 0.5:
kf = _get_keyframe_at_or_before(paths[idx], offset_in_file)
if kf is not None and kf >= 0.5:
inpoint_sec = kf
outpoint_sec = kf
# If keyframe probe failed, skip inpoint/outpoint — start from file start
# to avoid mid-GOP cuts that crash the stream
for i in range(n):
j = (idx + i) % n
fd.write(f"file '{_escape_path(paths[j])}'\n")
if i == 0 and offset_in_file >= 0.5:
fd.write(f"inpoint {offset_in_file:.2f}\n")
if offset_in_file >= 0.5:
if i == 0 and inpoint_sec is not None:
fd.write(f"inpoint {inpoint_sec:.2f}\n")
if outpoint_sec is not None:
fd.write(f"file '{_escape_path(paths[idx])}'\n")
fd.write(f"outpoint {offset_in_file:.2f}\n")
fd.write(f"outpoint {outpoint_sec:.2f}\n")
fd.flush()
@@ -212,14 +278,14 @@ def stream_live_channel(paths: list[Path], wfile, durations: list[float]) -> Non
proc = subprocess.Popen(
[
"ffmpeg",
"-fflags", "+nobuffer+flush_packets",
"-fflags", "+nobuffer+flush_packets+genpts",
"-stream_loop", "-1",
"-f", "concat", "-safe", "0", "-i", list_path,
"-c", "copy",
"-avoid_negative_ts", "make_zero",
"-muxdelay", "0",
"-muxpreload", "0",
"-max_muxing_queue_size", "1024",
"-max_muxing_queue_size", "2048",
"-f", "mpegts",
"-",
],