Commit c2b38c17 for libheif

commit c2b38c17adb1145dac622a5385611ecfcbbd5469
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Thu Dec 18 20:53:50 2025 +0100

    [BSD3] write and parse 'ctts' box

diff --git a/libheif/box.cc b/libheif/box.cc
index 99b751a0..b47f289e 100644
--- a/libheif/box.cc
+++ b/libheif/box.cc
@@ -803,6 +803,10 @@ Error Box::read(BitstreamRange& range, std::shared_ptr<Box>* result, const heif_
       box = std::make_shared<Box_stts>();
       break;

+    case fourcc("ctts"):
+      box = std::make_shared<Box_ctts>();
+      break;
+
     case fourcc("stsc"):
       box = std::make_shared<Box_stsc>();
       break;
diff --git a/libheif/sequences/seq_boxes.cc b/libheif/sequences/seq_boxes.cc
index ed80f120..9b478783 100644
--- a/libheif/sequences/seq_boxes.cc
+++ b/libheif/sequences/seq_boxes.cc
@@ -664,6 +664,176 @@ uint64_t Box_stts::get_total_duration(bool include_last_frame_duration)
 }


+Error Box_ctts::parse(BitstreamRange& range, const heif_security_limits* limits)
+{
+  parse_full_box_header(range);
+
+  uint8_t version = get_version();
+
+  if (version > 1) {
+    return unsupported_version_error("ctts");
+  }
+
+  uint32_t entry_count = range.read32();
+
+  if (entry_count > limits->max_sequence_frames) {
+    return {
+      heif_error_Memory_allocation_error,
+      heif_suberror_Security_limit_exceeded,
+      "Security limit for maximum number of sequence frames exceeded"
+    };
+  }
+
+  if (auto err = m_memory_handle.alloc(entry_count * sizeof(OffsetToSample),
+                                       limits, "the 'ctts' table")) {
+    return err;
+  }
+
+  m_entries.resize(entry_count);
+
+  for (uint32_t i = 0; i < entry_count; i++) {
+    if (range.eof()) {
+      std::stringstream sstr;
+      sstr << "ctts box should contain " << entry_count << " entries, but box only contained "
+          << i << " entries";
+
+      return {
+        heif_error_Invalid_input,
+        heif_suberror_End_of_data,
+        sstr.str()
+      };
+    }
+
+    OffsetToSample entry{};
+    entry.sample_count = range.read32();
+    if (version == 0) {
+      uint32_t offset = range.read32();
+      if (offset > INT32_MAX) {
+        return {
+          heif_error_Unsupported_feature,
+          heif_suberror_Unsupported_parameter,
+          "We don't support offsets > 0x7fff in 'ctts' box."
+        };
+      }
+
+      entry.sample_offset = static_cast<int32_t>(offset);
+    }
+    else if (version == 1) {
+      entry.sample_offset = range.read32s();
+    }
+    else {
+      assert(false);
+    }
+
+    m_entries[i] = entry;
+  }
+
+  return range.get_error();
+}
+
+
+std::string Box_ctts::dump(Indent& indent) const
+{
+  std::ostringstream sstr;
+  sstr << FullBox::dump(indent);
+  for (size_t i = 0; i < m_entries.size(); i++) {
+    sstr << indent << "[" << i << "] : cnt=" << m_entries[i].sample_count << ", offset=" << m_entries[i].sample_offset << "\n";
+  }
+
+  return sstr.str();
+}
+
+
+int32_t Box_ctts::compute_min_offset() const
+{
+  int32_t min_offset = INT32_MAX;
+  for (const auto& entry : m_entries) {
+    min_offset = std::min(min_offset, entry.sample_offset);
+  }
+
+  return min_offset;
+}
+
+
+Error Box_ctts::write(StreamWriter& writer) const
+{
+  size_t box_start = reserve_box_header_space(writer);
+
+  int32_t min_offset;
+
+  if (get_version() == 0) {
+    // shift such that all offsets are >= 0
+    min_offset = compute_min_offset();
+  }
+  else {
+    // do not modify offsets
+    min_offset = 0;
+  }
+
+  writer.write32(static_cast<uint32_t>(m_entries.size()));
+  for (const auto& sample : m_entries) {
+    writer.write32(sample.sample_count);
+    writer.write32s(sample.sample_offset - min_offset);
+  }
+
+  prepend_header(writer, box_start);
+
+  return Error::Ok;
+}
+
+
+int32_t Box_ctts::get_sample_offset(uint32_t sample_idx)
+{
+  size_t i = 0;
+  while (i < m_entries.size()) {
+    if (sample_idx < m_entries[i].sample_count) {
+      return m_entries[i].sample_offset;
+    }
+    else {
+      sample_idx -= m_entries[i].sample_count;
+    }
+  }
+
+  return 0;
+}
+
+
+void Box_ctts::append_sample_offset(int32_t offset)
+{
+  if (m_entries.empty() || m_entries.back().sample_offset != offset) {
+    OffsetToSample entry{};
+    entry.sample_offset = offset;
+    entry.sample_count = 1;
+    m_entries.push_back(entry);
+    return;
+  }
+
+  m_entries.back().sample_count++;
+}
+
+
+bool Box_ctts::is_constant_offset() const
+{
+  return m_entries.empty() || m_entries.size() == 1;
+}
+
+void Box_ctts::derive_box_version()
+{
+  set_version(0);
+}
+
+
+size_t Box_stsc::get_number_of_samples() const
+{
+  size_t total = 0;
+  for (const auto& entry : m_entries) {
+    total += entry.samples_per_chunk;
+  }
+
+  return total;
+}
+
+
 Error Box_stsc::parse(BitstreamRange& range, const heif_security_limits* limits)
 {
   parse_full_box_header(range);
diff --git a/libheif/sequences/seq_boxes.h b/libheif/sequences/seq_boxes.h
index 83a3313b..3f61be58 100644
--- a/libheif/sequences/seq_boxes.h
+++ b/libheif/sequences/seq_boxes.h
@@ -360,6 +360,44 @@ private:
 };


+// Composition Time to Sample Box
+class Box_ctts : public FullBox {
+public:
+  Box_ctts()
+  {
+    set_short_type(fourcc("ctts"));
+  }
+
+  std::string dump(Indent&) const override;
+
+  const char* debug_box_name() const override { return "Composition Time to Sample"; }
+
+  Error write(StreamWriter& writer) const override;
+
+  struct OffsetToSample {
+    uint32_t sample_count;
+    int32_t sample_offset;   // either uint32_t or int32_t, we assume that all uint32_t values will also fit into int32_t
+  };
+
+  int32_t get_sample_offset(uint32_t sample_idx);
+
+  void append_sample_offset(int32_t offset);
+
+  bool is_constant_offset() const;
+
+  void derive_box_version() override;
+
+  int32_t compute_min_offset() const;
+
+protected:
+  Error parse(BitstreamRange& range, const heif_security_limits*) override;
+
+private:
+  std::vector<OffsetToSample> m_entries;
+  MemoryHandle m_memory_handle;
+};
+
+
 // Sample to Chunk Box
 class Box_stsc : public FullBox {
 public:
@@ -395,6 +433,8 @@ public:
     return m_entries.back().samples_per_chunk == 0;
   }

+  size_t get_number_of_samples() const;
+
 protected:
   Error parse(BitstreamRange& range, const heif_security_limits*) override;

diff --git a/libheif/sequences/track.cc b/libheif/sequences/track.cc
index 93be0c77..3d2b52d2 100644
--- a/libheif/sequences/track.cc
+++ b/libheif/sequences/track.cc
@@ -480,6 +480,10 @@ Track::Track(HeifContext* ctx, uint32_t track_id, const TrackOptions* options, u
   m_stts = std::make_shared<Box_stts>();
   m_stbl->append_child_box(m_stts);

+  m_ctts = std::make_shared<Box_ctts>();
+  m_stbl->append_child_box(m_ctts);
+  // TODO: will only be added when needed
+
   m_stsc = std::make_shared<Box_stsc>();
   m_stbl->append_child_box(m_stsc);

@@ -785,7 +789,9 @@ void Track::set_sample_description_box(std::shared_ptr<Box> sample_description_b
 }


-Error Track::write_sample_data(const std::vector<uint8_t>& raw_data, uint32_t sample_duration, bool is_sync_sample,
+Error Track::write_sample_data(const std::vector<uint8_t>& raw_data, uint32_t sample_duration,
+                               int32_t composition_time_offset,
+                               bool is_sync_sample,
                                const heif_tai_timestamp_packet* tai, const std::optional<std::string>& gimi_contentID)
 {
   m_chunk_data.insert(m_chunk_data.end(), raw_data.begin(), raw_data.end());
@@ -805,6 +811,7 @@ Error Track::write_sample_data(const std::vector<uint8_t>& raw_data, uint32_t sa
   }

   m_stts->append_sample_duration(sample_duration);
+  m_ctts->append_sample_offset(composition_time_offset);


   // --- sample timestamp
diff --git a/libheif/sequences/track.h b/libheif/sequences/track.h
index 5dd8c0b6..d58dd614 100644
--- a/libheif/sequences/track.h
+++ b/libheif/sequences/track.h
@@ -237,6 +237,7 @@ protected:
   std::shared_ptr<Box_stsc> m_stsc;
   std::shared_ptr<Box_stco> m_stco;
   std::shared_ptr<Box_stts> m_stts;
+  std::shared_ptr<Box_ctts> m_ctts; // optional box, TODO: add only if needed
   std::shared_ptr<Box_stss> m_stss;
   std::shared_ptr<Box_stsz> m_stsz;
   std::shared_ptr<Box_elst> m_elst;
@@ -269,7 +270,10 @@ protected:

   // Write the actual sample data. `tai` may be null and `gimi_contentID` may be empty.
   // In these cases, no timestamp or no contentID will be written, respectively.
-  Error write_sample_data(const std::vector<uint8_t>& raw_data, uint32_t sample_duration, bool is_sync_sample,
+  Error write_sample_data(const std::vector<uint8_t>& raw_data,
+                          uint32_t sample_duration,
+                          int32_t composition_time_offset,
+                          bool is_sync_sample,
                           const heif_tai_timestamp_packet* tai,
                           const std::optional<std::string>& gimi_contentID);
 };
diff --git a/libheif/sequences/track_metadata.cc b/libheif/sequences/track_metadata.cc
index aaf1dedc..3d11bebd 100644
--- a/libheif/sequences/track_metadata.cc
+++ b/libheif/sequences/track_metadata.cc
@@ -177,6 +177,7 @@ Error Track_Metadata::write_raw_metadata(const heif_raw_sequence_sample* raw_sam

   Error err = write_sample_data(raw_sample->data,
                                 raw_sample->duration,
+                                0,
                                 true,
                                 raw_sample->timestamp,
                                 raw_sample->gimi_sample_content_id);
diff --git a/libheif/sequences/track_visual.cc b/libheif/sequences/track_visual.cc
index a64f70dd..1b905f0f 100644
--- a/libheif/sequences/track_visual.cc
+++ b/libheif/sequences/track_visual.cc
@@ -628,8 +628,12 @@ Result<bool> Track_Visual::process_encoded_data(heif_encoder* h_encoder)

     auto& user_data = m_frame_user_data[frame_number];

+    int32_t decoding_time = static_cast<int32_t>(m_stsc->get_number_of_samples()) * m_sample_duration;
+    int32_t composition_time = static_cast<int32_t>(frame_number) * m_sample_duration;
+
     Error err = write_sample_data(data.bitstream,
                                   user_data.sample_duration,
+                                  composition_time - decoding_time,
                                   data.is_sync_frame,
                                   user_data.tai_timestamp,
                                   user_data.gimi_content_id);