Inject APP0..APP15 from a length-prefixed sidecar into each MJPEG
packet, after SOI. Length 0 leaves the packet unchanged. An APP1-only
payload keeps encoder APP0 (typical JFIF).

Co-authored-by: Cursor <[email protected]>
Signed-off-by: Reinhard Nißl <[email protected]>
---
 Changelog                      |   1 +
 doc/bitstream_filters.texi     |  33 ++++
 libavcodec/bitstream_filters.c |   1 +
 libavcodec/bsf/Makefile        |   1 +
 libavcodec/bsf/jpegapp.c       | 269 +++++++++++++++++++++++++++++++++
 5 files changed, 305 insertions(+)
 create mode 100644 libavcodec/bsf/jpegapp.c

diff --git a/Changelog b/Changelog
index 1cfa9db1b6..ed98fe8580 100644
--- a/Changelog
+++ b/Changelog
@@ -15,6 +15,7 @@ version <next>:
 - H.264 data partitioning support
 - DSD (dsd_msbf) encoder
 
+- jpegapp bitstream filter
 
 version 9.0:
 - Extend AMF Color Converter (vf_vpp_amf) HDR capabilities
diff --git a/doc/bitstream_filters.texi b/doc/bitstream_filters.texi
index a00757d315..15e3479786 100644
--- a/doc/bitstream_filters.texi
+++ b/doc/bitstream_filters.texi
@@ -612,6 +612,39 @@ 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. Payload @code{0} 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 already has an APP0 after
+SOI (typical JFIF), that encoder APP0 is dropped so the sidecar can
+replace it. 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.
+
+@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..119b3f761a
--- /dev/null
+++ b/libavcodec/bsf/jpegapp.c
@@ -0,0 +1,269 @@
+/*
+ * 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).
+ * Length 0 leaves the packet unchanged.
+ *
+ * After SOI the payload is inserted. If it starts with APP0 and the
+ * packet already has an APP0 (typical JFIF), that encoder APP0 is
+ * dropped so the sidecar can replace it. An APP1-only payload keeps
+ * the encoder APP0: SOI, APP1, APP0, DQT, ...
+ */
+
+#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;
+    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'\n", s->filename);
+        return err;
+    }
+    return 0;
+}
+
+static void jpegapp_close(AVBSFContext *ctx)
+{
+    JPEGAppContext *s = ctx->priv_data;
+
+    if (s->file) {
+        if (getc(s->file) != EOF)
+            av_log(ctx, AV_LOG_VERBOSE,
+                   "jpegapp: unused sidecar data after %"PRId64" packets\n",
+                   s->packet_index);
+        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;
+
+    got = fread(hdr, 1, 4, s->file);
+    if (got == 0 && feof(s->file)) {
+        av_log(ctx, AV_LOG_ERROR,
+               "jpegapp: no sidecar record for packet %"PRId64"\n",
+               s->packet_index);
+        return AVERROR_EOF;
+    }
+    if (got != 4) {
+        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) {
+        got = fread(s->buf, 1, n, s->file);
+        if (got != n) {
+            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 = jpegapp_read_record(ctx, &rec_size);
+    if (ret < 0)
+        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) {
+            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);
+    s->packet_index++;
+    av_packet_free(&in);
+    return 0;
+
+fail:
+    if (ret >= 0)
+        s->packet_index++;
+    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,
+};
-- 
2.43.0

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

Reply via email to