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]

Reply via email to