ffmpeg_output.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. """Turning an ffmpeg subprocess's stderr into a log line worth reading (#2968).
  2. ffmpeg opens every run with ~20 lines of version, build and library banner and
  3. prints its diagnosis *last*. Truncating that from the front -- ``stderr[:200]``,
  4. which is what most call sites did -- keeps the banner and throws the diagnosis
  5. away. A reporter's H2D produced twelve of these, and every one of them read
  6. ffmpeg frame bytes capture failed (code 183): ffmpeg version 7.1.4-0+deb13u1
  7. Copyright (c) 2000-2026 the FFmpeg developers built with gcc 14 (Debian
  8. 14.2.0-19) configuration: --prefix=/usr --extra-version=0+deb13u1 --toolch
  9. -- 200 characters that are identical on every install and say nothing about why
  10. the capture failed. The exit code was the only usable byte in the whole line.
  11. The banner-stripping summariser this module holds was written for #925 and
  12. lived as a private helper in ``api/routes/camera.py``, where the streaming
  13. endpoint used it. Ten other places log ffmpeg or ffprobe stderr -- snapshot
  14. capture, last-frame extraction, the layer-timelapse stitch, the archive's MP4
  15. conversion, external USB and RTSP capture and streaming, and timelapse
  16. post-processing. Seven of them truncated from the front, two logged the whole
  17. banner, and one already kept the tail. They all come here now, so they cannot
  18. drift again.
  19. Redaction is part of the summary rather than each caller's job. ffmpeg echoes
  20. its input URL back in the ``Input #0`` line, so a camera password or a printer
  21. access code reaches stderr on any failure; seven of those ten logged it
  22. unmasked. A helper that redacts is one that cannot be called wrong.
  23. Kept as a leaf module -- stdlib plus :mod:`core.logging_filters`, which is
  24. itself stdlib-only -- so the services and the route can all reach it without
  25. pulling a startup graph behind them.
  26. """
  27. from __future__ import annotations
  28. import asyncio
  29. import contextlib
  30. import logging
  31. from backend.app.core.logging_filters import redact_url_credentials
  32. logger = logging.getLogger(__name__)
  33. # What ffmpeg prints before it has anything to say. Every line of the banner is
  34. # either the version line or an indented continuation, and a real diagnostic is
  35. # never indented this way, so the match is on the exact prefixes rather than on
  36. # indentation alone -- `` Duration: ...`` and `` Stream #0:0 ...`` are
  37. # indented too and are worth keeping.
  38. _BANNER_PREFIXES = (
  39. "ffmpeg version ",
  40. "ffprobe version ",
  41. " built with ",
  42. " configuration:",
  43. " libavutil ",
  44. " libavcodec ",
  45. " libavformat ",
  46. " libavdevice ",
  47. " libavfilter ",
  48. " libswscale ",
  49. " libswresample ",
  50. " libpostproc ",
  51. )
  52. # How much of the tail to keep. ffmpeg's diagnosis is the last thing it writes,
  53. # and ten lines is enough to carry the error plus the input analysis that
  54. # explains it without letting a chatty decoder rotate the log file.
  55. _MAX_LINES = 10
  56. # And a ceiling on the whole thing. Ten lines is only a bound on the log record
  57. # if the lines are a sane length, and ffmpeg quotes what the peer sent it back
  58. # at us -- a printer's RTSP response is not something Bambuddy controls. Well
  59. # above any real diagnosis, so this only ever trims a line that was already not
  60. # going to be read.
  61. _MAX_CHARACTERS = 2000
  62. # What to log when the summary is empty. A failure whose stderr held nothing but
  63. # the banner still deserves a line saying so -- ``failed: `` with an empty tail
  64. # reads like a truncation bug rather than a printer that closed the connection.
  65. NO_FFMPEG_OUTPUT = "no diagnostic output"
  66. def summarize_ffmpeg_stderr(text: str | bytes | None) -> str:
  67. """Strip ffmpeg's boilerplate banner and keep the last lines that matter.
  68. Accepts raw ``bytes`` as well as ``str`` and decodes with ``errors=
  69. "replace"``: ffmpeg copies fragments of the stream into its error messages,
  70. so a bare ``.decode()`` at the call site can raise ``UnicodeDecodeError``
  71. while reporting an unrelated failure. Losing the diagnosis to a second
  72. exception is the one outcome worse than logging the banner.
  73. Returns ``""`` when there is nothing left after the banner, which is the
  74. signal the streaming endpoint uses to stay quiet. One-shot callers that log
  75. unconditionally should fall back to :data:`NO_FFMPEG_OUTPUT`.
  76. """
  77. if not text:
  78. return ""
  79. if isinstance(text, (bytes, bytearray)):
  80. text = text.decode(errors="replace")
  81. # Redaction runs on the whole string before anything is dropped: a
  82. # credentialed URL that straddles the cut would otherwise leave its tail in
  83. # the log with no ``@`` left for the pattern to anchor on.
  84. text = redact_url_credentials(text) or ""
  85. meaningful = [line for line in text.splitlines() if line.strip() and not line.startswith(_BANNER_PREFIXES)]
  86. summary = "\n".join(meaningful[-_MAX_LINES:])
  87. if len(summary) > _MAX_CHARACTERS:
  88. # From the end, for the same reason the whole module exists.
  89. summary = "..." + summary[-_MAX_CHARACTERS:]
  90. return summary
  91. # ffmpeg's RTSP demuxer follows a server's 3xx Location by itself, with no
  92. # option to refuse, and logs the first line below before it connects there.
  93. # Only the configured address was ever checked, so a redirect ends the fetch:
  94. # nothing fetched from the new destination is used. av_dump_format prints the
  95. # second once the input is open, before ffmpeg writes any output.
  96. _RTSP_REDIRECT_LOG = b"Redirecting to "
  97. _INPUT_OPEN_LOG = b"Input #0, "
  98. # Pinned: both lines are logged at "info".
  99. FFMPEG_RTSP_LOGLEVEL = ("-loglevel", "info")
  100. # Longest log line read before the input is open; ffmpeg's own are far shorter.
  101. _MAX_LOG_LINE = 65536
  102. def rtsp_log_event(line: bytes) -> str | None:
  103. """``"redirected"`` or ``"open"`` for the ffmpeg log line that says so."""
  104. if _RTSP_REDIRECT_LOG in line:
  105. return "redirected"
  106. if line.startswith(_INPUT_OPEN_LOG):
  107. return "open"
  108. return None
  109. def refuse_rtsp_redirect(process: asyncio.subprocess.Process, line: bytes) -> None:
  110. """Stop an ffmpeg that reported following a redirect, saying where to."""
  111. target = line.split(_RTSP_REDIRECT_LOG, 1)[-1].split(b"\n", 1)[0].decode("utf-8", "replace").strip()
  112. logger.error(
  113. "RTSP camera redirected to %s; redirects are not followed, configure that address instead",
  114. (redact_url_credentials(target) or "")[:80],
  115. )
  116. with contextlib.suppress(ProcessLookupError):
  117. process.kill()
  118. async def read_ffmpeg_log_until_open(process: asyncio.subprocess.Process) -> tuple[str, bytes]:
  119. """Read an RTSP ffmpeg's log up to the line saying its input is open.
  120. Returns ``("open", log)``; ``("redirected", log)`` after a redirect, with
  121. ffmpeg killed; or ``("ended", log)`` when ffmpeg stopped first.
  122. """
  123. assert process.stderr is not None
  124. log = b""
  125. while True:
  126. try:
  127. line = await process.stderr.readline()
  128. except ValueError:
  129. # A line past the reader's limit is no line of ffmpeg's own.
  130. with contextlib.suppress(ProcessLookupError):
  131. process.kill()
  132. return "ended", log
  133. if not line:
  134. return "ended", log
  135. log = (log + line)[-_MAX_LOG_LINE:]
  136. event = rtsp_log_event(line)
  137. if event == "redirected":
  138. refuse_rtsp_redirect(process, line)
  139. return event, log
  140. if event == "open":
  141. return event, log
  142. async def watch_ffmpeg_log(process: asyncio.subprocess.Process) -> None:
  143. """Drain a streaming ffmpeg's log, so a full pipe never stalls it, and
  144. stop ffmpeg should it report a redirect after all."""
  145. assert process.stderr is not None
  146. tail = b""
  147. while chunk := await process.stderr.read(4096):
  148. window = tail + chunk
  149. if _RTSP_REDIRECT_LOG in window:
  150. refuse_rtsp_redirect(process, window[window.index(_RTSP_REDIRECT_LOG) :])
  151. return
  152. tail = window[-len(_RTSP_REDIRECT_LOG) :]