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