Skip to content

Stream history: in-memory H.264+AAC ring via ffmpeg (opt-in CMake toggle) - #1563

Open
Gin890 wants to merge 40 commits into
mainfrom
stream-history-ffmpeg-ring
Open

Gin890 wants to merge 40 commits into
mainfrom
stream-history-ffmpeg-ring

Conversation

@Gin890

@Gin890 Gin890 commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Implements the in-memory ffmpeg stream-history ring discussed in #Stream (option 1): video + audio are encoded in the background into an in-memory ring, and nothing touches storage until save() is called, when the ring is muxed into a single MP4/MKV file.

Toggle (compile-time)

-DPA_STREAM_HISTORY_FFMPEG=ON
  • OFF (default) — every existing build/CI path is byte-for-byte unaffected (the new .cpp isn't even compiled; StreamHistorySession.cpp keeps the old #if shape).
  • ON, Windows — headers from 3rdParty/ffmpeg/ (the ffmpeg include/ tree) + import libs avcodec/lib avformat/lib avutil/lib swscale/lib swresample/lib in 3rdPartyBinaries/ (mirrors the OpenCV import-lib precedent). The existing THIRD_PARTY_DLLS glob auto-deploys any ffmpeg DLLs placed in 3rdPartyBinaries/.
  • ON, macOS/Linux — pkg-config (avcodec avformat avutil swscale swresample); Ubuntu: sudo apt install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev, macOS: brew install ffmpeg.
  • ON but ffmpeg missing / too old — FATAL_ERROR at configure time with an explicit message (ffmpeg ≥ 5.1 / libavcodec 59+ required for the channel-layout API).

What the tracker does (Recording/StreamHistoryTracker_Ring.{h,cpp})

  • Video — same worker pattern as the JPEG tracker (bounded queue of 10, drop-oldest when lagging, AsyncTask on unlimited_normal()). Replaces JPEG with the ffmpeg C API: hardware H.264 encoders first (h264_qsv/h264_nvenc/h264_amf on Windows, h264_videotoolbox on macOS, h264_v4l2m2m on Linux) with libx264 veryfast fallback; no B-frames, forced keyframe every 2 s, NV12 fast path straight into sws_scale with a Qt toImage() RGB888 fallback for other pixel formats. Honors the existing StreamHistoryOption settings (RESOLUTION / VIDEO_FPS / ENCODING_MODE / VIDEO_QUALITY / VIDEO_BITRATE).
  • Audio — on_samples → swresample to 48 kHz → native aac encoder, 1024-sample frames, inline (sub-millisecond). Includes a deinterleave for DUAL_44100, which the session declares as 1ch but the pipeline actually delivers as interleaved stereo.
  • Ring — (WallClock, encoded packet) deques evicted by the HISTORY_SECONDS window plus a hard ~90 MB byte cap (≈23 min at the default 512 kbps). Zero I/O until prompted.
  • save(filename) — snapshot the ring (shared packets, no deep copy), rewind to the first keyframe in the ring (oldest decodable start; loses at most one ~2 s keyframe interval from the head of the window), trim audio to the same start point, mux to MP4 (or MKV when the filename ends .mkv) with libavformat using copy-only semantics. ErrorReports saves to Video.mp4, so MP4 is the default target.

One deviation from the discussion worth calling out: the plan said Opus, but I used AAC — the save target is Video.mp4 (Discord attachment) and H.264+AAC MP4 plays everywhere, whereas Opus-in-MP4 has spotty player support. The ring design (compressed audio packets, 48 kHz, ~20 ms frames) is otherwise as discussed; swapping to libopus is a ~30-line change if you'd rather have it.

Failure behavior

Encoder init, scaler, resampler, or open failures log and disable just that stream (video or audio) — the bot never crashes, matching the existing tracker's failure style. save() with an empty ring logs and returns false.

Verification

No ffmpeg/Qt toolchain in this sandbox, so this is not a full production build. What was done instead:

  • Compiled the real StreamHistoryTracker_Ring.cpp (unmodified, straight from the repo) with g++ 13 -std=c++23 -Wall -Wextra against signature-faithful ffmpeg ≥5.1 API stubs + a minimal Qt QVideoFrame/QImage model, and ran it behaviorally: full video+audio pipeline → MP4 mux (packet counts, keyframe cadence, µs timestamps, A/V alignment), HISTORY_SECONDS eviction, DUAL_44100 deinterleave/resample (44100 → 48000, exact frame counts), empty-ring save, .mkv muxer selection, and x264 veryfast/zerolatency/bframes=0 options. All pass, repeated runs included.
  • The stub x264 encoder deliberately models a hardware-encoder-style one-deep output queue (every send_frame hits EAGAIN until drained), which exercises the EAGAIN/retry path.
  • ASan+UBSan build of the same harness passes all tests.

The stub review caught and fixed (commit 7cd8981), against real ffmpeg 5.1 semantics:

  • save() rewound to the last keyframe → a 30 s window saved only the final ~2 s. Now rewinds to the first keyframe.
  • Video mux timestamps off by a factor of 1e6 (us * den / num on a 1/1e6 timebase) → unplayable files. Now av_rescale_q from 1/1000000.
  • Muxed packets never set stream_index → real libavformat would route audio into the video stream.
  • swr_alloc_set_opts2(&swr, ...) — 9-arg hybrid that does not exist in ffmpeg ≥5.1 (8 params, out layout first); would not compile against real headers.
  • avcodec_send_frame EAGAIN (full encoder input queue — realistic on QSV/NVENC/AMF) permanently clogged the encoder instead of draining + retrying.
  • Unlocked video_enabled writes racing on_frame; ffmpeg libs linked PRIVATE on POSIX; explicit libavutil/error.h/rational.h includes.

Please still do a real build with the toggle ON before relying on it — in particular the hardware-encoder paths (QSV/NVENC/AMF) are the part most likely to need a first-pass tweak.

Files

  • SerialPrograms/Source/CommonFramework/Recording/StreamHistoryTracker_Ring.{h,cpp} — new tracker
  • SerialPrograms/Source/CommonFramework/Recording/StreamHistorySession.cpp — 4-line #if selection
  • SerialPrograms/CMakeLists.txt — option, per-platform link blocks, compile definition
  • SerialPrograms/cmake/SourceFiles.cmake — new header in the list; new .cpp appended only when the option is ON

@jw098
jw098 marked this pull request as draft October 12, 2026 23:31
@jw098
jw098 force-pushed the stream-history-ffmpeg-ring branch from 63eac29 to 4f3491f Compare October 14, 2026 19:13
@Gin890

Gin890 commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

MP4 blank screen: malformed avcC fixed (commit 93253e8)

Symptom: saved MP4 had the correct duration but decoded to a blank screen, while the same ring saved as MKV played fine.

Root cause: h264_avcc_from_annexb_extradata() (added in b6e2221) built a malformed avcC record for the MP4 stream header:

  1. a stray 5th byte (sps[4], the first byte of the SPS RBSP) was written before the reserved/lengthSizeMinusOne byte, and
  2. the numOfSequenceParameterSets byte was 0xE0 (= 0 SPS) instead of 0xE1 (= 1 SPS).

MP4 players take the SPS from the stream header (avcC); with a zero SPS count they get none, so every sample fails to decode -> blank screen, while the container metadata (duration, size) is still correct. MKV is unaffected because it stores the Annex B SPS/PPS in-band/in CodecPrivate.

The record now follows the ISO/IEC 14496-15 avcC layout 01 <profile> <compat> <level> FF E1 <spsLen16> <SPS> 01 <ppsLen16> <PPS> — byte-for-byte the same layout ffmpeg's own mov muxer writes from the same SPS/PPS. Verified by building the record with this code, muxing it into an MP4, and decoding (clean, non-blank output). Linux build + full command-line test suite pass (1962/1962).

@Gin890

Gin890 commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

MP4 save now fails with "non monotonically increasing dts" — fixed (commit 4d40e92)

Root cause (separate from the avcC bug): ring video packets are stamped with the wall-clock time of the frame they were drained from, and a hardware encoder can emit several packets for one frame (or flush two or more at once) — so consecutive ring packets share a timestamp. The MP4 muxer requires strictly increasing dts and fails the whole save:

[mp4 @ ...] Application provided invalid, non monotonically increasing dts to muxer in stream 0: 596 >= 596

mux_to_file() now clamps a non-increasing video dts to last + 1 (1 ms at the 1/1000 video timebase), and skips empty ring packets instead of passing an empty sample to the muxer (which would also break the Annex B → AVCC packet conversion).

Verified with a standalone lavf harness: two consecutive H.264 packets with equal dts → AVERROR(EINVAL) from av_interleaved_write_frame() (the reported error); with the clamp the same sequence muxes and decodes cleanly (60/60 frames). Linux build + full command-line test suite pass (1962/1962).

On keeping the Annex B → AVCC conversion for MP4: yes, it is still needed.

  • The h264_amf wrapper emits Annex B (SPS/PPS extradata and 00 00 00 01-coded packets) even with AV_CODEC_FLAG_GLOBAL_HEADER — established empirically on AMD iGPUs, where AMF is the first encoder that works.
  • The vendored ffmpeg 8.x MP4 muxer crashes (access violation in avformat_write_header) when handed H.264 in that form — that crash is exactly why the conversion was added in b6e2221.
  • With the avcC record fixed in 93253e8 the conversion now produces a spec-conformant AVCC MP4 (verified by muxing and decoding its output).
  • The only way to drop it would be an encoder that natively emits AVCC (nvenc/qsv do, but AMF does not, and ffmpeg's AMF wrapper exposes no option to force it).

@Gin890

Gin890 commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

Audio never opened: AAC encoder missing sample_fmt — fixed (commit aa6809c)

"Stream history: Failed to open AAC encoder. Disabling audio." was not a Windows/vendored-ffmpeg issue — it reproduces on any ffmpeg build. init_audio_encoder() set rate, timebase, bitrate, channel layout and flags, but never actx->sample_fmt, so avcodec_open2() failed with EINVAL ("Invalid audio sample format: -1") every time. Audio has been silently disabled on all platforms since the ring tracker was added.

The native AAC encoder only accepts AV_SAMPLE_FMT_FLTP (planar float), so the fix:

  • init_audio_encoder() now sets actx->sample_fmt = AV_SAMPLE_FMT_FLTP (and frame_size = 1024) before opening,
  • on_samples() now builds each 1024-sample encode frame as FLTP by de-interleaving the accumulated packed resampler output into the frame's per-channel planes (previously it memcpy'd packed data into data[0] of a frame declared as AV_SAMPLE_FMT_FLT, which the encoder would have rejected anyway),
  • the open-failure log is split so a channel-layout failure and an avcodec_open2() failure are reported separately, the latter with the ffmpeg error string.

Verified with a standalone harness replicating the full pipeline (resample 44.1/48 kHz → packed FLT → de-interleave → FLTP frames → AAC encode with AV_CODEC_FLAG_GLOBAL_HEADER): encoder opens, produces packets, and exposes the 5-byte AudioSpecificConfig extradata that the MP4/MKV muxers need; AAC-with-ASC muxes and decodes cleanly in both MP4 and MKV. Linux build + full command-line test suite pass (1962/1962).

@Gin890

Gin890 commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

Audio resampler never initialized — fixed (commit 886a710)

The AAC encoder now opens (the sample_fmt/FLTP fix is working — see the audio = AAC 1ch @ 48 kHz line), but make_audio_resampler() only called swr_alloc_set_opts2(), which allocates the SwrContext and fills in the options — it does not initialize it. The header is explicit: "Once all values have been set, it must be initialized with swr_init()."

So every swr_convert() call logged [SWR] Context has not been initialized and failed, no samples were ever accumulated, and saved files had a video track but no audio.

Fix: call swr_init(swr) after allocation in make_audio_resampler(), and swr_free() it if initialization fails (the existing "Failed to create audio resampler. Disabling audio." path now reports real failures).

Verified the exact case from the log (48 kHz mono → 48 kHz mono, packed FLT in/out) in a standalone harness: swr_init = 0, swr_convert returns the full 1024 samples with clean data. Linux build + full command-line test suite pass (1962/1962).

@Gin890

Gin890 commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

MKV audio/duration: Matroska needs millisecond timestamps — fixed (commit e49393d)

"MKV: no audio, plays 30 s then freezes on the last frame for >20 min" traced to a Matroska-specific timestamp issue, reproduced with a standalone harness muxing real H.264+AAC packets through the same avformat calls:

The matroska muxer writes packet timestamps into the file verbatim. Its write_header() rewrites every stream's timebase to 1/1000 (Matroska's native TimestampScale is milliseconds) but does not rescale the dts/pts the application submits. We submitted audio with a 1/48000 timebase and sample-count timestamps, so:

  • every audio SimpleBlock landed 48× too late on the file timeline (a 30 s clip's 1024-sample audio blocks were spaced 1.024 s apart, each carrying only 21 ms of sound) → the player had no continuous audio to play during the video → "no audio"
  • the segment Duration element became 1441813 ms ≈ 24 min for a 30 s clip → after the video ends, the player waits out the remaining ~19 min on the last frame → "freeze on the last frame"

MP4 was unaffected because the mov muxer rescales from the stream timebase itself (hence MP4 audio worked).

Fix in mux_to_file():

  1. For .mkv only: use a 1/1000 stream timebase for both streams and rescale the packet dts into it (video: µs → ms; audio: samples → ms). .mp4 keeps the encoder's video timebase and the 1/48000 audio timebase it already handled correctly.
  2. A strictly-increasing dts guard for audio packets, mirroring the video one.
  3. Interleave the video/audio writes in timestamp order instead of writing all video then all audio, which laid the Matroska clusters out of time order (all audio data physically after all video data).

Harness verification: old behavior → MKV Duration element 1441813 (24 min) for a 30 s clip; new behavior → duration ≈ 30 s, audio packets decode at 0…30 s, MP4 path unchanged. Linux build + full command-line test suite pass (1962/1962).

@pa-devbot

Copy link
Copy Markdown
Collaborator

Follow-up to the encoder-format audit (nvenc/qsv/amf/videotoolbox/v4l2m2m wrappers reviewed against the ffmpeg 8.1 sources): no new encoder-specific conversion code is needed - every candidate emits its extradata and packets in the same form. This commit adds one log line at mux time: the first 4 bytes of the first video ring packet, printed next to the existing extradata hex. If a driver ever reported one H.264 form (AVCC extradata) while emitting the other (Annex B packets), the MP4 save would otherwise be a silent blank file; this makes the mismatch immediately visible in the log. Log-only, no behavior change. (aedba6c)

@pa-devbot

Copy link
Copy Markdown
Collaborator

Added h264_mf (the OS-native Media Foundation H.264 encoder) to the Windows candidate list, after h264_amf and before mpeg4. It is compiled into the vendored LGPL build (verified in avcodec-63.dll) and present on essentially all Windows 8+: when none of the GPU vendors' encoders (qsv/nvenc/amf) is available, the session now falls back to real, universally playable H.264 instead of MPEG-4 Part 2. Device-backed like the hardware wrappers, so a runtime failure follows the existing leak-and-poison path; rotation/poison/fallback logic needed no changes. The mux-time log lines report its extradata/packet form on first use. (9508c51, rebased onto dc8129c)

@jw098
jw098 force-pushed the stream-history-ffmpeg-ring branch from 9508c51 to 927370c Compare October 18, 2026 04:58
@jw098
jw098 marked this pull request as ready for review October 18, 2026 04:59
@Mysticial

Copy link
Copy Markdown
Collaborator

This is GPL safe right? We're using the LGPL part of ffmpeg because we're only including headers and their pre-compiled binaries?

@jw098

jw098 commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator

I believe so. I specifically used a LGPL build of ffmpeg. So, I think it should be LGPL?

Gin890 and others added 17 commits October 18, 2026 14:47
…HISTORY_FFMPEG).

Replaces the JPEG-frame ring with an in-memory ring of encoded H.264 video + AAC
audio packets when the new CMake option PA_STREAM_HISTORY_FFMPEG is enabled.
Nothing is written to storage until save() is called, at which point the ring
is muxed into an MP4 (or MKV for .mkv filenames) via libavformat.

- New StreamHistoryTracker_Ring (Recording/StreamHistoryTracker_Ring.{h,cpp}):
  - Video: dedicated encoder thread with the same bounded-queue / drop-oldest
    scheme as the JPEG tracker. Hardware H.264 encoders preferred
    (qsv/nvenc/amf on Windows, videotoolbox on macOS, v4l2m2m on Linux),
    libx264 veryfast as fallback, no B-frames, keyframe every 2 s, NV12 fast
    path with a Qt RGB888 fallback for other pixel formats.
  - Audio: resampled to 48 kHz (swresample) and encoded as AAC inline in
    on_samples(); deinterleaves the DUAL_44100 input, which the session
    declares as mono but the pipeline delivers as interleaved stereo.
  - The ring is evicted by time window (HISTORY_SECONDS) plus a ~90 MB cap.
  - save() snapshots the ring (shared packets, no deep copy), rewinds to the
    last keyframe, and muxes H.264+AAC. Encoder init / capture failures log
    and disable the affected stream instead of crashing.
- StreamHistorySession picks the ring tracker when PA_STREAM_HISTORY_FFMPEG is
  defined (same #if shape as the existing Qt version check).
- CMake: option(PA_STREAM_HISTORY_FFMPEG OFF) - OFF leaves every existing
  build untouched. When ON: Windows links the ffmpeg import libs from
  3rdPartyBinaries/ + headers from 3rdParty/ffmpeg/ (mirroring the OpenCV
  precedent; DLLs placed in 3rdPartyBinaries/ are auto-deployed by the
  existing THIRD_PARTY_DLLS glob), macOS/Linux use pkg-config
  (avcodec/avformat/avutil/swscale/swresample, requires ffmpeg >= 5.1).
  Missing ffmpeg fails at configure time with an explicit FATAL_ERROR.

Note: the design discussion called the audio codec Opus; AAC was chosen
instead because the save target is 'Video.mp4' (Discord attachments) and
H.264+AAC MP4 plays everywhere, while Opus-in-MP4 has spotty player support.
- save() rewound the video to the LAST keyframe, so saving a 30 s window
  returned only the ~2 s since the newest keyframe. Rewind to the FIRST
  keyframe in the ring instead (loses at most one keyframe interval from
  the head of the window).
- Video packet PTS were computed as us * time_base.den / time_base.num,
  which for the 1/1e6 timebase multiplies timestamps by 1e6, producing
  unplayable files. Use av_rescale_q from a 1/1000000 timebase.
- Muxed packets never set packet->stream_index, so real libavformat would
  route every packet (video and audio) into stream 0.
- swr_alloc_set_opts2() was called with 9 args including &swr as the first
  parameter; the ffmpeg >= 5.1 API takes 8 (out channel layout first, no
  SwrContext**). This did not compile against real ffmpeg headers.
- avcodec_send_frame() EAGAIN (encoder input queue full - realistic with
  hardware encoders) was treated as a hard failure without draining, which
  clogs the encoder permanently and logs every frame after. Drain one
  output packet and retry the send.
- Video encoder failure paths wrote video_enabled without the ring lock
  (raced with on_frame reading it under the lock).
- Link the ffmpeg libraries PRIVATE on POSIX (matches the PRIVATE compile
  definition and the Windows side).
- Include libavutil/error.h and libavutil/rational.h explicitly.
…encoder.

The project is LGPL-only, so the encoder candidate list no longer relies
on the GPL libx264. The final candidate is now 'mpeg4' (MPEG-4 Part 2,
built into libavcodec in LGPL builds), with the libx264-specific
preset/tune/bframes options removed along with the now-unneeded <cstring>
include. The CMake comment now points at the LGPL shared ffmpeg builds
(gyan.dev '...-lgpl-shared' / BtbN 'win64-lgpl-shared').
…g 5.1-8.x

- Save bug: the AAC encoder was opened without AV_CODEC_FLAG_GLOBAL_HEADER,
  so it emitted ADTS frames with no AudioSpecificConfig extradata and
  mux_to_file() failed to set up the audio stream - every save that
  included audio failed. Set the flag (both MP4 and MKV need it).
- MP4 output: pass movflags=+faststart to the mp4 muxer so the moov atom
  (file index) is written at the front of the file instead of at the end.
  MKV saves are unchanged.
- Portability: jw's build fixes targeted only the ffmpeg 8.0 API surface
  (void av_channel_layout_default + av_channel_layout_check,
  avcodec_get_supported_config, swr_alloc_set_opts2 taking a SwrContext**),
  which does not compile against the ffmpeg that mac/linux package managers
  provide. Guard each version-sensitive call on the library version macros
  so the tracker builds against ffmpeg 5.1 through 8.x (AVCodec.pix_fmts
  for pre-7.0, the old swr_alloc_set_opts2 signature for pre-8.0, the
  int-returning av_channel_layout_default for pre-8.0).
- Fix an AVFrame leak in on_samples() (early return without
  av_frame_free) and stop retrying a permanently broken audio path by
  disabling audio instead.
- Restore <cstring> (still needed for memcpy) and add
  <libavutil/version.h> for the guard macros.
… detection

* StreamHistoryTracker_Ring.cpp: ffmpeg's public API headers do not carry
  their own extern "C" guards, so from C++ every ffmpeg declaration was
  C++ name-mangled and none of the symbols resolved against the ffmpeg
  shared libraries (Windows: LNK2019 unresolved externals at the link
  stage after a full ~31 min build; mac/linux: undefined symbols).
  Wrap all ffmpeg includes in an extern "C" block.

* Restore the version-conditional include: ffmpeg 8.0 (libavutil 61+)
  renamed err.h to error.h, but Ubuntu 24.04's apt ships ffmpeg 6.1
  (libavutil 58) which only has err.h - the unconditional error.h include
  fails to compile there.

* Re-apply the C4458-safe parameter/local names in mux_to_file() and
  save() (video/audio/have_audio/video_start hide Impl members, which is
  a /W4 warning that becomes an error under /WX on Windows).

* Restore the #ifndef EAGAIN fallback for MSVC.

* CMakeLists (POSIX): pkg-config module names differ between platforms -
  Homebrew ships avcodec.pc etc. while Debian/Ubuntu ship libavcodec.pc
  etc. - so the previous 'pkg_check_modules(FFMPEG REQUIRED avcodec ...)'
  failed configure on Ubuntu, and the 'libavcodec>=59' version check
  failed configure on macOS (brew has no libavcodec.pc). Try both
  namings, check the minimum version with whichever was found, and add
  target_link_directories (like the OpenCV/Tesseract/DPP blocks above)
  so -lavcodec etc. are found under /opt/homebrew/lib on macOS.
Gin890 and others added 23 commits October 18, 2026 14:48
…al API

The CI Ubuntu runner's ffmpeg (7.1-era) already has the 'new' API
signatures (void av_channel_layout_default, int
swr_alloc_set_opts2(SwrContext **ps, ...)) while its libavutil/libswresample
major numbers are still below the 8.0 ones the #if guards expected, so the
old-API branches were selected against new-API headers and failed to
compile.

- av_channel_layout_default / swr_alloc_set_opts2: select the API
  generation at compile time from the actual declarations
  (std::is_same on the function-pointer types, C language linkage). The
  generation-specific calls are routed through function templates so they
  are only instantiated when their branch is taken - names/expressions in a
  discarded if-constexpr branch are still looked up and checked, which is
  why the old 8-argument swr_alloc_set_opts2 call (missing the
  AVDictionary** that real ffmpeg 5.1-7.0 takes) also failed against the
  real headers.
- error.h vs err.h: pick with __has_include instead of a version macro.
- avcodec_get_supported_config stays a preprocessor gate
  (LIBAVCODEC_VERSION_MAJOR >= 61): the symbol is not declared at all in
  older headers, so the code must be removed before name lookup, not merely
  branch-discarded. The threshold is confirmed by this CI (its libavcodec
  compiles the new branch fine) and by ffmpeg 6.1 (libavcodec 60, which has
  AVCodec.pix_fmts instead).
- CMake: log which pkg-config module set / version was found.

Verified: syntax-checks against the committed ffmpeg 8.0 headers, and full
behavioral harness runs against both an ffmpeg 5.1-7.0 stub set and a
7.1-runner-model stub set (new signatures, old version numbers).
…ffmpeg 7.x

Per jw: we won't support the old ffmpeg API, so remove the
compile-time detection of the API generation in
StreamHistoryTracker_Ring.cpp and use the current signatures directly.

- error.h/err.h: just include <libavutil/error.h>. The __has_include
  fallback to err.h was dead either way - error.h exists in every
  ffmpeg release, and ffmpeg 8.x removed err.h by merging it into
  error.h.
- Removed kChannelLayoutDefaultReturnsVoid /
  kSwrAllocSetOpts2TakesContextPtr and the old-API template shims; call
  av_channel_layout_default() (void, validated with
  av_channel_layout_check()) and swr_alloc_set_opts2() taking a
  SwrContext** directly. These signatures exist in ffmpeg 7.1+ (the
  pre-7.1 releases pass an AVDictionary** to swr_alloc_set_opts2 and
  get an int back from av_channel_layout_default).
- CMake: enforce the floor for real. macOS/Linux: avcodec>=61
  (libavcodec 61 = ffmpeg 7.x; previously avcodec>=59 / 'ffmpeg 5.1').
  Windows: new check parsing LIBAVCODEC_VERSION_MAJOR out of the
  vendored 3rdParty/ffmpeg/ headers (previously there was no version
  check at all). The vendored 8.x headers pass.
The ring's unguarded ffmpeg API usage (channel layout API:
av_channel_layout_default/check/copy/uninit, AVCodecContext.ch_layout,
AVFrame.ch_layout; swr_alloc_set_opts2; everything else is pre-5.x API)
is available in ffmpeg 5.1 with unchanged signatures, so 5.1 is the
minimum supported version.

libavcodec's major version cannot express that floor as a number (4.x
and 5.x are both libavcodec 58), so the CMake floor check now detects
the 5.1 API generation in the headers (vendored tree on Windows,
pkg-config include dirs elsewhere) instead of comparing
LIBAVCODEC_VERSION_MAJOR against 61.

The pix_fmts/avcodec_get_supported_config split in
StreamHistoryTracker_Ring.cpp is untouched: it is an API-generation
gate for 7.0+ headers, and with 5.1-7.0 headers the AVCodec.pix_fmts
path (still present in those releases) compiles instead.
…ntime

Hardware encoders (h264_amf & co.) can open successfully and still fail
on every frame - e.g. h264_amf whose D3D11 device cannot create
textures (SubmitInput() failed with error 18 / AMF_DIRECTX_FAILED,
CreateTexture2D -> 0x887A0005 DXGI_ERROR_GPU_NOT_FOUND). Previously that
logged 'Video encode failed' on every frame forever and the saved file
had no video.

After 3 consecutive hard avcodec_send_frame() failures the current
candidate is abandoned and init_video_encoder() retries starting from
the next candidate (typically the CPU mpeg4 encoder), so the recording
still gets video and the error spam stops. Candidate rotation is
remembered (failed candidates are not retried), and the scaler is
rebuilt when the new encoder prefers a different pixel format.
On some AMD iGPUs (observed: Ryzen 5 5625U), a hardware H.264 encoder
can open successfully and then fail on every frame because its GPU
device cannot allocate textures (h264_amf: CreateTexture2D fails with
DXGI_ERROR_GPU_NOT_FOUND). Releasing such a context with
avcodec_free_context() crashes the AMD display driver with an access
violation in atidxx64.dll, taking the whole process down - observed
both at the encoder fallback point and in the tracker destructor.

Therefore, when a hardware encoder fails at runtime (or at open), its
context is now deliberately leaked (a bounded, one-time leak) instead
of freed, and the encoder is remembered as poisoned for the rest of
the process so that later sessions skip it instead of re-initializing
it. Fallback to the next candidate now happens on the first hard
avcodec_send_frame() failure instead of after three, to minimize the
number of calls into the broken driver. The CPU mpeg4 fallback is
still released normally.
…on open failures

Two fixes for the encoder fallback path:

- mpeg4 failed to open with 'Invalid argument' on ffmpeg 8.x: since
  7.0, AVCodec.pix_fmts is gone and the supported formats must come
  from avcodec_get_supported_config(); when that query yields no list,
  ctx->pix_fmt was left at AV_PIX_FMT_NONE, which mpeg4 (and likely
  other encoders) reject at open time. The chosen input pixel format
  now always falls back to YUV420P - supported by every encoder in
  the candidate list - when no list is available, and the selected
  format is logged when the encoder opens.

- Encoders that merely fail to open (e.g. h264_qsv/h264_nvenc on a
  machine without that hardware) are now released normally instead of
  being leaked and poisoned for the whole process: the driver was
  never exercised through a context that failed at open. Only a
  context that failed at runtime (after pushing frames into a possibly
  broken GPU driver) is leaked, as before.
Two encoder-setup fixes, both observed on an AMD Ryzen 5 5625U iGPU
(ffmpeg 8.1):

- mpeg4 failed to open with EINVAL: 'timebase 1/1000000 not supported
  by MPEG 4 standard, the maximum admitted value for the timebase
  denominator is 65535' (the 16-bit time_incr_length of MPEG-4 Part 2).
  The video timebase is now 1/1000 (milliseconds) for all candidates;
  the hardware H.264 wrappers accept any timebase, and the muxer
  derives packet timestamps from the ring's microsecond WallClock
  timestamps anyway.

- h264_amf failed at the first SubmitInput: the AMD driver logged
  'unexpected frame rate' and removed the D3D11 device
  (AMF_NO_DEVICE). The frame rate is now set explicitly
  (ctx->framerate = target_fps/1), which the AMF wrapper passes to the
  driver; it also feeds rate control on the other hardware encoders.
Saving the video crashed in avformat_write_header() (access violation
in avformat-63.dll) on an AMD iGPU with the h264_amf encoder. The
muxer ran on the caller's thread while the video worker and the audio
pipeline thread were still encoding into the same AVCodecContexts, and
ffmpeg is not thread-safe on a live context: the hardware encoders
populate their extradata lazily (sometimes only after the first
encoded packet, and possibly mid-stream), so the cross-thread
avcodec_parameters_from_context() in mux_to_file() could capture a torn
state (e.g. a nonzero extradata_size with a null extradata pointer),
which is what made the mp4 muxer walk off a null pointer.

The muxer now works exclusively from codec-parameter snapshots
(video_params/audio_params + video_time_base) that are captured on
each encoder's own thread - the video worker after every encoded
packet, the audio pipeline thread after every encoded AAC frame -
under the ring lock, and are refreshed whenever the encoder's
extradata size changes. A new encoder invalidates the stale video
snapshot. If no snapshot (or no extradata) exists yet, the save fails
with the existing 'missing encoder state' error instead of crashing.

Also logs the video extradata size and first four bytes at mux time
to make any future extradata-format issue (AVCC vs Annex B) visible
in the log.
The h264_amf wrapper emits its H.264 stream in Annex B form (SPS/PPS
extradata and packets with 00 00 00 01 start codes), even with
AV_CODEC_FLAG_GLOBAL_HEADER. The MP4 muxer in the vendored ffmpeg 8.x
(avformat-63) crashes with an access violation inside
avformat_write_header() when it is given H.264 in that form (older
ffmpeg, e.g. 6.x, tolerated it). Now that h264_amf is the first
encoder that actually works on AMD iGPUs, saving the video hit this
crash deterministically.

Since MP4 (ISO BMFF) canonically stores H.264 as AVCC - an avcC
configuration record in the stream header plus NAL units with
4-byte big-endian length prefixes - mux_to_file() now converts the
stream to that form when the video is H.264, the target is MP4, and
the extradata is Annex B: it builds the avcC record from the SPS/PPS
NAL units in the extradata snapshot and rewrites each ring packet
with NAL length prefixes at write time. Matroska output is left in
Annex B, and encoders that already produce AVCC (nvenc, qsv) are
untouched.

Also fixes a regression introduced by the parameter-snapshot commit:
the 'missing encoder state' check required extradata for every video
codec, but MPEG-4 Part 2 (the CPU fallback) has no extradata at all;
the requirement now applies to H.264 only (AAC audio still requires
its AudioSpecificConfig).
avformat_write_header() does not open format->url: the lavf docs
require the caller to provide an opened AVIOContext (format->pb),
and the muxer's write_header() dereferences s->pb unconditionally.
With pb still NULL the MP4 muxer faults in mov_write_header
(NULL+0x88 read, avformat-63.dll). Open the file with avio_open2()
just before writing the header and close it on the failure path
(the success path already closed it via avio_closep).
The avcC configuration record built by h264_avcc_from_annexb_extradata()
was malformed: it wrote a stray 5th byte (sps[4], the first byte of the
SPS RBSP) before the reserved/lengthSizeMinusOne byte, and used 0xE0
(numOfSequenceParameterSets = 0) instead of 0xE1 (= 1). Players that
take the SPS from the stream header therefore found none: the saved MP4
had the right duration and container structure, but decoded to a blank
screen, while MKV (which keeps the Annex B SPS/PPS in-band) played fine.

The record now matches the ISO/IEC 14496-15 avcC layout
01 <profile> <compat> <level> FF E1 <spsLen16> <SPS> 01 <ppsLen16> <PPS>,
byte-for-byte the same layout ffmpeg's own mov muxer writes from the
same SPS/PPS (verified by muxing and decoding the result).
Ring video packets are stamped with the wall-clock time of the frame they
were drained from, and a hardware encoder can emit several packets for one
frame (or flush two or more at once), so consecutive ring packets can share
a timestamp. The MP4 muxer rejects that:
  [mp4] Application provided invalid, non monotonically increasing dts to
  muxer in stream 0: 596 >= 596
and the whole save failed.

mux_to_file() now clamps a non-increasing video dts to last+1 (1 ms at the
1/1000 video timebase), and skips empty ring packets instead of feeding an
empty sample to the muxer (which would also fail the Annex B -> AVCC
conversion).

Verified with a standalone lavf harness: writing two consecutive H.264
packets with the same dts fails with AVERROR(EINVAL) exactly like the
reported error, and the clamped sequence muxes and decodes cleanly.
…erleave into planes

init_audio_encoder() never set actx->sample_fmt, so avcodec_open2() failed
with EINVAL ("Invalid audio sample format: -1") and audio was disabled on
every platform - the log showed 'Failed to open AAC encoder. Disabling
audio.' even though everything else in the configuration was valid.

The native AAC encoder only accepts AV_SAMPLE_FMT_FLTP, so:
- set actx->sample_fmt = AV_SAMPLE_FMT_FLTP (and frame_size = 1024) before
  avcodec_open2(), and
- on_samples() now builds each 1024-sample encode frame as FLTP by
  de-interleaving the accumulated packed resampler output into the frame's
  per-channel planes instead of memcpy'ing it as one packed plane.

Also split the open-failure logging so the channel-layout step and
avcodec_open2() failures are reported separately, with the ffmpeg error
string for the latter.

Verified with a standalone harness that replicates the full pipeline
(resample 44.1/48 kHz -> packed FLT -> de-interleave -> FLTP frames ->
AAC encode with AV_CODEC_FLAG_GLOBAL_HEADER): the encoder opens, produces
packets, and exposes the 5-byte AudioSpecificConfig extradata that the
MP4/MKV muxers need.
make_audio_resampler() only called swr_alloc_set_opts2(), which allocates
the SwrContext and fills in the options but does NOT initialize it; the
context must be passed through swr_init() before swr_convert() will work.

On every audio callback swr_convert() therefore logged 'Context has not
been initialized' and returned an error, so no audio samples were ever
accumulated and saved videos had a video track but no audio track - even
after the AAC encoder itself was fixed to open (sample_fmt/FLTP).

Call swr_init() after allocation and free the context if it fails; the
existing 'Failed to create audio resampler' error path now reports real
initialization failures too.
…writes

The Matroska muxer writes packet timestamps into the file verbatim: its
write_header() rewrites every stream timebase to 1/1000 (the native
TimestampScale is milliseconds) but does not rescale the dts/pts the
application submits. The audio stream was submitted with a 1/48000
timebase and sample-count timestamps, so every audio block landed 48x too
late in the file timeline (a 30 s clip's audio blocks spanned 0..24 min)
and the segment Duration element became ~24 min for a 30 s clip.

Symptom: the MKV played its 30 s of video with no audible audio (each
audio block carried only 21 ms of sound, spaced 1.024 s apart on the
timeline), then sat on the last frame for the remaining ~19 minutes the
player believed the file still had. MP4 was unaffected because the mov
muxer rescales from the stream timebase itself.

Fix in mux_to_file():
- for .mkv, use a 1/1000 stream timebase for both video and audio and
  rescale the packet dts into it (video: us -> ms; audio: samples -> ms),
  while .mp4 keeps the encoder's video timebase and the 1/48000 audio
  timebase it already handled correctly;
- add a strictly-increasing dts guard to the audio packets, mirroring the
  video one;
- submit the video and audio packets interleaved in timestamp order
  instead of writing the whole video stream and then the whole audio
  stream, which laid the Matroska clusters out of time order (all audio
  data physically after all of the video).

Verified with a standalone harness that muxes real H.264 + AAC packets
through the same avformat calls: with sample-count audio dts the MKV
Duration element came out 1441813 ms (24 min) for a 30 s clip, with the
new millisecond stamping the duration is ~30 s and the audio packets
decode at 0..30 s; the MP4 path is unchanged.
… the last

poisoned_encoder held a single name, so if two different hardware
encoders hard-failed in one process, the second overwrote the first and
a later session would re-initialize the first (leaked, possibly still
broken) encoder. A set keeps the poison for every encoder that failed.
Only av_dict_* is used here, which is declared in dict.h; both
avcodec.h and avformat.h include dict.h directly, so opt.h was
redundant. pixdesc.h stays (needed by the Windows build).
Every known hardware encoder emits the extradata and the packets in the
same form (Annex B), and the MP4 conversion is driven by the extradata
form. If a driver ever reported one form while emitting the other, the
MP4 save would be a silent blank file. Logging the first 4 bytes of the
first ring packet next to the extradata makes any such mismatch
immediately visible (NVENC's SPS/PPS payload, in particular, is
driver-produced and unverified).
…dows candidates

h264_mf is the OS-native Media Foundation H.264 encoder (typically the
built-in Microsoft encoder). It ships in the vendored LGPL build (verified
in avcodec-63.dll) and is present on essentially all Windows 8+. When none
of the GPU vendors' encoders (qsv/nvenc/amf) is available, it now yields
real, universally playable H.264 instead of dropping to MPEG-4 Part 2.

It joins the candidate list after h264_amf and before mpeg4. Like the
hardware wrappers it is device-backed, so a runtime failure follows the
same leak-and-poison path as abandon_video_encoder() (only the mpeg4 CPU
fallback is released normally); the existing rotation, poison, and
fallback logic all handle it without changes. The mux-time log lines
(extradata hex + first packet bytes) report its H.264 form on first use.
@jw098
jw098 force-pushed the stream-history-ffmpeg-ring branch from 927370c to dc747ed Compare October 18, 2026 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants