Commit ea536da3 for libheif

commit ea536da34ea423ed4daa47fac0801bc6b93a00ad
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Oct 25 16:38:46 2026 +0100

    Bound sequence decode work without output [GHSA-73j7-gvm9-x8q6]

diff --git a/libheif/sequences/track_visual.cc b/libheif/sequences/track_visual.cc
index 4efda475..258b507e 100644
--- a/libheif/sequences/track_visual.cc
+++ b/libheif/sequences/track_visual.cc
@@ -32,6 +32,35 @@
 #include "codecs/uncompressed/unc_boxes.h"


+namespace {
+
+// One outstanding sample is enough for codecs without frame reordering: after
+// submitting a complete sample, the next poll must return an image or an error.
+// Video codecs may buffer pictures. This is a work budget, not a codec latency
+// claim: each returned frame also pays for one non-displayed sample.
+uint16_t max_sequence_decode_work_debt(heif_compression_format format)
+{
+  switch (format) {
+    case heif_compression_JPEG:
+    case heif_compression_JPEG2000:
+    case heif_compression_HTJ2K:
+    case heif_compression_uncompressed:
+    case heif_compression_mask:
+      return 1;
+    default:
+      return 32;
+  }
+}
+
+Error sequence_decode_work_limit_error()
+{
+  return {heif_error_Invalid_input, heif_suberror_Security_limit_exceeded,
+          "Sequence decoding exceeded the limit for samples without enough output frames."};
+}
+
+} // namespace
+
+
 Track_Visual::Track_Visual(HeifContext* ctx)
   : Track(ctx)
 {
@@ -193,6 +222,10 @@ bool Track_Visual::has_alpha_channel() const

 Result<std::shared_ptr<HeifPixelImage> > Track_Visual::decode_next_image_sample(const heif_decoding_options& options)
 {
+  if (m_decode_work_limit_exceeded) {
+    return sequence_decode_work_limit_error();
+  }
+
   // --- If we ignore the editlist, we stop when we reached the end of the original samples.

   uint64_t num_output_samples = m_num_output_samples;
@@ -223,6 +256,10 @@ Result<std::shared_ptr<HeifPixelImage> > Track_Visual::decode_next_image_sample(

     const std::shared_ptr<Chunk>& chunk = m_chunks[chunk_idx];

+    // TODO: A sample-description change can switch codecs (e.g. HEVC to JPEG 2000).
+    // Before using the new decoder, collect any pending output from the previous
+    // decoder in presentation order. Selecting a decoder by the next input sample
+    // alone does not poll the previous decoder after the switch.
     auto decoder = chunk->get_decoder();
     if (!decoder) {
       // Should not happen: Track_Visual::load() rejects any visual track that has a
@@ -236,6 +273,9 @@ Result<std::shared_ptr<HeifPixelImage> > Track_Visual::decode_next_image_sample(
       };
     }

+    const heif_compression_format format = decoder->get_compression_format();
+    uint16_t& decode_work_debt = m_decode_work_debt[format];
+
     // avoid calling get_decoded_frame() before starting the decoder.
     if (m_next_sample_to_be_decoded != 0) {
       Result<std::shared_ptr<HeifPixelImage> > getFrameResult = decoder->get_decoded_frame(options,
@@ -251,6 +291,10 @@ Result<std::shared_ptr<HeifPixelImage> > Track_Visual::decode_next_image_sample(
       if (*getFrameResult != nullptr) {
         image = *getFrameResult;

+        // Credit the returned frame and one potentially non-displayed sample.
+        // Do this at the decoder boundary, even if later image postprocessing fails.
+        decode_work_debt = (decode_work_debt > 2) ? decode_work_debt - 2 : 0;
+
         // If this was the last frame in the EditList segment, reset the 'flushed' flag
         // in case we have to restart the decoder for another repetition of the segment.
         if ((m_next_sample_to_be_output + 1) % m_presentation_timeline.size() == 0) {
@@ -272,7 +316,12 @@ Result<std::shared_ptr<HeifPixelImage> > Track_Visual::decode_next_image_sample(

     // --- Push more data into the decoder (or send end-of-sequence).

-    if (m_next_sample_to_be_decoded < m_num_output_samples) {
+    if (m_next_sample_to_be_decoded < num_output_samples) {
+
+      if (decode_work_debt >= max_sequence_decode_work_debt(format)) {
+        m_decode_work_limit_exceeded = true;
+        return sequence_decode_work_limit_error();
+      }

       // --- Find the data extent that stores the compressed frame data.

@@ -296,6 +345,8 @@ Result<std::shared_ptr<HeifPixelImage> > Track_Visual::decode_next_image_sample(
       if (decodingError) {
         return decodingError;
       }
+
+      decode_work_debt++;
     }
     else {
       // --- End of sequence (Editlist segment) reached.
diff --git a/libheif/sequences/track_visual.h b/libheif/sequences/track_visual.h
index bb683089..0cff7c96 100644
--- a/libheif/sequences/track_visual.h
+++ b/libheif/sequences/track_visual.h
@@ -96,6 +96,11 @@ private:
   heif_encoder* m_active_encoder = nullptr;
   std::unique_ptr<heif_encoder> m_alpha_track_encoder;

+  // Separate amortized work budgets for each compression format. A frame credits
+  // its own format only; balances saturate at zero and persist across decode calls.
+  std::map<heif_compression_format, uint16_t> m_decode_work_debt;
+  bool m_decode_work_limit_exceeded = false;
+
   Result<bool> process_encoded_data(heif_encoder* encoder);
 };

diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 7a52053e..98bd28dc 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -145,6 +145,7 @@ add_libheif_test(sequence_vfr_durations)
 add_libheif_test(track_reference_duplicates)
 add_libheif_test(sequence_raw_sample_errors)
 add_libheif_test(sequence_nonvisual_sample_entry)
+add_libheif_test(sequence_no_decoder_output)
 add_libheif_test(tai)
 add_libheif_test(text)
 add_libheif_test(cxx_wrapper)
diff --git a/tests/sequence_file_builder.h b/tests/sequence_file_builder.h
index 8ed566c4..3a0f5344 100644
--- a/tests/sequence_file_builder.h
+++ b/tests/sequence_file_builder.h
@@ -24,10 +24,9 @@
   SOFTWARE.
 */

-// Builds minimal, structurally valid HEIF sequence files containing a single
-// 'urim' metadata track, following the box tree that Track::load() requires.
-// A metadata track is used so that the raw-sample API path can be exercised
-// without any codec plugin. Shared by the sequence_* tests.
+// Builds minimal, structurally valid HEIF sequence files following the box
+// tree that Track::load() requires. Defaults to a 'urim' metadata track for
+// raw-sample API tests; visual-track tests can override its sample entries.

 #ifndef LIBHEIF_TESTS_SEQUENCE_FILE_BUILDER_H
 #define LIBHEIF_TESTS_SEQUENCE_FILE_BUILDER_H
@@ -124,12 +123,22 @@ struct SequenceFileParams

   uint32_t num_samples = 1;
   uint32_t sample_size = 1;
+  std::vector<uint8_t> sample_bytes; // if set, repeat these bytes for every sample
+
+  // Override these two to build a track of a different kind, e.g. a 'pict' track with 'hvc1' samples.
+  const char* handler_type = "meta";
+  std::vector<uint8_t> sample_entry; // complete sample entry box, empty -> 'urim'
+  uint32_t first_chunk_num_samples = 0; // nonzero -> split into two chunks
+  std::vector<uint8_t> second_sample_entry;
+  std::vector<uint8_t> second_sample_bytes; // same length as sample_bytes

   std::vector<SampleAuxInfo> aux_infos;
 };

 inline std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)
 {
+  const uint32_t sample_size = p.sample_bytes.empty() ? p.sample_size : static_cast<uint32_t>(p.sample_bytes.size());
+  const bool two_chunks = p.first_chunk_num_samples != 0;
   // --- ftyp
   std::vector<uint8_t> ftyp_payload;
   put_fourcc(ftyp_payload, "msf1"); // major brand: HEIF image sequence
@@ -206,11 +215,11 @@ inline std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)
   put16(mdhd_payload, 0);          // pre_defined
   std::vector<uint8_t> mdhd = box("mdhd", mdhd_payload);

-  // --- hdlr (handler type 'meta')
+  // --- hdlr (handler type 'meta' by default)
   std::vector<uint8_t> hdlr_payload;
   put32(hdlr_payload, 0x00000000); // version 0, flags 0
   put32(hdlr_payload, 0);          // pre_defined
-  put_fourcc(hdlr_payload, "meta");
+  put_fourcc(hdlr_payload, p.handler_type);
   put32(hdlr_payload, 0);          // reserved
   put32(hdlr_payload, 0);          // reserved
   put32(hdlr_payload, 0);          // reserved
@@ -222,7 +231,7 @@ inline std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)
   put32(nmhd_payload, 0x00000000); // version 0, flags 0
   std::vector<uint8_t> nmhd = box("nmhd", nmhd_payload);

-  // --- stsd with a single 'urim' (URI meta) sample entry
+  // --- stsd with one or two sample entries ('urim', URI meta, by default)
   std::vector<uint8_t> urim_payload;
   for (int i = 0; i < 6; i++) urim_payload.push_back(0); // SampleEntry reserved
   put16(urim_payload, 1);                                // data_reference_index
@@ -230,8 +239,9 @@ inline std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)

   std::vector<uint8_t> stsd_payload;
   put32(stsd_payload, 0x00000000); // version 0, flags 0
-  put32(stsd_payload, 1);          // entry_count
-  append(stsd_payload, urim);
+  put32(stsd_payload, two_chunks ? 2 : 1); // entry_count
+  append(stsd_payload, p.sample_entry.empty() ? urim : p.sample_entry);
+  if (two_chunks) append(stsd_payload, p.second_sample_entry);
   std::vector<uint8_t> stsd = box("stsd", stsd_payload);

   // --- stts
@@ -244,19 +254,24 @@ inline std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)
   }
   std::vector<uint8_t> stts = box("stts", stts_payload);

-  // --- stsc: all samples in a single chunk
+  // --- stsc: one or two chunks, each with its own sample description
   std::vector<uint8_t> stsc_payload;
   put32(stsc_payload, 0x00000000); // version 0, flags 0
-  put32(stsc_payload, 1);          // entry_count
+  put32(stsc_payload, two_chunks ? 2 : 1); // entry_count
   put32(stsc_payload, 1);          // first_chunk
-  put32(stsc_payload, p.num_samples); // samples_per_chunk
+  put32(stsc_payload, two_chunks ? p.first_chunk_num_samples : p.num_samples); // samples_per_chunk
   put32(stsc_payload, 1);          // sample_description_index
+  if (two_chunks) {
+    put32(stsc_payload, 2); // first_chunk
+    put32(stsc_payload, p.num_samples - p.first_chunk_num_samples);
+    put32(stsc_payload, 2); // sample_description_index
+  }
   std::vector<uint8_t> stsc = box("stsc", stsc_payload);

   // --- stsz: fixed sample size
   std::vector<uint8_t> stsz_payload;
   put32(stsz_payload, 0x00000000);  // version 0, flags 0
-  put32(stsz_payload, p.sample_size); // fixed sample size (non-zero -> no per-sample array)
+  put32(stsz_payload, sample_size); // fixed sample size (non-zero -> no per-sample array)
   put32(stsz_payload, p.num_samples); // sample_count
   std::vector<uint8_t> stsz = box("stsz", stsz_payload);

@@ -273,11 +288,11 @@ inline std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)
   }

   // --- stco / saio: offsets are patched below to point into the mdat payload
-  auto make_stco = [](uint32_t offset) {
+  auto make_stco = [](const std::vector<uint32_t>& offsets) {
     std::vector<uint8_t> stco_payload;
     put32(stco_payload, 0x00000000); // version 0, flags 0
-    put32(stco_payload, 1);          // entry_count
-    put32(stco_payload, offset);     // chunk offset
+    put32(stco_payload, static_cast<uint32_t>(offsets.size())); // entry_count
+    for (uint32_t offset : offsets) put32(stco_payload, offset);
     return box("stco", stco_payload);
   };

@@ -306,9 +321,12 @@ inline std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)

   // --- mdat payload: num_samples * sample_size bytes of arbitrary content,
   //     followed by one block of aux-info payloads per saiz/saio pair.
-  std::vector<uint8_t> mdat_payload(static_cast<size_t>(p.num_samples) * p.sample_size);
+  std::vector<uint8_t> mdat_payload(static_cast<size_t>(p.num_samples) * sample_size);
   for (size_t i = 0; i < mdat_payload.size(); i++) {
-    mdat_payload[i] = static_cast<uint8_t>(0xA0 + (i & 0x0f));
+    const auto& sample_bytes = (two_chunks && i >= static_cast<size_t>(p.first_chunk_num_samples) * sample_size)
+                               ? p.second_sample_bytes : p.sample_bytes;
+    mdat_payload[i] = sample_bytes.empty() ? static_cast<uint8_t>(0xA0 + (i & 0x0f))
+                                          : sample_bytes[i % sample_size];
   }

   std::vector<size_t> aux_block_offsets; // relative to the start of the mdat payload
@@ -325,7 +343,8 @@ inline std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)
   for (const auto& aux : p.aux_infos) {
     saio_placeholders.push_back(make_saio(aux, 0));
   }
-  std::vector<uint8_t> moov0 = assemble(make_stco(0), saio_placeholders);
+  std::vector<uint8_t> moov0 = assemble(make_stco(two_chunks ? std::vector<uint32_t>{0, 0}
+                                                              : std::vector<uint32_t>{0}), saio_placeholders);
   uint32_t mdat_payload_offset = static_cast<uint32_t>(ftyp.size() + moov0.size() + 8 /* mdat header */);
   uint32_t file_size = static_cast<uint32_t>(mdat_payload_offset + mdat_payload.size());

@@ -336,7 +355,9 @@ inline std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)
                       : mdat_payload_offset + static_cast<uint32_t>(aux_block_offsets[i]);
     saio_boxes.push_back(make_saio(p.aux_infos[i], offset));
   }
-  std::vector<uint8_t> moov = assemble(make_stco(mdat_payload_offset), saio_boxes);
+  std::vector<uint32_t> chunk_offsets = {mdat_payload_offset};
+  if (two_chunks) chunk_offsets.push_back(mdat_payload_offset + p.first_chunk_num_samples * sample_size);
+  std::vector<uint8_t> moov = assemble(make_stco(chunk_offsets), saio_boxes);

   std::vector<uint8_t> mdat = box("mdat", mdat_payload);

diff --git a/tests/sequence_no_decoder_output.cc b/tests/sequence_no_decoder_output.cc
new file mode 100644
index 00000000..55391975
--- /dev/null
+++ b/tests/sequence_no_decoder_output.cc
@@ -0,0 +1,518 @@
+/*
+  libheif regression test: a visual sequence track whose samples contain no picture,
+  combined with an edit list in "repeat" mode. The decoder accepts the samples, but it
+  never outputs a frame. A single heif_track_decode_next_image() call then pushed the
+  same samples again and again, up to max_sequence_frames times, before it gave up.
+
+  MIT License
+
+  Copyright (c) 2026 Dirk Farin <dirk.farin@gmail.com>
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in all
+  copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+  SOFTWARE.
+*/
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "libheif/heif_plugin.h"
+#include "libheif/heif_sequences.h"
+#include "sequence_file_builder.h"
+
+#include <cstdint>
+#include <limits>
+#include <vector>
+
+using namespace seqfile;
+
+namespace {
+
+// --- A mock HEVC decoder plugin, so that the test does not depend on the installed codecs
+//     and can count how often data is pushed. It ignores the data. It outputs one frame for
+//     each 'mock_output_divisor' pushed samples after an initial latency, or no frames at all.
+
+const int no_output = -1;
+
+int mock_latency = no_output;
+int mock_output_divisor = 1;
+int mock_output_multiplier = 1;
+int mock_num_pushed = 0;
+int mock_num_output = 0;
+bool mock_flushed = false;
+
+const heif_error mock_ok = {heif_error_Ok, heif_suberror_Unspecified, "Success"};
+
+const char* mock_plugin_name() { return "mock decoder"; }
+
+int mock_does_support_format(heif_compression_format format)
+{
+  return (format == heif_compression_HEVC || format == heif_compression_JPEG) ? 1000 : 0;
+}
+
+int mock_does_support_format2(const heif_decoder_plugin_compressed_format_description* desc)
+{
+  return mock_does_support_format(desc->format);
+}
+
+heif_error mock_new_decoder(void** decoder)
+{
+  *decoder = &mock_num_pushed; // any non-NULL pointer
+  return mock_ok;
+}
+
+heif_error mock_new_decoder2(void** decoder, const heif_decoder_plugin_options*)
+{
+  return mock_new_decoder(decoder);
+}
+
+void mock_free_decoder(void*) {}
+
+heif_error mock_push_data2(void*, const void*, size_t, uintptr_t)
+{
+  mock_num_pushed++;
+  return mock_ok;
+}
+
+heif_error mock_push_data(void* decoder, const void* data, size_t size)
+{
+  return mock_push_data2(decoder, data, size, 0);
+}
+
+heif_error mock_flush_data(void*)
+{
+  mock_flushed = true;
+  return mock_ok;
+}
+
+heif_error mock_decode_next_image2(void*, heif_image** out_img, uintptr_t* out_user_data, const heif_security_limits*)
+{
+  *out_img = nullptr;
+
+  int latency = mock_flushed ? 0 : mock_latency;
+  if (mock_latency == no_output || mock_num_pushed <= latency ||
+      mock_num_output >= (mock_num_pushed - latency) * mock_output_multiplier / mock_output_divisor) {
+    return mock_ok;
+  }
+
+  heif_error err = heif_image_create(4, 4, heif_colorspace_monochrome, heif_chroma_monochrome, out_img);
+  if (err.code) {
+    return err;
+  }
+
+  err = heif_image_add_plane(*out_img, heif_channel_Y, 4, 4, 8);
+  if (err.code) {
+    return err;
+  }
+
+  if (out_user_data) {
+    *out_user_data = 0;
+  }
+
+  mock_num_output++;
+  return mock_ok;
+}
+
+heif_error mock_decode_next_image(void* decoder, heif_image** out_img, const heif_security_limits* limits)
+{
+  return mock_decode_next_image2(decoder, out_img, nullptr, limits);
+}
+
+heif_error mock_decode_image(void* decoder, heif_image** out_img)
+{
+  return mock_decode_next_image2(decoder, out_img, nullptr, nullptr);
+}
+
+void mock_set_strict_decoding(void*, int) {}
+
+const heif_decoder_plugin mock_decoder_plugin{
+  5,
+  mock_plugin_name,
+  nullptr, // init_plugin
+  nullptr, // deinit_plugin
+  mock_does_support_format,
+  mock_new_decoder,
+  mock_free_decoder,
+  mock_push_data,
+  mock_decode_image,
+  mock_set_strict_decoding,
+  "mock",
+  mock_decode_next_image,
+  LIBHEIF_MAKE_VERSION(1, 21, 0),
+  mock_does_support_format2,
+  mock_new_decoder2,
+  mock_push_data2,
+  mock_flush_data,
+  mock_decode_next_image2
+};
+
+void start_mock_decoder(int latency, int output_divisor = 1, int output_multiplier = 1)
+{
+  static bool registered = false;
+  if (!registered) {
+    REQUIRE(heif_register_decoder_plugin(&mock_decoder_plugin).code == heif_error_Ok);
+    registered = true;
+  }
+
+  mock_latency = latency;
+  mock_output_divisor = output_divisor;
+  mock_output_multiplier = output_multiplier;
+  mock_num_pushed = 0;
+  mock_num_output = 0;
+  mock_flushed = false;
+}
+
+
+// A minimal visual sample entry. For HEVC, include an hvcC box without parameter sets.
+std::vector<uint8_t> make_visual_entry(const char* type, bool with_hevc_config)
+{
+  std::vector<uint8_t> vse;
+  for (int i = 0; i < 6; i++) vse.push_back(0); // SampleEntry reserved
+  put16(vse, 1);                                // data_reference_index
+  put16(vse, 0);                                // pre_defined
+  put16(vse, 0);                                // reserved
+  for (int i = 0; i < 3; i++) put32(vse, 0);    // pre_defined
+  put16(vse, 4);                                // width
+  put16(vse, 4);                                // height
+  put32(vse, 0x00480000);                       // horizresolution 72 dpi
+  put32(vse, 0x00480000);                       // vertresolution 72 dpi
+  put32(vse, 0);                                // reserved
+  put16(vse, 1);                                // frame_count
+  for (int i = 0; i < 32; i++) vse.push_back(0);// compressorname (length-prefixed, empty)
+  put16(vse, 0x0018);                           // depth
+  put16(vse, 0xFFFF);                           // pre_defined (-1)
+
+  if (with_hevc_config) {
+    // HEVCDecoderConfigurationRecord, 23 bytes, zero NAL arrays.
+    std::vector<uint8_t> hvcc;
+    hvcc.push_back(1);        // configuration_version
+    hvcc.push_back(0);        // profile_space / tier / profile_idc
+    put32(hvcc, 0);           // general_profile_compatibility_flags
+    for (int i = 0; i < 6; i++) hvcc.push_back(0); // general_constraint_indicator_flags
+    hvcc.push_back(0);        // general_level_idc
+    put16(hvcc, 0);           // min_spatial_segmentation_idc
+    hvcc.push_back(0);        // parallelism_type
+    hvcc.push_back(1);        // chroma_format (4:2:0)
+    hvcc.push_back(0);        // bit_depth_luma_minus8
+    hvcc.push_back(0);        // bit_depth_chroma_minus8
+    put16(hvcc, 0);           // avg_frame_rate
+    hvcc.push_back(3);        // constant_frame_rate / temporal layers / length_size
+    hvcc.push_back(0);        // num_of_arrays
+    append(vse, box("hvcC", hvcc));
+  }
+
+  return box(type, vse);
+}
+
+// A 'pict' track with 'num_samples' samples that is repeated endlessly.
+std::vector<uint8_t> build_endless_visual_sequence(uint32_t num_samples)
+{
+  SequenceFileParams p;
+  p.mvhd_duration = std::numeric_limits<uint64_t>::max(); // indefinite sentinel
+  p.mdhd_duration = num_samples;
+  p.elst_segment_duration = num_samples;
+  p.stts = {{num_samples, 1}};
+  p.num_samples = num_samples;
+  p.handler_type = "pict";
+  p.sample_entry = make_visual_entry("hvc1", true);
+  // A length-prefixed HEVC access unit containing only an AUD, no picture.
+  p.sample_bytes = {0, 0, 0, 2, 0x46, 0x01};
+
+  return build_sequence_file(p);
+}
+
+std::vector<uint8_t> build_endless_jpeg_sequence()
+{
+  SequenceFileParams p;
+  p.mvhd_duration = std::numeric_limits<uint64_t>::max();
+  p.mdhd_duration = 1;
+  p.elst_segment_duration = 1;
+  p.stts = {{1, 1}};
+  p.handler_type = "pict";
+  p.sample_entry = make_visual_entry("mjpg", false);
+  // Enough JPEG SOF data for the coded-size check; the mock decoder ignores it.
+  p.sample_bytes = {0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08,
+                    0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x11,
+                    0x00, 0xff, 0xd9};
+  return build_sequence_file(p);
+}
+
+std::vector<uint8_t> build_endless_hevc_then_jpeg_sequence()
+{
+  SequenceFileParams p;
+  p.mvhd_duration = std::numeric_limits<uint64_t>::max();
+  p.mdhd_duration = 12;
+  p.elst_segment_duration = 12;
+  p.stts = {{12, 1}};
+  p.num_samples = 12;
+  p.first_chunk_num_samples = 10;
+  p.handler_type = "pict";
+  p.sample_entry = make_visual_entry("hvc1", true);
+  p.second_sample_entry = make_visual_entry("mjpg", false);
+  // Both sample descriptions use 17-byte samples because stsz has a fixed size.
+  p.sample_bytes = {0, 0, 0, 13, 0x46, 0x01, 0, 0, 0,
+                    0, 0, 0, 0, 0, 0, 0, 0};
+  p.second_sample_bytes = {0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08,
+                           0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x11,
+                           0x00, 0xff, 0xd9};
+  return build_sequence_file(p);
+}
+
+} // namespace
+
+
+TEST_CASE("repeated samples without decoder output are not pushed endlessly")
+{
+  const uint32_t num_samples = 3;
+  std::vector<uint8_t> file = build_endless_visual_sequence(num_samples);
+
+  start_mock_decoder(no_output);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+
+  // Lower the frame limit (default: 18 million) so that a regression fails quickly.
+  heif_context_get_security_limits(ctx)->max_sequence_frames = 100000;
+
+  heif_error err = heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_track* track = heif_context_get_track(ctx, 0);
+  REQUIRE(track != nullptr);
+  REQUIRE(heif_track_get_number_of_repetitions(track) ==
+          heif_sequence_track_number_of_repetitions_infinite);
+
+  heif_image* img = nullptr;
+  err = heif_track_decode_next_image(track, &img, heif_colorspace_undefined, heif_chroma_undefined, nullptr);
+  INFO("error: " << err.message << ", subcode: " << err.subcode << ", pushed: " << mock_num_pushed);
+  REQUIRE(err.code == heif_error_Invalid_input);
+  REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+  REQUIRE(img == nullptr);
+
+  // The decoder has seen all samples, but only a small number of repetitions.
+  // Before the fix, this was max_sequence_frames.
+  REQUIRE(mock_num_pushed == 32);
+
+  // Asking again does not start over.
+  int num_pushed_in_first_call = mock_num_pushed;
+  err = heif_track_decode_next_image(track, &img, heif_colorspace_undefined, heif_chroma_undefined, nullptr);
+  REQUIRE(err.code == heif_error_Invalid_input);
+  REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+  REQUIRE(mock_num_pushed == num_pushed_in_first_call);
+
+  heif_track_release(track);
+  heif_context_free(ctx);
+}
+
+
+TEST_CASE("JPEG sequence stops after one sample without output")
+{
+  std::vector<uint8_t> file = build_endless_jpeg_sequence();
+  start_mock_decoder(no_output);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+  REQUIRE(heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr).code == heif_error_Ok);
+  heif_track* track = heif_context_get_track(ctx, 0);
+  REQUIRE(track != nullptr);
+
+  heif_image* img = nullptr;
+  heif_error err = heif_track_decode_next_image(track, &img, heif_colorspace_undefined,
+                                                 heif_chroma_undefined, nullptr);
+  REQUIRE(err.code == heif_error_Invalid_input);
+  REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+  REQUIRE(img == nullptr);
+  REQUIRE(mock_num_pushed == 1);
+
+  heif_track_release(track);
+  heif_context_free(ctx);
+}
+
+
+TEST_CASE("HEVC debt does not exhaust the JPEG budget at a codec change")
+{
+  std::vector<uint8_t> file = build_endless_hevc_then_jpeg_sequence();
+  start_mock_decoder(no_output);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+  REQUIRE(heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr).code == heif_error_Ok);
+  heif_track* track = heif_context_get_track(ctx, 0);
+  REQUIRE(track != nullptr);
+
+  heif_image* img = nullptr;
+  heif_error err = heif_track_decode_next_image(track, &img, heif_colorspace_undefined,
+                                                 heif_chroma_undefined, nullptr);
+  REQUIRE(err.code == heif_error_Invalid_input);
+  REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+  REQUIRE(img == nullptr);
+  // Ten HEVC samples and one JPEG sample are accepted. The next JPEG sample
+  // exceeds JPEG's own budget of one; HEVC's outstanding debt is not charged to it.
+  REQUIRE(mock_num_pushed == 11);
+
+  heif_track_release(track);
+  heif_context_free(ctx);
+}
+
+
+TEST_CASE("decoder latency may exceed the length of the repeated segment")
+{
+  // The first frame comes out only after the single sample was repeated many times.
+  std::vector<uint8_t> file = build_endless_visual_sequence(1);
+
+  start_mock_decoder(24);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+
+  heif_error err = heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_track* track = heif_context_get_track(ctx, 0);
+  REQUIRE(track != nullptr);
+
+  for (int i = 0; i < 200; i++) {
+    heif_image* img = nullptr;
+    err = heif_track_decode_next_image(track, &img, heif_colorspace_undefined, heif_chroma_undefined, nullptr);
+    INFO("error: " << err.message << ", subcode: " << err.subcode << ", pushed: " << mock_num_pushed);
+    REQUIRE(err.code == heif_error_Ok);
+    REQUIRE(img != nullptr);
+    heif_image_release(img);
+  }
+
+  heif_track_release(track);
+  heif_context_free(ctx);
+}
+
+
+TEST_CASE("one non-displayed sample per decoded frame stays within the work budget")
+{
+  std::vector<uint8_t> file = build_endless_visual_sequence(1);
+  start_mock_decoder(0, 2);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+  REQUIRE(heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr).code == heif_error_Ok);
+  heif_track* track = heif_context_get_track(ctx, 0);
+  REQUIRE(track != nullptr);
+
+  for (int i = 0; i < 300; i++) {
+    heif_image* img = nullptr;
+    heif_error err = heif_track_decode_next_image(track, &img, heif_colorspace_undefined,
+                                                   heif_chroma_undefined, nullptr);
+    REQUIRE(err.code == heif_error_Ok);
+    REQUIRE(img != nullptr);
+    heif_image_release(img);
+  }
+
+  REQUIRE(mock_num_pushed == 600);
+  heif_track_release(track);
+  heif_context_free(ctx);
+}
+
+
+TEST_CASE("extra output frames do not create credit for future samples")
+{
+  std::vector<uint8_t> file = build_endless_visual_sequence(1);
+  start_mock_decoder(0, 1, 2);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+  REQUIRE(heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr).code == heif_error_Ok);
+  heif_track* track = heif_context_get_track(ctx, 0);
+  REQUIRE(track != nullptr);
+
+  for (int i = 0; i < 200; i++) {
+    heif_image* img = nullptr;
+    heif_error err = heif_track_decode_next_image(track, &img, heif_colorspace_undefined,
+                                                   heif_chroma_undefined, nullptr);
+    REQUIRE(err.code == heif_error_Ok);
+    REQUIRE(img != nullptr);
+    heif_image_release(img);
+  }
+
+  REQUIRE(mock_num_pushed == 100);
+  heif_track_release(track);
+  heif_context_free(ctx);
+}
+
+
+TEST_CASE("more than one non-displayed sample per frame eventually exhausts the work budget")
+{
+  std::vector<uint8_t> file = build_endless_visual_sequence(1);
+  start_mock_decoder(0, 3);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+  REQUIRE(heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr).code == heif_error_Ok);
+  heif_track* track = heif_context_get_track(ctx, 0);
+  REQUIRE(track != nullptr);
+
+  heif_error err{};
+  heif_image* img = nullptr;
+  for (int i = 0; i < 300; i++) {
+    err = heif_track_decode_next_image(track, &img, heif_colorspace_undefined,
+                                       heif_chroma_undefined, nullptr);
+    if (err.code != heif_error_Ok) {
+      break;
+    }
+    REQUIRE(img != nullptr);
+    heif_image_release(img);
+    img = nullptr;
+  }
+
+  REQUIRE(err.code == heif_error_Invalid_input);
+  REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+  REQUIRE(img == nullptr);
+  REQUIRE(mock_num_pushed <= 2 * mock_num_output + 32);
+
+  int num_pushed_before_retry = mock_num_pushed;
+  err = heif_track_decode_next_image(track, &img, heif_colorspace_undefined,
+                                     heif_chroma_undefined, nullptr);
+  REQUIRE(err.code == heif_error_Invalid_input);
+  REQUIRE(mock_num_pushed == num_pushed_before_retry);
+
+  heif_track_release(track);
+  heif_context_free(ctx);
+}
+
+
+TEST_CASE("ignoring an edit list stops at the physical sample count")
+{
+  std::vector<uint8_t> file = build_endless_visual_sequence(3);
+  start_mock_decoder(no_output);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+  REQUIRE(heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr).code == heif_error_Ok);
+  heif_track* track = heif_context_get_track(ctx, 0);
+  REQUIRE(track != nullptr);
+
+  heif_decoding_options* options = heif_decoding_options_alloc();
+  REQUIRE(options != nullptr);
+  options->ignore_sequence_editlist = 1;
+
+  heif_image* img = nullptr;
+  heif_error err = heif_track_decode_next_image(track, &img, heif_colorspace_undefined,
+                                                 heif_chroma_undefined, options);
+  REQUIRE(err.code == heif_error_Decoder_plugin_error);
+  REQUIRE(img == nullptr);
+  REQUIRE(mock_num_pushed == 3);
+
+  heif_decoding_options_free(options);
+  heif_track_release(track);
+  heif_context_free(ctx);
+}