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,
+};