Commit 039335d9bc for ffmpeg

commit 039335d9bc9ee8f8108f2768528cfe2657ecff4f
Author: Kacper Michajłow <kasper93@gmail.com>
Date:   Sat Oct 17 01:37:53 2026 +0200

    avformat/webvttdec: add an option to apply the X-TIMESTAMP-MAP header

    HLS WebVTT segments carry an X-TIMESTAMP-MAP header that maps a cue time
    to an MPEG-TS time of the other renditions (RFC 8216, 3.5). With
    timestamp_map set, add the offset it gives to the cue timestamps, and use
    the 90 kHz MPEG-TS time base for the stream so that the mapping stays
    exact. Skip the header when it comes in a block of its own instead of
    ending the parsing there.

    Signed-off-by: Kacper Michajłow <kasper93@gmail.com>

diff --git a/doc/demuxers.texi b/doc/demuxers.texi
index bc8116d9d2..c1edd448cc 100644
--- a/doc/demuxers.texi
+++ b/doc/demuxers.texi
@@ -1279,4 +1279,22 @@ color hint will be used, otherwise transparent black will be used for
 the background. Default value is false.
 @end table

+@section webvtt
+
+WebVTT subtitle demuxer.
+
+It accepts the following options:
+
+@table @option
+@item kind
+Set the kind of the track: @samp{subtitles} (default), @samp{captions},
+@samp{descriptions} or @samp{metadata}.
+
+@item timestamp_map
+Apply the @code{X-TIMESTAMP-MAP} header of HLS WebVTT segments to the cue
+timestamps, which moves them onto the MPEG-TS timeline of the other
+renditions, and use the 90 kHz MPEG-TS time base for the stream. Default
+value is false.
+@end table
+
 @c man end DEMUXERS
diff --git a/libavformat/webvttdec.c b/libavformat/webvttdec.c
index 4ca1e939b1..8b58466382 100644
--- a/libavformat/webvttdec.c
+++ b/libavformat/webvttdec.c
@@ -28,6 +28,7 @@
 #include "demux.h"
 #include "internal.h"
 #include "subtitles.h"
+#include "libavutil/avstring.h"
 #include "libavutil/bprint.h"
 #include "libavutil/intreadwrite.h"
 #include "libavutil/opt.h"
@@ -36,6 +37,7 @@ typedef struct {
     const AVClass *class;
     FFDemuxSubtitlesQueue q;
     int kind;
+    int timestamp_map;
 } WebVTTContext;

 static int webvtt_probe(const AVProbeData *p)
@@ -58,16 +60,55 @@ static int64_t read_ts(const char *s)
     return AV_NOPTS_VALUE;
 }

+/* X-TIMESTAMP-MAP=LOCAL:<cue time>,MPEGTS:<MPEG-2 time>, in either order,
+ * on a header line of its own */
+static void parse_timestamp_map(AVFormatContext *s, const char *p,
+                                AVRational time_base, int64_t *offset)
+{
+    int64_t local = AV_NOPTS_VALUE, mpegts = AV_NOPTS_VALUE;
+
+    while (*p && !av_strstart(p, "X-TIMESTAMP-MAP=", NULL)) {
+        p += strcspn(p, "\r\n");
+        p += strspn(p, "\r\n");
+    }
+    if (!*p)
+        return;
+    p += strlen("X-TIMESTAMP-MAP=");
+    while (*p && *p != '\r' && *p != '\n') {
+        const char *val;
+        if (av_strstart(p, "LOCAL:", &val)) {
+            local = read_ts(val);
+        } else if (av_strstart(p, "MPEGTS:", &val)) {
+            char *end;
+            mpegts = strtoll(val, &end, 10);
+            if (end == val || (*end && !strchr(",\r\n", *end)) ||
+                mpegts < 0 || mpegts >= (1LL << 33))
+                mpegts = AV_NOPTS_VALUE;
+        }
+        p += strcspn(p, ",\r\n");
+        if (*p == ',')
+            p++;
+    }
+    if (local == AV_NOPTS_VALUE || mpegts == AV_NOPTS_VALUE) {
+        av_log(s, AV_LOG_WARNING, "Invalid X-TIMESTAMP-MAP header\n");
+        return;
+    }
+
+    *offset = av_rescale_q(mpegts, (AVRational){ 1, 90000 }, time_base) -
+              av_rescale_q(local, (AVRational){ 1, 1000 }, time_base);
+}
+
 static int webvtt_read_header(AVFormatContext *s)
 {
     WebVTTContext *webvtt = s->priv_data;
     AVBPrint cue;
     int res = 0;
+    int64_t ts_offset = 0;
     AVStream *st = avformat_new_stream(s, NULL);

     if (!st)
         return AVERROR(ENOMEM);
-    avpriv_set_pts_info(st, 64, 1, 1000);
+    avpriv_set_pts_info(st, 64, 1, webvtt->timestamp_map ? 90000 : 1000);
     st->codecpar->codec_type = AVMEDIA_TYPE_SUBTITLE;
     st->codecpar->codec_id   = AV_CODEC_ID_WEBVTT;
     st->disposition |= webvtt->kind;
@@ -92,10 +133,16 @@ static int webvtt_read_header(AVFormatContext *s)
         p = identifier = cue.str;
         pos = avio_tell(s->pb);

-        /* ignore header chunk */
+        /* header chunk */
         if (!strncmp(p, "\xEF\xBB\xBFWEBVTT", 9) ||
             !strncmp(p, "WEBVTT", 6) ||
-            !strncmp(p, "STYLE", 5) ||
+            (!strncmp(p, "X-TIMESTAMP-MAP=", 16) && !strstr(p, "-->"))) {
+            if (webvtt->timestamp_map)
+                parse_timestamp_map(s, p, st->time_base, &ts_offset);
+            continue;
+        }
+
+        if (!strncmp(p, "STYLE", 5) ||
             !strncmp(p, "REGION", 6) ||
             !strncmp(p, "NOTE", 4))
             continue;
@@ -148,8 +195,10 @@ static int webvtt_read_header(AVFormatContext *s)
             goto end;
         }
         sub->pos = pos;
-        sub->pts = ts_start;
-        sub->duration = ts_end - ts_start;
+        sub->pts = av_rescale_q(ts_start, (AVRational){ 1, 1000 }, st->time_base) +
+                   ts_offset;
+        sub->duration = av_rescale_q(ts_end - ts_start, (AVRational){ 1, 1000 },
+                                     st->time_base);

 #define SET_SIDE_DATA(name, type) do {                                  \
     if (name##_len) {                                                   \
@@ -203,6 +252,7 @@ static const AVOption options[] = {
         { "captions",     "WebVTT captions kind",     0, AV_OPT_TYPE_CONST, { .i64 = AV_DISPOSITION_CAPTIONS },     INT_MIN, INT_MAX, KIND_FLAGS, .unit = "webvtt_kind" },
         { "descriptions", "WebVTT descriptions kind", 0, AV_OPT_TYPE_CONST, { .i64 = AV_DISPOSITION_DESCRIPTIONS }, INT_MIN, INT_MAX, KIND_FLAGS, .unit = "webvtt_kind" },
         { "metadata",     "WebVTT metadata kind",     0, AV_OPT_TYPE_CONST, { .i64 = AV_DISPOSITION_METADATA },     INT_MIN, INT_MAX, KIND_FLAGS, .unit = "webvtt_kind" },
+    { "timestamp_map", "Apply the X-TIMESTAMP-MAP header to the cue timestamps, in the 90 kHz MPEG-TS time base", OFFSET(timestamp_map), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, KIND_FLAGS },
     { NULL }
 };

diff --git a/tests/fate/subtitles.mak b/tests/fate/subtitles.mak
index 3ded96e044..200125f718 100644
--- a/tests/fate/subtitles.mak
+++ b/tests/fate/subtitles.mak
@@ -109,6 +109,9 @@ fate-sub-webvtt: CMD = fmtstdout ass -i $(TARGET_SAMPLES)/sub/WebVTT_capability_
 FATE_SUBTITLES_ASS-$(call DEMDEC, WEBVTT, WEBVTT) += fate-sub-webvtt2
 fate-sub-webvtt2: CMD = fmtstdout ass -i $(TARGET_SAMPLES)/sub/WebVTT_extended_tester.vtt

+FATE_SUBTITLES-$(call ALLYES, WEBVTT_DEMUXER FRAMECRC_MUXER) += fate-sub-webvtt-timestamp-map
+fate-sub-webvtt-timestamp-map: CMD = framecrc -copyts -timestamp_map 1 -i $(TARGET_SAMPLES)/sub/WebVTT_timestamp_map.vtt -timestamp_map 1 -i $(TARGET_SAMPLES)/sub/WebVTT_timestamp_map_block.vtt -timestamp_map 1 -i $(TARGET_SAMPLES)/sub/WebVTT_timestamp_map_invalid.vtt -timestamp_map 1 -i $(TARGET_SAMPLES)/sub/WebVTT_timestamp_map_nolocal.vtt -timestamp_map 1 -i $(TARGET_SAMPLES)/sub/WebVTT_timestamp_map_range.vtt -i $(TARGET_SAMPLES)/sub/WebVTT_timestamp_map.vtt -map 0 -map 1 -map 2 -map 3 -map 4 -map 5 -c copy
+
 FATE_SUBTITLES-$(call ALLYES, SRT_DEMUXER SUBRIP_DECODER WEBVTT_ENCODER WEBVTT_MUXER) += fate-sub-webvttenc
 fate-sub-webvttenc: CMD = fmtstdout webvtt -i $(TARGET_SAMPLES)/sub/SubRip_capability_tester.srt

diff --git a/tests/ref/fate/sub-webvtt-timestamp-map b/tests/ref/fate/sub-webvtt-timestamp-map
new file mode 100644
index 0000000000..4b084961d4
--- /dev/null
+++ b/tests/ref/fate/sub-webvtt-timestamp-map
@@ -0,0 +1,26 @@
+#tb 0: 1/90000
+#media_type 0: subtitle
+#codec_id 0: webvtt
+#tb 1: 1/90000
+#media_type 1: subtitle
+#codec_id 1: webvtt
+#tb 2: 1/90000
+#media_type 2: subtitle
+#codec_id 2: webvtt
+#tb 3: 1/90000
+#media_type 3: subtitle
+#codec_id 3: webvtt
+#tb 4: 1/90000
+#media_type 4: subtitle
+#codec_id 4: webvtt
+#tb 5: 1/1000
+#media_type 5: subtitle
+#codec_id 5: webvtt
+2,      90000,      90000,    90000,        4, 0x044101bc
+3,      90000,      90000,    90000,        4, 0x042401aa
+4,      90000,      90000,    90000,        3, 0x02a30154
+5,       1000,       1000,     1000,        3, 0x028e0142, S=1, WebVTT ID,       15, 0x226c0444
+5,       3000,       3000,      500,        3, 0x02b9015a, S=1, WebVTT Settings,        6, 0x08210213
+0,     990000,     990000,    90000,        3, 0x028e0142, S=1, WebVTT ID,       15, 0x226c0444
+1,    1125000,    1125000,    45000,        5, 0x06690218
+0,    1170000,    1170000,    45000,        3, 0x02b9015a, S=1, WebVTT Settings,        6, 0x08210213