Commit 955276412d for ffmpeg

commit 955276412d447902ac3dec1dd6a7ee08ad1128be
Author: Reinhard Nißl <rnissl@gmx.de>
Date:   Sun Sep 20 21:15:19 2026 +0200

    avcodec/bsf: add jpegapp to inject JPEG APP segments

    Inject APP0..APP15 segments from a length-prefixed sidecar into each
    MJPEG packet after SOI. A zero-length record passes the packet through.
    An APP1-only payload retains the existing APP0; a payload starting with
    APP0 replaces an APP0 located immediately after SOI.

    Consume records in packet arrival order, with a 16 MiB record limit.
    Missing or invalid records return an error. Remember sidecar read and
    validation errors so later packets cannot consume a rejected record's
    payload as metadata. Flushing neither rewinds the sidecar nor clears
    this error; closing the context does not read ahead.

    Count every consumed input packet in diagnostics, distinguish I/O errors
    from truncated records, include the open error text for a sidecar that
    cannot be opened, and document the sidecar and lifecycle contract.

    Co-authored-by: Cursor <cursoragent@cursor.com>
    Signed-off-by: Reinhard Nißl <rnissl@gmx.de>
    Assisted-by: Fairy

diff --git a/Changelog b/Changelog
index 01232ef212..32e7c8bfe7 100644
--- a/Changelog
+++ b/Changelog
@@ -18,6 +18,7 @@ version <next>:
 - ADPCM IMA Citrix decoder
 - Panasonic RW2 decoder
 - xHE-AAC encoding via libfdk-xhe-aac
+- jpegapp bitstream filter


 version 9.0:
diff --git a/doc/bitstream_filters.texi b/doc/bitstream_filters.texi
index a00757d315..47bb75892b 100644
--- a/doc/bitstream_filters.texi
+++ b/doc/bitstream_filters.texi
@@ -612,6 +612,45 @@ For example, to remux 30 MB/sec NTSC IMX to MOV:
 ffmpeg -i input.mxf -c copy -bsf:v imxdump -tag:v mx3n output.mov
 @end example

+@section jpegapp
+
+Inject JPEG APP0..APP15 segments from a length-prefixed sidecar into each
+MJPEG packet, after SOI.
+
+The sidecar is a sequence of records, one per encoded JPEG, in encode
+order:
+
+@example
+u32be length
+u8    payload[length]
+@end example
+
+@var{length} does not include the 4-byte prefix and must not exceed
+16 MiB. A zero @var{length} leaves that packet unchanged. A non-empty
+payload must be a concatenation of complete APP segments (@code{FF En}
+plus the 16-bit length that includes those two length bytes).
+
+If the payload starts with APP0 and the packet has an APP0 marker in
+the two bytes immediately following SOI, that APP0 segment is dropped.
+APP0 segments preceded by fill bytes or another segment are retained.
+An APP1-only payload keeps the encoder APP0: SOI, APP1, APP0, DQT, ...
+
+The filter requires @option{filename}. Extra unused sidecar bytes are
+ignored; a packet without a matching record is an error.
+Records follow packets in arrival order. Flushing the filter does not
+rewind the sidecar, so a reused context needs a sidecar positioned for
+the packets it will see next.
+A sidecar read or validation error prevents further record consumption
+by that filter context. To resume, create a new context with a sidecar
+matching the remaining packets. Flushing does not clear this error.
+
+@example
+ffmpeg -i in.mp4 -c:v mjpeg -bsf:v jpegapp=filename=app.bin frame_%d.jpg
+@end example
+
+On Windows, escape the drive colon in the BSF option:
+@code{filename='E\:/path/app.bin'}.
+
 @section mjpeg2jpeg

 Convert MJPEG/AVI1 packets to full JPEG/JFIF packets.
diff --git a/libavcodec/bitstream_filters.c b/libavcodec/bitstream_filters.c
index f57c2632a0..ed57db5281 100644
--- a/libavcodec/bitstream_filters.c
+++ b/libavcodec/bitstream_filters.c
@@ -49,6 +49,7 @@ extern const FFBitStreamFilter ff_hapqa_extract_bsf;
 extern const FFBitStreamFilter ff_hevc_metadata_bsf;
 extern const FFBitStreamFilter ff_hevc_mp4toannexb_bsf;
 extern const FFBitStreamFilter ff_imx_dump_header_bsf;
+extern const FFBitStreamFilter ff_jpegapp_bsf;
 extern const FFBitStreamFilter ff_lcevc_merge_bsf;
 extern const FFBitStreamFilter ff_lcevc_metadata_bsf;
 extern const FFBitStreamFilter ff_media100_to_mjpegb_bsf;
diff --git a/libavcodec/bsf/Makefile b/libavcodec/bsf/Makefile
index ec224e8774..c0badf5ed9 100644
--- a/libavcodec/bsf/Makefile
+++ b/libavcodec/bsf/Makefile
@@ -30,6 +30,7 @@ OBJS-$(CONFIG_DOVI_RPU_BSF)               += bsf/dovi_rpu.o
 OBJS-$(CONFIG_DOVI_SPLIT_BSF)             += bsf/dovi_split.o
 OBJS-$(CONFIG_HEVC_MP4TOANNEXB_BSF)       += bsf/hevc_mp4toannexb.o
 OBJS-$(CONFIG_IMX_DUMP_HEADER_BSF)        += bsf/imx_dump_header.o
+OBJS-$(CONFIG_JPEGAPP_BSF)                += bsf/jpegapp.o
 OBJS-$(CONFIG_LCEVC_MERGE_BSF)            += bsf/lcevc_merge.o
 OBJS-$(CONFIG_LCEVC_METADATA_BSF)         += bsf/lcevc_metadata.o
 OBJS-$(CONFIG_MEDIA100_TO_MJPEGB_BSF)     += bsf/media100_to_mjpegb.o
diff --git a/libavcodec/bsf/jpegapp.c b/libavcodec/bsf/jpegapp.c
new file mode 100644
index 0000000000..c75822dfb6
--- /dev/null
+++ b/libavcodec/bsf/jpegapp.c
@@ -0,0 +1,287 @@
+/*
+ * Inject per-frame JPEG APP segments from a length-prefixed sidecar.
+ *
+ * Copyright (c) 2026 Reinhard Nißl
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+/**
+ * Sidecar format (big-endian), one record per encoded JPEG packet:
+ *
+ *   u32be length
+ *   u8    payload[length]
+ *
+ * Payload is zero or more complete JPEG APP segments (APP0..APP15),
+ * each ``FF En`` + 16-bit length (length includes those two bytes).
+ * A zero length leaves the packet unchanged.
+ *
+ * After SOI the payload is inserted. If it starts with APP0 and the
+ * two bytes immediately after SOI are an APP0 marker, that segment is
+ * dropped so the sidecar can replace it. An APP0 preceded by fill
+ * bytes or another segment is retained. An APP1-only payload keeps
+ * the encoder APP0: SOI, APP1, APP0, DQT, ...
+ *
+ * A sidecar read or validation error stops further record consumption
+ * for this filter context. Flushing does not clear that error.
+ */
+
+#include <errno.h>
+#include <inttypes.h>
+#include <limits.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "libavutil/error.h"
+#include "libavutil/file_open.h"
+#include "libavutil/intreadwrite.h"
+#include "libavutil/log.h"
+#include "libavutil/mem.h"
+#include "libavutil/opt.h"
+
+#include "libavcodec/bsf.h"
+#include "libavcodec/bsf_internal.h"
+#include "libavcodec/mjpeg.h"
+
+#define JPEGAPP_MAX_RECORD (16 * 1024 * 1024)
+
+typedef struct JPEGAppContext {
+    const AVClass *class;
+    char *filename;
+    FILE *file;
+    uint8_t *buf;
+    int buf_size;
+    int read_error;
+    int64_t packet_index;
+} JPEGAppContext;
+
+static int jpeg_app_segment_len(const uint8_t *data, int size)
+{
+    int len;
+
+    if (size < 4 || data[0] != 0xff || data[1] < APP0 || data[1] > APP15)
+        return AVERROR_INVALIDDATA;
+    len = AV_RB16(data + 2);
+    if (len < 2 || len > size - 2)
+        return AVERROR_INVALIDDATA;
+    return 2 + len;
+}
+
+static int jpegapp_validate_payload(const uint8_t *data, int size)
+{
+    int pos = 0, n;
+
+    while (pos < size) {
+        n = jpeg_app_segment_len(data + pos, size - pos);
+        if (n < 0)
+            return n;
+        pos += n;
+    }
+    return 0;
+}
+
+static int jpegapp_init(AVBSFContext *ctx)
+{
+    JPEGAppContext *s = ctx->priv_data;
+
+    if (!s->filename || !s->filename[0]) {
+        av_log(ctx, AV_LOG_ERROR, "Option filename is required\n");
+        return AVERROR(EINVAL);
+    }
+
+    s->file = avpriv_fopen_utf8(s->filename, "rb");
+    if (!s->file) {
+        int err = errno ? AVERROR(errno) : AVERROR(EIO);
+        av_log(ctx, AV_LOG_ERROR, "Could not open '%s': %s\n",
+               s->filename, av_err2str(err));
+        return err;
+    }
+    return 0;
+}
+
+static void jpegapp_close(AVBSFContext *ctx)
+{
+    JPEGAppContext *s = ctx->priv_data;
+
+    if (s->file) {
+        fclose(s->file);
+        s->file = NULL;
+    }
+    av_freep(&s->buf);
+    s->buf_size = 0;
+}
+
+static int jpegapp_read_record(AVBSFContext *ctx, int *out_size)
+{
+    JPEGAppContext *s = ctx->priv_data;
+    uint8_t hdr[4];
+    uint32_t n;
+    size_t got;
+    int ret;
+
+    errno = 0;
+    got = fread(hdr, 1, 4, s->file);
+    ret = errno;
+    if (got == 0 && feof(s->file) && !ferror(s->file)) {
+        av_log(ctx, AV_LOG_ERROR,
+               "jpegapp: no sidecar record for packet %"PRId64"\n",
+               s->packet_index);
+        return AVERROR_INVALIDDATA;
+    }
+    if (got != 4) {
+        if (ferror(s->file)) {
+            av_log(ctx, AV_LOG_ERROR, "jpegapp: sidecar read failed\n");
+            return ret ? AVERROR(ret) : AVERROR(EIO);
+        }
+        av_log(ctx, AV_LOG_ERROR, "jpegapp: truncated length prefix\n");
+        return AVERROR_INVALIDDATA;
+    }
+    n = AV_RB32(hdr);
+    if (n > JPEGAPP_MAX_RECORD) {
+        av_log(ctx, AV_LOG_ERROR, "jpegapp: record length %u too large\n", n);
+        return AVERROR_INVALIDDATA;
+    }
+    if (n > (uint32_t)s->buf_size) {
+        ret = av_reallocp(&s->buf, n);
+        if (ret < 0) {
+            s->buf_size = 0;
+            return ret;
+        }
+        s->buf_size = n;
+    }
+    if (n) {
+        errno = 0;
+        got = fread(s->buf, 1, n, s->file);
+        ret = errno;
+        if (got != n) {
+            if (ferror(s->file)) {
+                av_log(ctx, AV_LOG_ERROR, "jpegapp: sidecar read failed\n");
+                return ret ? AVERROR(ret) : AVERROR(EIO);
+            }
+            av_log(ctx, AV_LOG_ERROR, "jpegapp: truncated record payload\n");
+            return AVERROR_INVALIDDATA;
+        }
+        ret = jpegapp_validate_payload(s->buf, n);
+        if (ret < 0) {
+            av_log(ctx, AV_LOG_ERROR,
+                   "jpegapp: payload is not a sequence of APP0..APP15 segments\n");
+            return ret;
+        }
+    }
+    *out_size = n;
+    return 0;
+}
+
+static int jpegapp_filter(AVBSFContext *ctx, AVPacket *out)
+{
+    JPEGAppContext *s = ctx->priv_data;
+    AVPacket *in;
+    int rec_size = 0, src_off, dst_size, ret;
+
+    ret = ff_bsf_get_packet(ctx, &in);
+    if (ret < 0)
+        return ret;
+
+    ret = s->read_error;
+    if (!ret)
+        ret = jpegapp_read_record(ctx, &rec_size);
+    s->packet_index++;
+    if (ret < 0) {
+        s->read_error = ret;
+        goto fail;
+    }
+
+    if (in->size < 2 || AV_RB16(in->data) != 0xffd8) {
+        av_log(ctx, AV_LOG_ERROR, "jpegapp: packet is not JPEG (missing SOI)\n");
+        ret = AVERROR_INVALIDDATA;
+        goto fail;
+    }
+
+    src_off = 2;
+    if (rec_size >= 2 && s->buf[0] == 0xff && s->buf[1] == APP0 &&
+        in->size >= 4 && in->data[2] == 0xff && in->data[3] == APP0) {
+        int skip = jpeg_app_segment_len(in->data + 2, in->size - 2);
+        if (skip < 0) {
+            av_log(ctx, AV_LOG_ERROR,
+                   "jpegapp: existing APP0 is invalid or truncated\n");
+            ret = skip;
+            goto fail;
+        }
+        src_off = 2 + skip;
+    }
+
+    if (!rec_size) {
+        ret = av_packet_ref(out, in);
+        goto fail;
+    }
+
+    if (in->size - src_off > INT_MAX - 2 - rec_size) {
+        ret = AVERROR(ERANGE);
+        goto fail;
+    }
+    dst_size = 2 + rec_size + (in->size - src_off);
+    ret = av_new_packet(out, dst_size);
+    if (ret < 0)
+        goto fail;
+    ret = av_packet_copy_props(out, in);
+    if (ret < 0)
+        goto fail;
+
+    memcpy(out->data, in->data, 2);
+    memcpy(out->data + 2, s->buf, rec_size);
+    memcpy(out->data + 2 + rec_size, in->data + src_off, in->size - src_off);
+    av_packet_free(&in);
+    return 0;
+
+fail:
+    if (ret < 0)
+        av_packet_unref(out);
+    av_packet_free(&in);
+    return ret;
+}
+
+#define OFFSET(x) offsetof(JPEGAppContext, x)
+#define FLAGS (AV_OPT_FLAG_VIDEO_PARAM | AV_OPT_FLAG_BSF_PARAM)
+static const AVOption jpegapp_options[] = {
+    { "filename", "sidecar of u32be-length + APP-segment records", OFFSET(filename),
+      AV_OPT_TYPE_STRING, { .str = NULL }, 0, 0, FLAGS },
+    { "file",     "sidecar of u32be-length + APP-segment records", OFFSET(filename),
+      AV_OPT_TYPE_STRING, { .str = NULL }, 0, 0, FLAGS },
+    { NULL },
+};
+
+static const AVClass jpegapp_class = {
+    .class_name = "jpegapp",
+    .item_name  = av_default_item_name,
+    .option     = jpegapp_options,
+    .version    = LIBAVUTIL_VERSION_INT,
+};
+
+static const enum AVCodecID jpegapp_codec_ids[] = {
+    AV_CODEC_ID_MJPEG, AV_CODEC_ID_NONE,
+};
+
+const FFBitStreamFilter ff_jpegapp_bsf = {
+    .p.name         = "jpegapp",
+    .p.codec_ids    = jpegapp_codec_ids,
+    .p.priv_class   = &jpegapp_class,
+    .priv_data_size = sizeof(JPEGAppContext),
+    .init           = jpegapp_init,
+    .close          = jpegapp_close,
+    .filter         = jpegapp_filter,
+};