[PR] avformat/mp3enc: buffer ID3v2 for non-seekable outputs (PR #23996)

iSoldLeo via ffmpeg-devel <[email protected]> Mon, 03 Aug 2026 14:51:46 -0000
Newsgroups gmane.comp.video.ffmpeg.devel
Message-ID <178576870730.59.5615482294996351477@29965ddac10e>
PR #23996 opened by iSoldLeo
URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/23996
Patch URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/23996.patch

ID3v2 finalization seeks back to update the tag size. After data has been written to a non-seekable output, that seek fails and leaves the tag size at zero. This affects attached pictures and sufficiently large metadata.

Build ID3v2 tags in a dynamic buffer for non-seekable MP3 outputs, then write the finalized tag to the output. Seekable outputs keep the existing path.

Two FATE tests cover non-seekable output with an attached picture and with large metadata padding.

Fixes #23917


>From 9373581d18bc83d160b815a7bda960625b20062d Mon Sep 17 00:00:00 2001
From: iSold Leo <[email protected]>
Date: Mon, 3 Aug 2026 22:48:32 +0800
Subject: [PATCH] avformat/mp3enc: buffer ID3v2 for non-seekable outputs

ID3v2 writes the tag size by seeking back to its header. This fails after output has been flushed to a non-seekable AVIO context, leaving a zero-sized tag. Attached pictures expose this reliably; sufficiently large metadata is affected too.

Build the tag in a dynamic buffer for non-seekable MP3 outputs and copy it after finalization. Keep the direct path for seekable outputs.

Fixes: #23917
---
 libavformat/aiffenc.c    |  4 +--
 libavformat/id3v2.h      |  6 ++--
 libavformat/id3v2enc.c   | 32 +++++++++++---------
 libavformat/mp3enc.c     | 64 ++++++++++++++++++++++++++++++++++------
 tests/fate/cover-art.mak |  6 ++++
 tests/fate/id3v2.mak     |  5 ++++
 6 files changed, 90 insertions(+), 27 deletions(-)

diff --git a/libavformat/aiffenc.c b/libavformat/aiffenc.c
index 3368e28404..e27a2ca5e6 100644
--- a/libavformat/aiffenc.c
+++ b/libavformat/aiffenc.c
@@ -59,9 +59,9 @@ static int put_id3v2_tags(AVFormatContext *s, AIFFOutputContext *aiff)
     pos = avio_tell(pb);
 
     ff_id3v2_start(&id3v2, pb, aiff->id3v2_version, ID3v2_DEFAULT_MAGIC);
-    ff_id3v2_write_metadata(s, &id3v2);
+    ff_id3v2_write_metadata(s, pb, &id3v2);
     while (list_entry) {
-        if ((ret = ff_id3v2_write_apic(s, &id3v2, &list_entry->pkt)) < 0)
+        if ((ret = ff_id3v2_write_apic(s, pb, &id3v2, &list_entry->pkt)) < 0)
             return ret;
         list_entry = list_entry->next;
     }
diff --git a/libavformat/id3v2.h b/libavformat/id3v2.h
index 9afa5a2ddc..f31d9115e8 100644
--- a/libavformat/id3v2.h
+++ b/libavformat/id3v2.h
@@ -137,12 +137,14 @@ void ff_id3v2_start(ID3v2EncContext *id3, AVIOContext *pb, int id3v2_version,
 /**
  * Convert and write all global metadata from s into an ID3v2 tag.
  */
-int ff_id3v2_write_metadata(AVFormatContext *s, ID3v2EncContext *id3);
+int ff_id3v2_write_metadata(AVFormatContext *s, AVIOContext *pb,
+                            ID3v2EncContext *id3);
 
 /**
  * Write an attached picture from pkt into an ID3v2 tag.
  */
-int ff_id3v2_write_apic(AVFormatContext *s, ID3v2EncContext *id3, AVPacket *pkt);
+int ff_id3v2_write_apic(AVFormatContext *s, AVIOContext *pb,
+                        ID3v2EncContext *id3, AVPacket *pkt);
 
 /**
  * Finalize an opened ID3v2 tag.
diff --git a/libavformat/id3v2enc.c b/libavformat/id3v2enc.c
index 12b0400541..015a767202 100644
--- a/libavformat/id3v2enc.c
+++ b/libavformat/id3v2enc.c
@@ -408,7 +408,8 @@ static int write_metadata(AVIOContext *pb, AVDictionary **metadata,
     return 0;
 }
 
-static int write_ctoc(AVFormatContext *s, ID3v2EncContext *id3, int enc)
+static int write_ctoc(AVFormatContext *s, AVIOContext *pb,
+                      ID3v2EncContext *id3, int enc)
 {
     AVIOContext *dyn_bc;
     char name[123];
@@ -428,11 +429,12 @@ static int write_ctoc(AVFormatContext *s, ID3v2EncContext *id3, int enc)
         avio_put_str(dyn_bc, name);
     }
 
-    return id3v2_put_frame(id3, s->pb, dyn_bc,
+    return id3v2_put_frame(id3, pb, dyn_bc,
                            MKBETAG('C', 'T', 'O', 'C'), 0);
 }
 
-static int write_chapter(AVFormatContext *s, ID3v2EncContext *id3, int id, int enc)
+static int write_chapter(AVFormatContext *s, AVIOContext *pb,
+                         ID3v2EncContext *id3, int id, int enc)
 {
     const AVRational time_base = {1, 1000};
     AVChapter *ch = s->chapters[id];
@@ -460,10 +462,10 @@ static int write_chapter(AVFormatContext *s, ID3v2EncContext *id3, int id, int e
     len = avio_get_dyn_buf(dyn_bc, &dyn_buf);
     id3->len += 16 + ID3v2_HEADER_SIZE;
 
-    avio_wb32(s->pb, MKBETAG('C', 'H', 'A', 'P'));
-    avio_wb32(s->pb, len);
-    avio_wb16(s->pb, 0);
-    avio_write(s->pb, dyn_buf, len);
+    avio_wb32(pb, MKBETAG('C', 'H', 'A', 'P'));
+    avio_wb32(pb, len);
+    avio_wb16(pb, 0);
+    avio_write(pb, dyn_buf, len);
 
 fail:
     ffio_free_dyn_buf(&dyn_bc);
@@ -471,28 +473,30 @@ fail:
     return ret;
 }
 
-int ff_id3v2_write_metadata(AVFormatContext *s, ID3v2EncContext *id3)
+int ff_id3v2_write_metadata(AVFormatContext *s, AVIOContext *pb,
+                            ID3v2EncContext *id3)
 {
     int enc = id3->version == 3 ? ID3v2_ENCODING_UTF16BOM :
                                   ID3v2_ENCODING_UTF8;
     int i, ret;
 
     ff_standardize_creation_time(s);
-    if ((ret = write_metadata(s->pb, &s->metadata, id3, enc)) < 0)
+    if ((ret = write_metadata(pb, &s->metadata, id3, enc)) < 0)
         return ret;
 
-    if ((ret = write_ctoc(s, id3, enc)) < 0)
+    if ((ret = write_ctoc(s, pb, id3, enc)) < 0)
         return ret;
 
     for (i = 0; i < s->nb_chapters; i++) {
-        if ((ret = write_chapter(s, id3, i, enc)) < 0)
+        if ((ret = write_chapter(s, pb, id3, i, enc)) < 0)
             return ret;
     }
 
     return 0;
 }
 
-int ff_id3v2_write_apic(AVFormatContext *s, ID3v2EncContext *id3, AVPacket *pkt)
+int ff_id3v2_write_apic(AVFormatContext *s, AVIOContext *pb,
+                        ID3v2EncContext *id3, AVPacket *pkt)
 {
     AVStream *st = s->streams[pkt->stream_index];
     AVDictionaryEntry *e;
@@ -545,7 +549,7 @@ int ff_id3v2_write_apic(AVFormatContext *s, ID3v2EncContext *id3, AVPacket *pkt)
     id3v2_encode_string(dyn_buf, desc, enc);
     avio_write(dyn_buf, pkt->data, pkt->size);
 
-    return id3v2_put_frame(id3, s->pb, dyn_buf,
+    return id3v2_put_frame(id3, pb, dyn_buf,
                            MKBETAG('A', 'P', 'I', 'C'), 0);
 }
 
@@ -580,7 +584,7 @@ int ff_id3v2_write_simple(struct AVFormatContext *s, int id3v2_version,
     int ret;
 
     ff_id3v2_start(&id3, s->pb, id3v2_version, magic);
-    if ((ret = ff_id3v2_write_metadata(s, &id3)) < 0)
+    if ((ret = ff_id3v2_write_metadata(s, s->pb, &id3)) < 0)
         return ret;
     ff_id3v2_finish(&id3, s->pb, s->metadata_header_padding);
 
diff --git a/libavformat/mp3enc.c b/libavformat/mp3enc.c
index 131fb231fc..796e18d481 100644
--- a/libavformat/mp3enc.c
+++ b/libavformat/mp3enc.c
@@ -100,6 +100,7 @@ static int id3v1_create_tag(AVFormatContext *s, uint8_t *buf)
 typedef struct MP3Context {
     const AVClass *class;
     ID3v2EncContext id3;
+    AVIOContext *id3_pb;
     int id3v2_version;
     int write_id3v1;
     int write_xing;
@@ -379,14 +380,41 @@ static int mp3_write_audio_packet(AVFormatContext *s, AVPacket *pkt)
     return ff_raw_write_packet(s, pkt);
 }
 
+static int mp3_finish_id3v2(AVFormatContext *s)
+{
+    MP3Context *mp3 = s->priv_data;
+    AVIOContext *pb = mp3->id3_pb ? mp3->id3_pb : s->pb;
+    uint8_t *buf = NULL;
+    int ret, size;
+
+    ff_id3v2_finish(&mp3->id3, pb, s->metadata_header_padding);
+    ret = pb->error;
+
+    if (!mp3->id3_pb)
+        return ret;
+
+    size = avio_get_dyn_buf(mp3->id3_pb, &buf);
+    ret = mp3->id3_pb->error;
+    if (ret >= 0) {
+        avio_write(s->pb, buf, size);
+        ret = s->pb->error;
+    }
+    ffio_free_dyn_buf(&mp3->id3_pb);
+
+    return ret;
+}
+
 static int mp3_queue_flush(AVFormatContext *s)
 {
     MP3Context *mp3 = s->priv_data;
     AVPacket *const pkt = ffformatcontext(s)->pkt;
     int ret = 0, write = 1;
 
-    ff_id3v2_finish(&mp3->id3, s->pb, s->metadata_header_padding);
-    mp3_write_xing(s);
+    ret = mp3_finish_id3v2(s);
+    if (ret < 0)
+        write = 0;
+    else
+        mp3_write_xing(s);
 
     while (mp3->queue.head) {
         ff_packet_list_get(&mp3->queue, pkt);
@@ -474,11 +502,13 @@ static int mp3_write_trailer(struct AVFormatContext *s)
 {
     uint8_t buf[ID3v1_TAG_SIZE];
     MP3Context *mp3 = s->priv_data;
+    int ret;
 
     if (mp3->pics_to_write) {
         av_log(s, AV_LOG_WARNING, "No packets were sent for some of the "
                "attached pictures.\n");
-        mp3_queue_flush(s);
+        if ((ret = mp3_queue_flush(s)) < 0)
+            return ret;
     }
 
     /* write the id3v1 tag */
@@ -536,12 +566,14 @@ static int mp3_write_packet(AVFormatContext *s, AVPacket *pkt)
             if (ret < 0) {
                 av_log(s, AV_LOG_WARNING, "Not enough memory to buffer audio. Skipping picture streams\n");
                 mp3->pics_to_write = 0;
-                mp3_queue_flush(s);
+                if ((ret = mp3_queue_flush(s)) < 0)
+                    return ret;
                 return mp3_write_audio_packet(s, pkt);
             }
         } else
             return mp3_write_audio_packet(s, pkt);
     } else {
+        AVIOContext *pb = mp3->id3_pb ? mp3->id3_pb : s->pb;
         int ret;
 
         /* warn only once for each stream */
@@ -552,8 +584,11 @@ static int mp3_write_packet(AVFormatContext *s, AVPacket *pkt)
         if (!mp3->pics_to_write || s->streams[pkt->stream_index]->nb_frames >= 1)
             return 0;
 
-        if ((ret = ff_id3v2_write_apic(s, &mp3->id3, pkt)) < 0)
+        ret = ff_id3v2_write_apic(s, pb, &mp3->id3, pkt);
+        if (ret < 0)
             return ret;
+        if (pb->error < 0)
+            return pb->error;
         mp3->pics_to_write--;
 
         /* flush the buffered audio packets */
@@ -617,18 +652,28 @@ static int mp3_init(struct AVFormatContext *s)
 static int mp3_write_header(struct AVFormatContext *s)
 {
     MP3Context  *mp3 = s->priv_data;
+    AVIOContext *pb = s->pb;
     int ret;
 
     if (mp3->id3v2_version) {
-        ff_id3v2_start(&mp3->id3, s->pb, mp3->id3v2_version, ID3v2_DEFAULT_MAGIC);
-        ret = ff_id3v2_write_metadata(s, &mp3->id3);
+        if (!(s->pb->seekable & AVIO_SEEKABLE_NORMAL)) {
+            ret = avio_open_dyn_buf(&mp3->id3_pb);
+            if (ret < 0)
+                return ret;
+            pb = mp3->id3_pb;
+        }
+
+        ff_id3v2_start(&mp3->id3, pb, mp3->id3v2_version, ID3v2_DEFAULT_MAGIC);
+        ret = ff_id3v2_write_metadata(s, pb, &mp3->id3);
         if (ret < 0)
             return ret;
+        if (pb->error < 0)
+            return pb->error;
     }
 
     if (!mp3->pics_to_write) {
-        if (mp3->id3v2_version)
-            ff_id3v2_finish(&mp3->id3, s->pb, s->metadata_header_padding);
+        if (mp3->id3v2_version && (ret = mp3_finish_id3v2(s)) < 0)
+            return ret;
         mp3_write_xing(s);
     }
 
@@ -640,6 +685,7 @@ static void mp3_deinit(struct AVFormatContext *s)
     MP3Context *mp3 = s->priv_data;
 
     ff_packet_list_free(&mp3->queue);
+    ffio_free_dyn_buf(&mp3->id3_pb);
     av_freep(&mp3->xing_frame);
 }
 
diff --git a/tests/fate/cover-art.mak b/tests/fate/cover-art.mak
index b56d254bdd..febdec6f04 100644
--- a/tests/fate/cover-art.mak
+++ b/tests/fate/cover-art.mak
@@ -46,6 +46,12 @@ FATE_COVER_ART_REMUX-$(call ALLYES, MP3_DEMUXER MJPEG_DECODER \
                        += fate-cover-art-mp3-id3v2-remux
 fate-cover-art-mp3-id3v2-remux: CMD = transcode mp3 $(TARGET_SAMPLES)/exif/embedded_small.mp3 mp3 "-map 0 -map 0:v -map 0:v -c:a copy -filter:v:0 scale -filter:v:2 scale -c:v:0 bmp -c:v:1 copy -c:v:2 png -metadata:s:v:0 comment=Band/Orchestra" "-map 0 -c copy -t 0.1" "-show_entries stream_tags:stream_disposition=attached_pic:stream=index,codec_name"
 
+FATE_COVER_ART_REMUX-$(call ALLYES, MP3_DEMUXER MP3_MUXER MD5_PROTOCOL) \
+                       += fate-cover-art-mp3-id3v2-nonseekable
+fate-cover-art-mp3-id3v2-nonseekable: CMD = md5pipe -i $(TARGET_SAMPLES)/exif/embedded_small.mp3 -map 0 -c copy -fflags +bitexact -write_xing 0 -f mp3
+fate-cover-art-mp3-id3v2-nonseekable: CMP = oneline
+fate-cover-art-mp3-id3v2-nonseekable: REF = 7d7fc4c0a5fe89a418bab6c74b6cda08
+
 # Also covers muxing and demuxing of nonstandard channel layouts into FLAC
 # as well as the unorthodox multi_dim_quant option of the FLAC encoder.
 FATE_COVER_ART_REMUX-$(call ALLYES, MOV_DEMUXER OGG_DEMUXER   \
diff --git a/tests/fate/id3v2.mak b/tests/fate/id3v2.mak
index 4bde6225b1..76c2b76be3 100644
--- a/tests/fate/id3v2.mak
+++ b/tests/fate/id3v2.mak
@@ -18,6 +18,11 @@ fate-id3v2-keep-metadata-invalid-stream-spec: REF = Trailing garbage at the end
 FATE_ID3V2_FFMPEG_FFPROBE-$(call REMUX, MP3) += fate-id3v2-priv-remux
 fate-id3v2-priv-remux: CMD = transcode mp3 $(TARGET_SAMPLES)/id3v2/id3v2_priv.mp3 mp3 "-c copy" "-c copy -t 0.1" "-show_entries format_tags"
 
+FATE_ID3V2_FFMPEG-$(call ALLYES, MP3_DEMUXER MP3_MUXER MD5_PROTOCOL) += fate-id3v2-padding-nonseekable
+fate-id3v2-padding-nonseekable: CMD = md5pipe -i $(TARGET_SAMPLES)/exif/embedded_small.mp3 -map 0:a -map_metadata -1 -metadata title=padding-test -c copy -fflags +bitexact -write_xing 0 -metadata_header_padding 40000 -f mp3
+fate-id3v2-padding-nonseekable: CMP = oneline
+fate-id3v2-padding-nonseekable: REF = 6a53649febb6345d3752445dcd3f3159
+
 ID3V2_TESTBIN = libavformat/tests/id3v2$(EXESUF)
 
 FATE_ID3V2_RAW-$(call REMUX, MP3) += fate-id3v2-comm
-- 
2.52.0

_______________________________________________
ffmpeg-devel mailing list -- [email protected]
To unsubscribe send an email to [email protected]