try 2
This commit is contained in:
@@ -64,21 +64,25 @@ def receive_record_and_relay(stream_url, output_path, relay_url):
|
|||||||
|
|
||||||
|
|
||||||
def receive_record_relay_and_detect(stream_url, output_path, relay_url,
|
def receive_record_relay_and_detect(stream_url, output_path, relay_url,
|
||||||
frames_dir, scene_threshold=0.10,
|
scene_threshold=0.10):
|
||||||
start_number=1):
|
|
||||||
"""Single process: receive TCP → record fMP4 + relay UDP + scene detect.
|
"""Single process: receive TCP → record fMP4 + relay UDP + scene detect.
|
||||||
|
|
||||||
One ffmpeg process, three output branches from the same TCP input:
|
One ffmpeg process, three output branches from the same TCP input:
|
||||||
1. File output — c=copy to fMP4
|
1. File output — c=copy to fMP4 (raw packets, no decode)
|
||||||
2. UDP relay — c=copy to mpegts for live display
|
2. UDP relay — c=copy to mpegts for live display (raw packets)
|
||||||
3. Scene frames — CUDA decode → select(scene) → showinfo → JPEG files
|
3. Scene frames — Vulkan decode + scdet_vulkan (GPU scene comparison,
|
||||||
|
sc_pass=1 drops non-scene frames on GPU) → hwdownload (only scene
|
||||||
|
frames hit CPU) → showinfo → MJPEG piped to stdout
|
||||||
|
|
||||||
The scene filter runs on decoded frames in-process, so detection latency
|
Scene frames are piped to stdout as image2pipe/mjpeg to avoid the image2
|
||||||
is near-zero (no polling, no file re-reading, no separate process).
|
muxer's one-frame buffering delay. The caller reads JPEG data from stdout
|
||||||
Stderr must be read continuously to parse showinfo lines.
|
and writes files itself. Stderr carries showinfo lines with timestamps.
|
||||||
|
Both stdout and stderr must be read continuously.
|
||||||
"""
|
"""
|
||||||
stream = ffmpeg.input(stream_url, fflags="nobuffer", flags="low_delay",
|
stream = ffmpeg.input(
|
||||||
hwaccel="cuda")
|
stream_url, fflags="nobuffer", flags="low_delay",
|
||||||
|
hwaccel="vulkan", hwaccel_output_format="vulkan",
|
||||||
|
)
|
||||||
|
|
||||||
# Copy outputs (raw packet remux, no decode)
|
# Copy outputs (raw packet remux, no decode)
|
||||||
file_out = ffmpeg.output(
|
file_out = ffmpeg.output(
|
||||||
@@ -93,13 +97,19 @@ def receive_record_relay_and_detect(stream_url, output_path, relay_url,
|
|||||||
c="copy", f="mpegts",
|
c="copy", f="mpegts",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Scene detection output (decode + filter → JPEG)
|
# Scene detection on Vulkan GPU — only scene-change frames leave the GPU
|
||||||
select_expr = f"gt(scene,{scene_threshold})"
|
scdet_threshold = scene_threshold * 100 # config 0-1 → scdet 0-100
|
||||||
scene_stream = stream.filter("select", select_expr).filter("showinfo")
|
scene_stream = (
|
||||||
|
stream
|
||||||
|
.filter("scdet_vulkan", threshold=scdet_threshold, sc_pass=1)
|
||||||
|
.filter("hwdownload")
|
||||||
|
.filter("format", "yuv420p")
|
||||||
|
.filter("showinfo")
|
||||||
|
)
|
||||||
scene_out = ffmpeg.output(
|
scene_out = ffmpeg.output(
|
||||||
scene_stream, str(frames_dir / "F%04d.jpg"),
|
scene_stream, "pipe:1",
|
||||||
vsync="vfr", flush_packets=1, **{"q:v": "2"},
|
f="image2pipe", vcodec="mjpeg",
|
||||||
start_number=start_number,
|
vsync="vfr", **{"q:v": "2"},
|
||||||
)
|
)
|
||||||
|
|
||||||
return ffmpeg.merge_outputs(file_out, relay_out, scene_out).global_args(*GLOBAL_ARGS)
|
return ffmpeg.merge_outputs(file_out, relay_out, scene_out).global_args(*GLOBAL_ARGS)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from queue import Queue, Empty
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
||||||
from cht.config import (
|
from cht.config import (
|
||||||
@@ -196,14 +197,13 @@ class StreamManager:
|
|||||||
start_number = self._next_frame_number()
|
start_number = self._next_frame_number()
|
||||||
node = ff.receive_record_relay_and_detect(
|
node = ff.receive_record_relay_and_detect(
|
||||||
self.stream_url, self.recording_path, self.relay_url,
|
self.stream_url, self.recording_path, self.relay_url,
|
||||||
self.frames_dir, scene_threshold=self.scene_threshold,
|
scene_threshold=self.scene_threshold,
|
||||||
start_number=start_number,
|
|
||||||
)
|
)
|
||||||
proc = ff.run_async(node, pipe_stderr=True)
|
proc = ff.run_async(node, pipe_stdout=True, pipe_stderr=True)
|
||||||
self._procs["recorder"] = proc
|
self._procs["recorder"] = proc
|
||||||
log.info("Recorder+scene: pid=%s → %s (threshold=%.2f, start_number=%d)",
|
log.info("Recorder+scene: pid=%s → %s (threshold=%.2f, start_number=%d)",
|
||||||
proc.pid, self.recording_path, self.scene_threshold, start_number)
|
proc.pid, self.recording_path, self.scene_threshold, start_number)
|
||||||
self._start_scene_stderr_reader(proc, start_number)
|
self._start_scene_readers(proc, start_number)
|
||||||
|
|
||||||
# -- Scene Detection --
|
# -- Scene Detection --
|
||||||
|
|
||||||
@@ -236,16 +236,20 @@ class StreamManager:
|
|||||||
index.append(entry)
|
index.append(entry)
|
||||||
index_path.write_text(json.dumps(index, indent=2))
|
index_path.write_text(json.dumps(index, indent=2))
|
||||||
|
|
||||||
def _start_scene_stderr_reader(self, proc, start_number):
|
def _start_scene_readers(self, proc, start_number):
|
||||||
"""Read stderr continuously, parsing showinfo lines for scene frames.
|
"""Read scene frames from stdout (MJPEG pipe) and timestamps from stderr.
|
||||||
|
|
||||||
Each showinfo line corresponds to a JPEG that ffmpeg writes. We wait
|
Two threads:
|
||||||
briefly for the file to appear on disk (showinfo fires before the
|
- stderr: parses showinfo lines, queues pts_time values
|
||||||
muxer flushes), then update the index and fire the callback.
|
- stdout: reads JPEG frames from pipe, pairs with queued timestamps,
|
||||||
|
writes files to disk, fires callbacks immediately
|
||||||
|
|
||||||
|
showinfo fires before the JPEG encoder, so timestamps are always
|
||||||
|
queued before the corresponding JPEG data arrives on stdout.
|
||||||
"""
|
"""
|
||||||
def _read():
|
ts_queue = Queue()
|
||||||
frame_num = start_number
|
|
||||||
offset = self.current_global_offset
|
def _read_stderr():
|
||||||
for raw in proc.stderr:
|
for raw in proc.stderr:
|
||||||
line = raw.decode("utf-8", errors="replace").rstrip()
|
line = raw.decode("utf-8", errors="replace").rstrip()
|
||||||
if not line:
|
if not line:
|
||||||
@@ -254,21 +258,42 @@ class StreamManager:
|
|||||||
log.debug("[recorder] %s", line)
|
log.debug("[recorder] %s", line)
|
||||||
continue
|
continue
|
||||||
pts_match = re.search(r"pts_time:\s*([\d.]+)", line)
|
pts_match = re.search(r"pts_time:\s*([\d.]+)", line)
|
||||||
if not pts_match:
|
if pts_match:
|
||||||
continue
|
ts_queue.put(float(pts_match.group(1)))
|
||||||
pts_time = float(pts_match.group(1))
|
log.info("[recorder] stderr closed, exit=%s", proc.poll())
|
||||||
|
|
||||||
|
def _read_stdout():
|
||||||
|
frame_num = start_number
|
||||||
|
offset = self.current_global_offset
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
chunk = proc.stdout.read(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
# Split JPEG frames by SOI (0xFFD8) and EOI (0xFFD9) markers
|
||||||
|
while True:
|
||||||
|
soi = buf.find(b"\xff\xd8")
|
||||||
|
if soi < 0:
|
||||||
|
buf = b""
|
||||||
|
break
|
||||||
|
eoi = buf.find(b"\xff\xd9", soi + 2)
|
||||||
|
if eoi < 0:
|
||||||
|
buf = buf[soi:] # keep from SOI, need more data
|
||||||
|
break
|
||||||
|
jpeg_data = buf[soi:eoi + 2]
|
||||||
|
buf = buf[eoi + 2:]
|
||||||
|
|
||||||
|
# Get timestamp (showinfo fires before encode, so it's queued)
|
||||||
|
try:
|
||||||
|
pts_time = ts_queue.get(timeout=2.0)
|
||||||
|
except Empty:
|
||||||
|
log.warning("No timestamp for scene frame %d", frame_num)
|
||||||
|
pts_time = 0.0
|
||||||
|
|
||||||
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"
|
||||||
|
frame_path.write_bytes(jpeg_data)
|
||||||
# Wait for ffmpeg to flush the JPEG (showinfo fires before mux)
|
|
||||||
for _ in range(20): # up to ~200ms
|
|
||||||
if frame_path.exists() and frame_path.stat().st_size > 0:
|
|
||||||
break
|
|
||||||
time.sleep(0.01)
|
|
||||||
|
|
||||||
if not frame_path.exists():
|
|
||||||
log.warning("Scene frame %s not found on disk", frame_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
"id": frame_id,
|
"id": frame_id,
|
||||||
@@ -282,10 +307,10 @@ class StreamManager:
|
|||||||
if self._on_new_frames:
|
if self._on_new_frames:
|
||||||
self._on_new_frames([entry])
|
self._on_new_frames([entry])
|
||||||
frame_num += 1
|
frame_num += 1
|
||||||
|
log.info("[recorder] stdout closed")
|
||||||
|
|
||||||
log.info("[recorder] stderr closed, exit=%s", proc.poll())
|
Thread(target=_read_stderr, daemon=True, name="recorder_stderr").start()
|
||||||
|
Thread(target=_read_stdout, daemon=True, name="recorder_stdout").start()
|
||||||
Thread(target=_read, daemon=True, name="recorder_stderr").start()
|
|
||||||
|
|
||||||
def _probe_safe_duration(self):
|
def _probe_safe_duration(self):
|
||||||
"""Probe current recording duration via ffprobe. Returns seconds or None."""
|
"""Probe current recording duration via ffprobe. Returns seconds or None."""
|
||||||
|
|||||||
Reference in New Issue
Block a user