Commit 906e59b6 for libheif

commit 906e59b64406fbb3f45bcdf0655f0e144014037e
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Thu Oct 15 20:59:44 2026 +0200

    heif-enc: rewrite the WebVMT parser without std::regex

    The libstdc++ regex matcher recurses once per input character, so a
    sync data value, a timestamp or a whitespace run of about 30,000
    characters overflowed the stack and crashed heif-enc. A 20 KB base64
    blob in a sync command was enough to trigger it.

    The cue line, the timestamp and the cue payload are now parsed by hand.
    The payload is read with a small JSON reader that decodes string
    escapes (so that \" and \uXXXX in sync data are stored correctly) and
    limits the nesting depth.

    Further fixes:
    - A missing or unreadable file and a file without the WEBVMT signature
      are reported and heif-enc exits with an error instead of writing an
      empty metadata track.
    - CR, LF and CRLF line terminators and a byte order mark are accepted.
      Bounded cues in CRLF files were silently dropped before.
    - Errors from adding the metadata track or a packet are reported.
    - Hex data with non-hex characters or an odd number of digits, base64
      data with invalid characters, misplaced padding or a partial group,
      sync commands without type or data, and payloads that are not JSON
      are reported and the cue is skipped instead of silently writing
      wrong or empty data. A cue without a sync command is reported.
    - Messages name the line number of the cue.

diff --git a/examples/vmt.cc b/examples/vmt.cc
index 534ac322..026e58e7 100644
--- a/examples/vmt.cc
+++ b/examples/vmt.cc
@@ -30,8 +30,8 @@
 #include <cstdint>
 #include <fstream>
 #include <iostream>
+#include <iterator>
 #include <optional>
-#include <regex>
 #include <string>
 #include <vector>

@@ -39,9 +39,62 @@
 #include <libheif/heif_sequences.h>


+// The parsers in this file are written by hand instead of with std::regex. The libstdc++ regex matcher
+// recurses once per input character for patterns like ".*", which overflows the stack for inputs of
+// a few ten thousand characters, e.g. a base64 blob in a sync command.
+
 static const uint32_t BAD_VMT_TIMESTAMP = UINT32_MAX;


+static bool is_space(char c)
+{
+  return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v';
+}
+
+static bool is_digit(char c)
+{
+  return c >= '0' && c <= '9';
+}
+
+static bool is_blank(const std::string& line)
+{
+  for (char c : line) {
+    if (!is_space(c)) {
+      return false;
+    }
+  }
+
+  return true;
+}
+
+// Shorten a string for an error message.
+static std::string excerpt(const std::string& s)
+{
+  const size_t max_length = 40;
+
+  if (s.size() <= max_length) {
+    return s;
+  }
+
+  return s.substr(0, max_length) + "...";
+}
+
+static std::string trim(const std::string& s)
+{
+  size_t start = 0;
+  while (start < s.size() && is_space(s[start])) {
+    start++;
+  }
+
+  size_t end = s.size();
+  while (end > start && is_space(s[end - 1])) {
+    end--;
+  }
+
+  return s.substr(start, end - start);
+}
+
+
 static std::optional<uint8_t> nibble_to_val(char c)
 {
   if (c>='0' && c<='9') {
@@ -57,142 +110,529 @@ static std::optional<uint8_t> nibble_to_val(char c)
   return std::nullopt;
 }

-// Convert hex data to raw binary. Ignore any non-hex characters.
-static std::vector<uint8_t> hex_to_binary(const std::string& line)
+// Convert hex data to raw binary and append it to 'data'. Whitespace between the digits is allowed.
+static bool hex_to_binary(const std::string& text, std::vector<uint8_t>& data, std::string& error)
 {
-  std::vector<uint8_t> data;
-  uint8_t current_value = 0;
+  std::optional<uint8_t> high_nibble;
+
+  for (char c : text) {
+    if (is_space(c)) {
+      continue;
+    }

-  bool high_nibble = true;
-  for (auto c : line) {
     auto v = nibble_to_val(c);
-    if (v) {
-      if (high_nibble) {
-        current_value = static_cast<uint8_t>(*v << 4);
-        high_nibble = false;
+    if (!v) {
+      error = std::string("invalid hex character '") + c + "'";
+      return false;
+    }
+
+    if (!high_nibble) {
+      high_nibble = *v;
+    }
+    else {
+      data.push_back(static_cast<uint8_t>((*high_nibble << 4) | *v));
+      high_nibble.reset();
+    }
+  }
+
+  if (high_nibble) {
+    error = "odd number of hex digits";
+    return false;
+  }
+
+  return true;
+}
+
+
+// Convert base64 data (RFC 4648, padded with '=') to raw binary and append it to 'data'.
+static bool decode_base64(const std::string& text, std::vector<uint8_t>& data, std::string& error)
+{
+  auto value = [](char c) -> int {
+    if (c >= 'A' && c <= 'Z') return c - 'A';
+    if (c >= 'a' && c <= 'z') return c - 'a' + 26;
+    if (c >= '0' && c <= '9') return c - '0' + 52;
+    if (c == '+') return 62;
+    if (c == '/') return 63;
+    return -1;
+  };
+
+  if (text.size() % 4 != 0) {
+    error = "base64 length is not a multiple of four";
+    return false;
+  }
+
+  for (size_t i = 0; i < text.size(); i += 4) {
+    int v[4];
+    int padding = 0;
+    bool last_group = (i + 4 == text.size());
+
+    for (int j = 0; j < 4; j++) {
+      char c = text[i + j];
+
+      if (c == '=' && j >= 2 && last_group) {
+        v[j] = 0;
+        padding++;
+      }
+      else if (padding > 0) {
+        error = "base64 padding is not at the end";
+        return false;
       }
       else {
-        current_value |= *v;
-        data.push_back(current_value);
-        high_nibble = true;
+        v[j] = value(c);
+        if (v[j] < 0) {
+          error = std::string("invalid base64 character '") + c + "'";
+          return false;
+        }
       }
     }
+
+    data.push_back(static_cast<uint8_t>((v[0] << 2) | (v[1] >> 4)));
+    if (padding < 2) {
+      data.push_back(static_cast<uint8_t>(((v[1] & 0x0f) << 4) | (v[2] >> 2)));
+    }
+    if (padding < 1) {
+      data.push_back(static_cast<uint8_t>(((v[2] & 0x03) << 6) | v[3]));
+    }
   }

-  return data;
+  return true;
 }


-// Convert base64 data to raw binary.
-static std::vector<uint8_t> decode_base64(const std::string& line)
+static void append_utf8(std::string& out, uint32_t cp)
 {
-  const std::string base64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
-  std::vector<uint8_t> data;
+  if (cp < 0x80) {
+    out.push_back(static_cast<char>(cp));
+  }
+  else if (cp < 0x800) {
+    out.push_back(static_cast<char>(0xC0 | (cp >> 6)));
+    out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
+  }
+  else if (cp < 0x10000) {
+    out.push_back(static_cast<char>(0xE0 | (cp >> 12)));
+    out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
+    out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
+  }
+  else {
+    out.push_back(static_cast<char>(0xF0 | (cp >> 18)));
+    out.push_back(static_cast<char>(0x80 | ((cp >> 12) & 0x3F)));
+    out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
+    out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
+  }
+}
+
+
+// A minimal JSON reader, sufficient to find the "sync" commands in a cue payload. It decodes string
+// escapes and skips over values of any other type. The nesting depth is limited so that malformed
+// input cannot exhaust the stack.
+class JsonReader
+{
+public:
+  explicit JsonReader(const std::string& text) : m_text(text) {}
+
+  void skip_whitespace()
+  {
+    while (m_pos < m_text.size() && is_space(m_text[m_pos])) {
+      m_pos++;
+    }
+  }

-  size_t len = line.size();
-  if (len % 4 != 0) {
-    len = int(len / 4) * 4;
+  bool at_end()
+  {
+    skip_whitespace();
+    return m_pos >= m_text.size();
   }

-  for (size_t i = 0; i < len; i += 4) {
-    uint8_t buf[4];
-    for (uint8_t j = 0; j < 4; j++) {
-      size_t k = base64.find(line[i + j]);
-      buf[j] = (uint8_t)(k == std::string::npos ? base64.size() : k);
+  // Consume the character 'c' if it is the next non-whitespace character.
+  bool consume(char c)
+  {
+    if (!at_end() && m_text[m_pos] == c) {
+      m_pos++;
+      return true;
     }

-    data.push_back((uint8_t)(buf[0] << 2) + (buf[1] >> 4));
+    return false;
+  }

-    if (line[i + 2] != '=') {
-      data.push_back((uint8_t)((buf[1] & 0x0f) << 4) + (buf[2] >> 2));
+  // Parse a JSON string literal into UTF-8.
+  bool parse_string(std::string& out)
+  {
+    if (!consume('"')) {
+      return false;
     }

-    if (line[i + 3] != '=') {
-      data.push_back((uint8_t)((buf[2] & 0x03) << 6) + buf[3]);
+    out.clear();
+
+    while (true) {
+      if (m_pos >= m_text.size()) {
+        return false; // unterminated string
+      }
+
+      char c = m_text[m_pos++];
+
+      if (c == '"') {
+        return true;
+      }
+
+      if (static_cast<unsigned char>(c) < 0x20) {
+        return false; // control characters must be escaped
+      }
+
+      if (c != '\\') {
+        out.push_back(c);
+        continue;
+      }
+
+      if (m_pos >= m_text.size()) {
+        return false;
+      }
+
+      char e = m_text[m_pos++];
+      switch (e) {
+        case '"': out.push_back('"'); break;
+        case '\\': out.push_back('\\'); break;
+        case '/': out.push_back('/'); break;
+        case 'b': out.push_back('\b'); break;
+        case 'f': out.push_back('\f'); break;
+        case 'n': out.push_back('\n'); break;
+        case 'r': out.push_back('\r'); break;
+        case 't': out.push_back('\t'); break;
+        case 'u': {
+          uint32_t cp;
+          if (!parse_hex4(cp)) {
+            return false;
+          }
+
+          if (cp >= 0xD800 && cp <= 0xDBFF) {
+            // high surrogate, must be followed by an escaped low surrogate
+            if (m_pos + 2 > m_text.size() || m_text[m_pos] != '\\' || m_text[m_pos + 1] != 'u') {
+              return false;
+            }
+            m_pos += 2;
+
+            uint32_t low;
+            if (!parse_hex4(low) || low < 0xDC00 || low > 0xDFFF) {
+              return false;
+            }
+
+            cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00);
+          }
+          else if (cp >= 0xDC00 && cp <= 0xDFFF) {
+            return false; // lone low surrogate
+          }
+
+          append_utf8(out, cp);
+          break;
+        }
+        default:
+          return false;
+      }
     }
   }

-  return data;
-}
+  // Skip over a value of any type.
+  bool skip_value(int depth = 0)
+  {
+    if (depth > MAX_DEPTH || at_end()) {
+      return false;
+    }

+    char c = m_text[m_pos];

-// Parse metadata from WebVMT sync commmands
-static std::vector<uint8_t> parse_vmt_sync_data(const std::string& content)
-{
-  std::vector<uint8_t> data;
+    if (c == '"') {
+      std::string s;
+      return parse_string(s);
+    }
+
+    if (c == '{') {
+      m_pos++;
+      if (consume('}')) {
+        return true;
+      }
+
+      while (true) {
+        std::string key;
+        if (!parse_string(key) || !consume(':') || !skip_value(depth + 1)) {
+          return false;
+        }

-  // [\s\S] instead of '.' because '.' does not match line breaks and a sync command may span several lines.
-  std::regex pattern_sync(R"(\s*\{\s*\"sync\"\s*:\s*\{([\s\S]*?)\}\s*\}\s*)");
-  const std::sregex_token_iterator ti_end;
+        if (consume(',')) {
+          continue;
+        }

-  for (std::sregex_token_iterator ti(content.begin(), content.end(), pattern_sync, 1); ti != ti_end; ++ti)
+        return consume('}');
+      }
+    }
+
+    if (c == '[') {
+      m_pos++;
+      if (consume(']')) {
+        return true;
+      }
+
+      while (true) {
+        if (!skip_value(depth + 1)) {
+          return false;
+        }
+
+        if (consume(',')) {
+          continue;
+        }
+
+        return consume(']');
+      }
+    }
+
+    // number or literal
+    size_t start = m_pos;
+    while (m_pos < m_text.size() && (is_digit(m_text[m_pos]) ||
+                                     (m_text[m_pos] >= 'a' && m_text[m_pos] <= 'z') ||
+                                     (m_text[m_pos] >= 'A' && m_text[m_pos] <= 'Z') ||
+                                     m_text[m_pos] == '-' || m_text[m_pos] == '+' || m_text[m_pos] == '.')) {
+      m_pos++;
+    }
+
+    std::string token = m_text.substr(start, m_pos - start);
+    if (token == "true" || token == "false" || token == "null") {
+      return true;
+    }
+
+    return !token.empty() && (token[0] == '-' || is_digit(token[0]));
+  }
+
+private:
+  bool parse_hex4(uint32_t& cp)
   {
-    std::string sync = *ti;
-    std::regex pattern_type(R"([\s\S]*\"type\"\s*:\s*\"(.*?)\"[\s\S]*)");
-    std::regex pattern_data(R"([\s\S]*\"data\"\s*:\s*\"(.*?)\"[\s\S]*)");
-    std::smatch match;
+    if (m_pos + 4 > m_text.size()) {
+      return false;
+    }
+
+    cp = 0;
+    for (int i = 0; i < 4; i++) {
+      auto v = nibble_to_val(m_text[m_pos++]);
+      if (!v) {
+        return false;
+      }
+      cp = (cp << 4) | *v;
+    }

-    if (std::regex_match(sync, match, pattern_type)) {
-      std::string type = match[1];
+    return true;
+  }
+
+  const std::string& m_text;
+  size_t m_pos = 0;

-      std::regex pattern_hex(R"(.*\.hex$)");
-      std::regex pattern_b64(R"(.*\.base64$)");
+  static const int MAX_DEPTH = 32;
+};

-      std::string textData;
-      if (std::regex_match(sync, match, pattern_data)) {
-        textData = match[1];
+
+// Parse the parameter object of a "sync" command and append its data to 'data'.
+// The data is decoded according to the ".hex" or ".base64" suffix of the type.
+static bool parse_vmt_sync_command(JsonReader& reader, std::vector<uint8_t>& data, std::string& error)
+{
+  if (!reader.consume('{')) {
+    error = "sync parameters are not a JSON object";
+    return false;
+  }
+
+  std::optional<std::string> type;
+  std::optional<std::string> value;
+
+  if (!reader.consume('}')) {
+    while (true) {
+      std::string key;
+      if (!reader.parse_string(key) || !reader.consume(':')) {
+        error = "invalid JSON in sync command";
+        return false;
       }

-      if (std::regex_match(type, match, pattern_hex)) {
-        std::vector<uint8_t> binaryData = hex_to_binary(textData);
-        data.insert(data.end(), binaryData.begin(), binaryData.end());
+      if (key == "type") {
+        std::string s;
+        if (!reader.parse_string(s)) {
+          error = "sync type is not a string";
+          return false;
+        }
+        type = s;
       }
-      else if (std::regex_match(type, match, pattern_b64)) {
-        std::vector<uint8_t> binaryData = decode_base64(textData);
-        data.insert(data.end(), binaryData.begin(), binaryData.end());
+      else if (key == "data") {
+        std::string s;
+        if (!reader.parse_string(s)) {
+          error = "sync data is not a string";
+          return false;
+        }
+        value = s;
       }
-      else {
-        data.insert(data.end(), textData.data(), textData.data() + textData.length());
+      else if (!reader.skip_value()) {
+        error = "invalid JSON in sync command";
+        return false;
       }
+
+      if (reader.consume(',')) {
+        continue;
+      }
+
+      if (reader.consume('}')) {
+        break;
+      }
+
+      error = "invalid JSON in sync command";
+      return false;
     }
   }

-  return data;
+  if (!type) {
+    error = "sync command without type";
+    return false;
+  }
+
+  if (!value) {
+    error = "sync command without data";
+    return false;
+  }
+
+  if (type->ends_with(".hex")) {
+    return hex_to_binary(*value, data, error);
+  }
+  else if (type->ends_with(".base64")) {
+    return decode_base64(*value, data, error);
+  }
+  else {
+    data.insert(data.end(), value->begin(), value->end());
+    return true;
+  }
 }


+// Parse the metadata from the WebVMT sync commands in a cue payload. The payload is a list of JSON objects,
+// one per command. The data of all sync commands is concatenated.
+static bool parse_vmt_sync_data(const std::string& content, std::vector<uint8_t>& data, bool& have_sync, std::string& error)
+{
+  JsonReader reader(content);
+  have_sync = false;
+
+  while (!reader.at_end()) {
+    if (!reader.consume('{')) {
+      error = "cue payload is not a list of JSON objects";
+      return false;
+    }
+
+    if (reader.consume('}')) {
+      continue;
+    }
+
+    while (true) {
+      std::string command;
+      if (!reader.parse_string(command) || !reader.consume(':')) {
+        error = "invalid JSON in cue payload";
+        return false;
+      }
+
+      if (command == "sync") {
+        if (!parse_vmt_sync_command(reader, data, error)) {
+          return false;
+        }
+        have_sync = true;
+      }
+      else if (!reader.skip_value()) {
+        error = "invalid JSON in cue payload";
+        return false;
+      }
+
+      if (reader.consume(',')) {
+        continue;
+      }
+
+      if (reader.consume('}')) {
+        break;
+      }
+
+      error = "invalid JSON in cue payload";
+      return false;
+    }
+  }
+
+  return true;
+}
+
+
+// Parse a WebVMT timestamp "[hh:]mm:ss.fff" into milliseconds.
+// Hours are optional and may have any number of digits, minutes and seconds are exactly two digits
+// and at most 59, the fraction is mandatory and exactly three digits.
 static uint32_t parse_vmt_timestamp(const std::string& vmt_time)
 {
-  std::regex pattern(R"(-?((\d{1,}):)?(\d{2}):(\d{2})(\.(\d{3})))");
-  std::smatch match;
+  size_t pos = 0;
+  const size_t n = vmt_time.size();
+
+  bool negative = false;
+  if (pos < n && vmt_time[pos] == '-') {
+    negative = true;
+    pos++;
+  }
+
+  auto read_digits = [&](std::string& out) {
+    size_t start = pos;
+    while (pos < n && is_digit(vmt_time[pos])) {
+      pos++;
+    }
+    out = vmt_time.substr(start, pos - start);
+    return !out.empty();
+  };
+
+  std::string g1, g2, g3, fs;
+
+  if (!read_digits(g1) || pos >= n || vmt_time[pos] != ':') {
+    return BAD_VMT_TIMESTAMP;
+  }
+  pos++;

-  if (!std::regex_match(vmt_time, match, pattern)) {
-    return BAD_VMT_TIMESTAMP; // no match
+  if (!read_digits(g2)) {
+    return BAD_VMT_TIMESTAMP;
   }

-  std::string hh = match[2]; // optional
-  std::string mm = match[3];
-  std::string ss = match[4];
-  std::string fs = match[6];
+  bool has_hours = false;
+  if (pos < n && vmt_time[pos] == ':') {
+    pos++;
+    if (!read_digits(g3)) {
+      return BAD_VMT_TIMESTAMP;
+    }
+    has_hours = true;
+  }

-  if (vmt_time.find('-') != std::string::npos) {
-    return 0; // negative time not supported
+  if (pos >= n || vmt_time[pos] != '.') {
+    return BAD_VMT_TIMESTAMP;
+  }
+  pos++;
+
+  if (!read_digits(fs) || fs.size() != 3 || pos != n) {
+    return BAD_VMT_TIMESTAMP;
+  }
+
+  const std::string hh = has_hours ? g1 : "";
+  const std::string mm = has_hours ? g2 : g1;
+  const std::string ss = has_hours ? g3 : g2;
+
+  if (mm.size() != 2 || ss.size() != 2) {
+    return BAD_VMT_TIMESTAMP;
   }

   int minutes = std::stoi(mm);
   int seconds = std::stoi(ss);
-  uint32_t ms = std::stoi(fs); // the pattern guarantees exactly three digits
+  uint32_t ms = std::stoi(fs);

   if (minutes > 59 || seconds > 59) {
     return BAD_VMT_TIMESTAMP;
   }

   // The timestamp is stored in a 32 bit millisecond counter. Reject hour values that cannot fit
-  // (and that would overflow std::stoi) before doing any arithmetic.
-  if (hh.length() > 9) {
+  // (and that would overflow std::stoull) before doing any arithmetic.
+  if (hh.size() > 9) {
     return BAD_VMT_TIMESTAMP;
   }

+  if (negative) {
+    return 0; // negative timestamps are interpreted as zero
+  }
+
   uint64_t ts = ((hh != "" ? std::stoull(hh) : 0) * 3600 * 1000 +
                  minutes * 60 * 1000 +
                  seconds * 1000 +
@@ -205,10 +645,82 @@ static uint32_t parse_vmt_timestamp(const std::string& vmt_time)
   return static_cast<uint32_t>(ts);
 }

+
+// Read the file and split it into lines. CR, LF and CRLF are accepted as line terminators.
+// A byte order mark at the start of the file is removed.
+static bool read_vmt_lines(const std::string& filename, std::vector<std::string>& lines)
+{
+  std::ifstream istr(filename.c_str(), std::ios::binary);
+  if (!istr) {
+    return false;
+  }
+
+  std::string content((std::istreambuf_iterator<char>(istr)), std::istreambuf_iterator<char>());
+
+  if (content.starts_with("\xEF\xBB\xBF")) {
+    content.erase(0, 3);
+  }
+
+  std::string line;
+  for (size_t i = 0; i < content.size(); i++) {
+    char c = content[i];
+
+    if (c == '\r' || c == '\n') {
+      if (c == '\r' && i + 1 < content.size() && content[i + 1] == '\n') {
+        i++;
+      }
+
+      lines.push_back(line);
+      line.clear();
+    }
+    else {
+      line.push_back(c);
+    }
+  }
+
+  if (!line.empty()) {
+    lines.push_back(line);
+  }
+
+  return true;
+}
+
+
+static bool is_vmt_signature(const std::string& line)
+{
+  return line.starts_with("WEBVMT") && (line.size() == 6 || is_space(line[6]));
+}
+
+
+// A comment block starts with "NOTE" followed by a space or the end of the line.
+static bool is_vmt_note(const std::string& line)
+{
+  size_t p = 0;
+  while (p < line.size() && is_space(line[p])) {
+    p++;
+  }
+
+  return line.compare(p, 4, "NOTE") == 0 && (p + 4 == line.size() || is_space(line[p + 4]));
+}
+
+
 int encode_vmt_metadata_track(heif_context* context, heif_track* visual_track,
                               const std::string& vmt_metadata_file,
                               const std::string& track_uri, bool binary)
 {
+  // --- read the WebVMT file
+
+  std::vector<std::string> lines;
+  if (!read_vmt_lines(vmt_metadata_file, lines)) {
+    std::cerr << "Cannot read WebVMT file: " << vmt_metadata_file << "\n";
+    return 5;
+  }
+
+  if (lines.empty() || !is_vmt_signature(lines[0])) {
+    std::cerr << "Not a WebVMT file (missing WEBVMT signature): " << vmt_metadata_file << "\n";
+    return 5;
+  }
+
   // --- add metadata track

   heif_track* track = nullptr;
@@ -216,90 +728,126 @@ int encode_vmt_metadata_track(heif_context* context, heif_track* visual_track,
   heif_track_options* track_options = heif_track_options_alloc();
   heif_track_options_set_timescale(track_options, 1000);

-  heif_context_add_uri_metadata_sequence_track(context, track_uri.c_str(), track_options, &track);
+  heif_error err = heif_context_add_uri_metadata_sequence_track(context, track_uri.c_str(), track_options, &track);
+  if (err.code) {
+    std::cerr << "Cannot add metadata track: " << err.message << "\n";
+    heif_track_options_release(track_options);
+    return 5;
+  }
+
   heif_raw_sequence_sample* sample = heif_raw_sequence_sample_alloc();

+  auto release_all = [&]() {
+    heif_raw_sequence_sample_release(sample);
+    heif_track_options_release(track_options);
+    heif_track_release(track);
+  };
+
+  auto add_packet = [&](const std::vector<uint8_t>& data, uint32_t duration) {
+    heif_raw_sequence_sample_set_data(sample, data.data(), data.size());
+    heif_raw_sequence_sample_set_duration(sample, duration);

-  std::ifstream istr(vmt_metadata_file.c_str());
+    heif_error e = heif_track_add_raw_sequence_sample(track, sample);
+    if (e.code) {
+      std::cerr << "Cannot add metadata packet: " << e.message << "\n";
+      return false;
+    }

-  std::regex pattern_cue(R"(^\s*(-?(\d|:|\.)*)\s*-->\s*(-?(\d|:|\.)*)?.*)");
-  std::regex pattern_note(R"(^\s*(NOTE).*)");
+    return true;
+  };

   // Each cue closes the packet that was opened by the previous cue. A packet is written as soon as its duration is known.
   // The track starts at time 0. If the first cue starts later, an empty packet covers the time before it.
   std::vector<uint8_t> prev_metadata;
   uint32_t prev_ts = 0;

-  std::string line;
-  while (std::getline(istr, line))
-  {
-    std::smatch match;
+  for (size_t i = 1; i < lines.size(); i++) {
+    const std::string& line = lines[i];

-    if (std::regex_match(line, match, pattern_note)) {
-      while (std::getline(istr, line)) {
-        if (line.empty()) {
-          break;
-        }
+    if (is_vmt_note(line)) {
+      while (i + 1 < lines.size() && !is_blank(lines[i + 1])) {
+        i++;
       }

       continue;
     }

-    if (!std::regex_match(line, match, pattern_cue)) {
+    // A cue starts with its timing line. Header lines, cue identifiers and other blocks are skipped.
+
+    size_t arrow = line.find("-->");
+    if (arrow == std::string::npos) {
       continue;
     }

-    std::string cue_start = match[1];
-    std::string cue_end = match[3]; // == "" for unbounded cues
+    const size_t cue_line = i + 1;
+    std::string cue_start = trim(line.substr(0, arrow));
+    // The cue end time and the cue settings after the arrow are ignored. Unbounded cues have no end time.

     uint32_t ts = parse_vmt_timestamp(cue_start);

+    // --- collect the cue payload up to the next blank line
+
+    std::vector<std::string> payload;
+    while (i + 1 < lines.size() && !is_blank(lines[i + 1])) {
+      i++;
+      payload.push_back(lines[i]);
+    }
+
+    if (ts == BAD_VMT_TIMESTAMP) {
+      std::cerr << "WebVMT line " << cue_line << ": bad timestamp '" << excerpt(cue_start) << "'\n";
+      continue;
+    }
+
+    if (ts < prev_ts) {
+      std::cerr << "WebVMT line " << cue_line << ": timestamp '" << excerpt(cue_start) << "' is before the previous cue\n";
+      continue;
+    }
+
+    // --- decode the payload
+
     std::vector<uint8_t> concat;
+    std::string error;
+    bool ok = true;

     if (binary) {
-      while (std::getline(istr, line)) {
-        if (line.empty()) {
+      for (const std::string& payload_line : payload) {
+        if (!hex_to_binary(payload_line, concat, error)) {
+          ok = false;
           break;
         }
-
-        std::vector<uint8_t> binaryData = hex_to_binary(line);
-        concat.insert(concat.end(), binaryData.begin(), binaryData.end());
       }
-
     }
     else {
-      while (std::getline(istr, line)) {
-        if (line.empty()) {
-          break;
-        }
-
-        concat.insert(concat.end(), line.data(), line.data() + line.length());
-        concat.push_back('\n');
+      std::string content;
+      for (const std::string& payload_line : payload) {
+        content += payload_line;
+        content += '\n';
       }

-      concat.push_back(0);
-      std::string content(concat.begin(), concat.end());
-      concat = parse_vmt_sync_data(content);
-    }
+      bool have_sync = false;
+      ok = parse_vmt_sync_data(content, concat, have_sync, error);

-    if (ts == BAD_VMT_TIMESTAMP) {
-      std::cerr << "Bad WebVMT timestamp: " << cue_start << "\n";
-      continue;
+      if (ok && !have_sync && !payload.empty()) {
+        std::cerr << "WebVMT line " << cue_line << ": cue has no sync command, writing an empty packet\n";
+      }
     }

-    if (ts < prev_ts) {
-      std::cerr << "Bad WebVMT timestamp order: " << cue_start << "\n";
+    if (!ok) {
+      std::cerr << "WebVMT line " << cue_line << ": cannot parse cue payload: " << error << "\n";
       continue;
     }

+    // --- write the packet of the previous cue
+
     if (ts == prev_ts) {
       // Cues with the same start time are combined into one packet.
       prev_metadata.insert(prev_metadata.end(), concat.begin(), concat.end());
     }
     else {
-      heif_raw_sequence_sample_set_data(sample, (const uint8_t*)prev_metadata.data(), prev_metadata.size());
-      heif_raw_sequence_sample_set_duration(sample, ts - prev_ts);
-      heif_track_add_raw_sequence_sample(track, sample);
+      if (!add_packet(prev_metadata, ts - prev_ts)) {
+        release_all();
+        return 5;
+      }

       prev_ts = ts;
       prev_metadata = concat;
@@ -308,9 +856,10 @@ int encode_vmt_metadata_track(heif_context* context, heif_track* visual_track,

   // --- flush last metadata packet

-  heif_raw_sequence_sample_set_data(sample, (const uint8_t*)prev_metadata.data(), prev_metadata.size());
-  heif_raw_sequence_sample_set_duration(sample, 1);
-  heif_track_add_raw_sequence_sample(track, sample);
+  if (!add_packet(prev_metadata, 1)) {
+    release_all();
+    return 5;
+  }

   // --- add track reference

@@ -318,9 +867,7 @@ int encode_vmt_metadata_track(heif_context* context, heif_track* visual_track,

   // --- release all objects

-  heif_raw_sequence_sample_release(sample);
-  heif_track_options_release(track_options);
-  heif_track_release(track);
+  release_all();

   return 0;
 }