PR #24444 opened by hfliuyun
URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24444
Patch URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24444.patch
## Summary of changes
The Nero chpl atom stores the chapter title length in a single byte,so titles
are limited to 255 bytes. Truncating at a raw byte offset can split a
multi-byte UTF-8 character, writing invalid UTF-8 into the atom, which strict
parsers such as Mutagen reject.
Truncate at the largest valid UTF-8 boundary not exceeding 255 bytes instead; a
title that fits is left unchanged. Add a FATE test for a title whose last
character straddles the limit.
## Verification
Using the reproducer from the issue (a chapter title of 253 ASCII 'A' followed
by U+2026, 256 bytes in total) and the reporter's tool, Mutagen:
```python
from mutagen.mp4 import MP4
m = MP4("./chpl-utf8-truncation.m4a")
print(len(m.chapters[0].title))
print(m.chapters[0].title == "A" * 253)
```
- unpatched buildļ¼
```text
mutagen.mp4.MP4MetadataError: chapter 0 title: 'utf-8' codec can't decode bytes
in position 253-254: unexpected end of data
```
- patch build:
```text
253
True
```
And `make fate-mov-chpl-utf8` passes.
Fixes: https://code.ffmpeg.org/FFmpeg/FFmpeg/issues/24423
From b61f4fc607099001fcc6813b318ea6f30a6c8227 Mon Sep 17 00:00:00 2001
From: liuyun <[email protected]>
Date: Fri, 11 Sep 2026 02:19:13 +0800
Subject: [PATCH] avformat/movenc: truncate chpl titles at UTF-8 boundary
The Nero chpl atom stores the chapter title length in a single byte,
so titles are limited to 255 bytes. Truncating at a raw byte offset
can split a multi-byte UTF-8 character, writing invalid UTF-8 into
the atom, which strict parsers such as Mutagen reject.
Truncate at the largest valid UTF-8 boundary not exceeding 255 bytes
instead; a title that fits is left unchanged. Add a FATE test for a
title whose last character straddles the limit.
Fixes: https://code.ffmpeg.org/FFmpeg/FFmpeg/issues/24423
---
libavformat/movenc.c | 20 +++++++++++++++++++-
tests/chpl-utf8.ffmeta | 6 ++++++
tests/fate/mov.mak | 10 ++++++++++
tests/ref/fate/mov-chpl-utf8 | 21 +++++++++++++++++++++
4 files changed, 56 insertions(+), 1 deletion(-)
create mode 100644 tests/chpl-utf8.ffmeta
create mode 100644 tests/ref/fate/mov-chpl-utf8
diff --git a/libavformat/movenc.c b/libavformat/movenc.c
index 4c7868c5f8..97ade3409a 100644
--- a/libavformat/movenc.c
+++ b/libavformat/movenc.c
@@ -151,6 +151,24 @@ static int utf8len(const uint8_t *b)
return len;
}
+static int utf8_truncated_length(const char *str, int maxlen)
+{
+ const char *p = str;
+ const char *end = p + strlen(str);
+ int len = 0;
+
+ while (p < end) {
+ const char *start = p;
+ uint32_t c;
+ GET_UTF8(c, p < end ? (uint8_t)*p++ : 0, goto done;)
+ if (len + (p - start) > maxlen)
+ break;
+ len += (p - start);
+ }
+done:
+ return len;
+}
+
//FIXME support 64 bit variant with wide placeholders
static int64_t update_size(AVIOContext *pb, int64_t pos)
{
@@ -5087,7 +5105,7 @@ static int mov_write_chpl_tag(AVIOContext *pb,
AVFormatContext *s)
avio_wb64(pb, av_rescale_q(c->start, c->time_base,
(AVRational){1,10000000}));
if ((t = av_dict_get(c->metadata, "title", NULL, 0))) {
- int len = FFMIN(strlen(t->value), 255);
+ int len = utf8_truncated_length(t->value, 255);
avio_w8(pb, len);
avio_write(pb, t->value, len);
} else
diff --git a/tests/chpl-utf8.ffmeta b/tests/chpl-utf8.ffmeta
new file mode 100644
index 0000000000..5bc2e50634
--- /dev/null
+++ b/tests/chpl-utf8.ffmeta
@@ -0,0 +1,6 @@
+;FFMETADATA1
+[CHAPTER]
+TIMEBASE=1/1000
+START=0
+END=100
+title=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAā¦
diff --git a/tests/fate/mov.mak b/tests/fate/mov.mak
index 022e0214d6..bc57db8534 100644
--- a/tests/fate/mov.mak
+++ b/tests/fate/mov.mak
@@ -441,6 +441,16 @@ fate-mov-reenc-delete-stream-metadata: CMD = transcode mov
$(TARGET_SAMPLES)/qt-
FATE_MOV_FFMPEG_FFPROBE-$(call ENCDEC, FLAC PCM_S16BE, NUT MOV) +=
fate-mov-reenc-delete-stream-metadata-global-tag
fate-mov-reenc-delete-stream-metadata-global-tag: CMD = transcode mov
$(TARGET_SAMPLES)/qt-surge-suite/surge-2-16-B-twos.mov nut "-c:a flac -bitexact
-t 0.1 -metadata vendor_id=custom" "-c copy -t 0.1" "-show_entries
format_tags:stream_tags" "" "" "" null
+# chpl titles are limited to 255 bytes; truncation must not split a UTF-8
+# character. 3gp is used because MP4/MOV read the chapter track, not chpl.
+FATE_MOV_FFMPEG_FFPROBE-$(call TRANSCODE, AAC, TGP MOV, WAV_DEMUXER
FFMETADATA_DEMUXER ARESAMPLE_FILTER) += fate-mov-chpl-utf8
+fate-mov-chpl-utf8: tests/data/asynth-44100-2.wav
+fate-mov-chpl-utf8: CMD = transcode wav
$(TARGET_PATH)/tests/data/asynth-44100-2.wav 3gp \
+ "-af aresample -map 0:a -map_chapters 1 -c:a aac" \
+ "-af aresample -t 0.1" \
+ "-show_chapters" \
+ "-f ffmetadata -i $(SRC_PATH)/tests/chpl-utf8.ffmeta"
+
FATE_FFMPEG += $(FATE_MOV_FFMPEG-yes)
FATE_FFMPEG_FFPROBE += $(FATE_MOV_FFMPEG_FFPROBE-yes)
diff --git a/tests/ref/fate/mov-chpl-utf8 b/tests/ref/fate/mov-chpl-utf8
new file mode 100644
index 0000000000..f41ea6cf19
--- /dev/null
+++ b/tests/ref/fate/mov-chpl-utf8
@@ -0,0 +1,21 @@
+a5e8b8e9ced7a15afd95f1b3fac7baa4 *tests/data/fate/mov-chpl-utf8.3gp
+70380 tests/data/fate/mov-chpl-utf8.3gp
+#tb 0: 1/44100
+#media_type 0: audio
+#codec_id 0: pcm_s16le
+#sample_rate 0: 44100
+#channel_layout_name 0: stereo
+0, 0, 0, 1024, 4096, 0x8d0fda7b
+0, 1024, 1024, 1024, 4096, 0x2db4fbbd
+0, 2048, 2048, 1024, 4096, 0xc0a80afe
+0, 3072, 3072, 1024, 4096, 0x7001ebf1
+0, 4096, 4096, 314, 1256, 0x97ee6ec2
+[CHAPTER]
+id=0
+time_base=1/10000000
+start=0
+start_time=0.000000
+end=60000000
+end_time=6.000000
+TAG:title=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+[/CHAPTER]
--
2.52.0
_______________________________________________
ffmpeg-devel mailing list -- [email protected]
To unsubscribe send an email to [email protected]