Commit f17635b1 for libheif

commit f17635b1a02c6ac2fc1edf84900f2bf1718c7499
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Mon Sep 21 15:30:47 2026 +0200

    Visual sequences: look up the frame duration by the output position

    Track_Visual::decode_next_image_sample() assigned each decoded frame the
    duration of the sample at sample_idx_in_chunk. That index follows
    m_next_sample_to_be_decoded, which runs ahead of the output by the decoder
    latency, so every frame received the duration of the next pushed sample
    and the last frame wrapped around to sample 0. A variable frame rate
    sequence with the durations {100, 250, 400} decoded as {250, 400, 100}.

    Look the duration up by the output position through the presentation
    timeline instead, like Track::get_next_sample_raw_data() already does.

    Reported by @twn39 in #1914, whose regression test this includes. The PR
    also re-applied the duration after the colour conversions on the encode
    and decode paths. Those parts were dropped because the conversion
    pipeline already carries the sample duration over in copy_metadata_from().

diff --git a/libheif/sequences/track_visual.cc b/libheif/sequences/track_visual.cc
index 92add576..2ed1ad25 100644
--- a/libheif/sequences/track_visual.cc
+++ b/libheif/sequences/track_visual.cc
@@ -286,7 +286,11 @@ Result<std::shared_ptr<HeifPixelImage> > Track_Visual::decode_next_image_sample(
   //     Postprocess decoded image, attach metadata.

   if (m_stts) {
-    image->set_sample_duration(m_stts->get_sample_duration(sample_idx_in_chunk));
+    // 'sample_idx_in_chunk' follows m_next_sample_to_be_decoded, which runs ahead of the output
+    // by the decoder latency. Look the duration up by the output position instead, like
+    // Track::get_next_sample_raw_data() does.
+    const SampleTiming& outputTiming = m_presentation_timeline[m_next_sample_to_be_output % m_presentation_timeline.size()];
+    image->set_sample_duration(m_stts->get_sample_duration(outputTiming.sampleIdx));
   }

   // --- assign alpha if we have an assigned alpha track
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index b2a9c7f5..ed7941f5 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -117,6 +117,7 @@ add_libheif_test(sequence_mixed_bit_depth)
 add_libheif_test(sequence_no_track)
 add_libheif_test(sequence_null_options)
 add_libheif_test(sequence_timing_overflow)
+add_libheif_test(sequence_vfr_durations)
 add_libheif_test(track_reference_duplicates)
 add_libheif_test(sequence_raw_sample_errors)
 add_libheif_test(tai)
diff --git a/tests/sequence_vfr_durations.cc b/tests/sequence_vfr_durations.cc
new file mode 100644
index 00000000..dc0f031f
--- /dev/null
+++ b/tests/sequence_vfr_durations.cc
@@ -0,0 +1,175 @@
+/*
+  libheif regression test for the per-frame durations of visual sequences.
+
+  MIT License
+
+  Copyright (c) 2026 libheif contributors
+
+  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.
+*/
+
+// Track_Visual::decode_next_image_sample() looked up the duration of a decoded
+// frame with the index of the sample that was pushed into the decoder last. That
+// index runs ahead of the output by the decoder latency, so every frame reported
+// the duration of the next sample and the last frame wrapped around to sample 0:
+// a sequence with the durations {100, 250, 400} decoded as {250, 400, 100}.
+//
+// The test encodes a variable frame rate sequence and checks the durations after
+// decoding in the native colorspace and after a conversion to RGB, which creates
+// a new image that has to carry the duration over.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "libheif/heif_sequences.h"
+#include "test_utils.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <vector>
+
+namespace {
+
+constexpr uint32_t WIDTH = 64;
+constexpr uint32_t HEIGHT = 64;
+
+heif_error mem_writer(heif_context*, const void* data, size_t size, void* userdata)
+{
+  auto* out = static_cast<std::vector<uint8_t>*>(userdata);
+  const auto* p = static_cast<const uint8_t*>(data);
+  out->insert(out->end(), p, p + size);
+  return heif_error{heif_error_Ok, heif_suberror_Unspecified, nullptr};
+}
+
+heif_image* make_gray_image(uint32_t duration, uint8_t value)
+{
+  heif_image* img = nullptr;
+  REQUIRE(heif_image_create(WIDTH, HEIGHT, heif_colorspace_RGB, heif_chroma_interleaved_RGB, &img).code == heif_error_Ok);
+  REQUIRE(img != nullptr);
+
+  REQUIRE(heif_image_add_plane(img, heif_channel_interleaved, WIDTH, HEIGHT, 8).code == heif_error_Ok);
+
+  size_t stride = 0;
+  uint8_t* p = heif_image_get_plane2(img, heif_channel_interleaved, &stride);
+  REQUIRE(p != nullptr);
+
+  for (uint32_t y = 0; y < HEIGHT; ++y) {
+    std::fill(p + y * stride, p + y * stride + WIDTH * 3, value);
+  }
+
+  heif_image_set_duration(img, duration);
+  return img;
+}
+
+std::vector<uint8_t> encode_vfr_sequence(const std::vector<uint32_t>& durations, heif_compression_format format)
+{
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+
+  heif_encoder* encoder = nullptr;
+  REQUIRE(heif_context_get_encoder_for_format(ctx, format, &encoder).code == heif_error_Ok);
+  REQUIRE(encoder != nullptr);
+
+  heif_track* track = nullptr;
+  heif_error err = heif_context_add_visual_sequence_track(ctx, (uint16_t) WIDTH, (uint16_t) HEIGHT,
+                                                          heif_track_type_image_sequence,
+                                                          nullptr, nullptr, &track);
+  INFO("add_visual_sequence_track: " << (err.message ? err.message : ""));
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(track != nullptr);
+
+  for (size_t i = 0; i < durations.size(); ++i) {
+    heif_image* img = make_gray_image(durations[i], static_cast<uint8_t>((i + 1) * 60));
+
+    err = heif_track_encode_sequence_image(track, img, encoder, nullptr);
+    INFO("encode_sequence_image: " << (err.message ? err.message : ""));
+    REQUIRE(err.code == heif_error_Ok);
+    heif_image_release(img);
+  }
+
+  err = heif_track_encode_end_of_sequence(track, encoder);
+  REQUIRE(err.code == heif_error_Ok);
+
+  std::vector<uint8_t> file;
+  heif_writer writer{};
+  writer.writer_api_version = 1;
+  writer.write = mem_writer;
+  REQUIRE(heif_context_write(ctx, &writer, &file).code == heif_error_Ok);
+
+  heif_track_release(track);
+  heif_encoder_release(encoder);
+  heif_context_free(ctx);
+
+  return file;
+}
+
+std::vector<uint32_t> decode_durations(const std::vector<uint8_t>& file,
+                                       heif_colorspace colorspace, heif_chroma chroma)
+{
+  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);
+  REQUIRE(heif_context_has_sequence(ctx) == 1);
+
+  heif_track* track = heif_context_get_track(ctx, 0);
+  REQUIRE(track != nullptr);
+
+  std::vector<uint32_t> durations;
+  for (;;) {
+    heif_image* img = nullptr;
+    heif_error err = heif_track_decode_next_image(track, &img, colorspace, chroma, nullptr);
+    if (err.code == heif_error_End_of_sequence) {
+      break;
+    }
+    INFO("decode_next_image: " << (err.message ? err.message : ""));
+    REQUIRE(err.code == heif_error_Ok);
+    REQUIRE(img != nullptr);
+
+    durations.push_back(heif_image_get_duration(img));
+    heif_image_release(img);
+  }
+
+  heif_track_release(track);
+  heif_context_free(ctx);
+
+  return durations;
+}
+
+} // namespace
+
+
+TEST_CASE("variable frame rate sequence reports the per-frame durations")
+{
+  heif_compression_format format = heif_compression_undefined;
+  for (heif_compression_format candidate : {heif_compression_AV1, heif_compression_HEVC}) {
+    if (heif_have_encoder_for_format(candidate) && heif_have_decoder_for_format(candidate)) {
+      format = candidate;
+      break;
+    }
+  }
+  if (format == heif_compression_undefined) {
+    SKIP("No AV1 or HEVC encoder/decoder pair available, skipping test");
+  }
+
+  const std::vector<uint32_t> expected_durations = {100, 250, 400};
+  std::vector<uint8_t> file = encode_vfr_sequence(expected_durations, format);
+  REQUIRE(!file.empty());
+
+  CHECK(decode_durations(file, heif_colorspace_undefined, heif_chroma_undefined) == expected_durations);
+  CHECK(decode_durations(file, heif_colorspace_RGB, heif_chroma_interleaved_RGB) == expected_durations);
+}