commit c60eb77862edaaa23e6ef95863e90460d70ec873
parent d1fa87910de843d6aa62ff9992b51275d5f0c4ea
Author: Christian Grothoff <christian@grothoff.org>
Date: Fri, 7 Aug 2026 03:41:48 +0200
resurrect video-extractors
Diffstat:
36 files changed, 4358 insertions(+), 9 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -105,6 +105,10 @@ src/plugins/*.log
src/plugins/*.trs
src/plugins/test-suite.log
src/plugins/test_archive
+src/plugins/test_previewgst
+src/plugins/test_previewopus
+src/plugins/test_thumbnailffmpeg
+src/plugins/test_thumbnailgst
src/plugins/test_deb
src/plugins/test_dvi
src/plugins/test_elf
diff --git a/ChangeLog b/ChangeLog
@@ -1,3 +1,15 @@
+Fri Aug 7 03:40:00 AM CEST 2026
+ Add video thumbnail and audio preview plugins, in a GStreamer and an
+ FFmpeg (>= 5.1) variant each: thumbnailgst, thumbnailffmpeg,
+ previewgst and previewopus. The audio previews are Ogg Opus and are
+ now reported with the MIME type "audio/ogg" rather than the
+ "audio/opus" the plugin removed in 1.11 used, as the latter is not
+ the type for Opus in an Ogg container (RFC 7845).
+ Forget a channel on the plugin as well when the plugin process dies,
+ instead of handing the next file to a freed struct EXTRACTOR_Channel;
+ restart a plugin whose process is already gone when the next file is
+ submitted, rather than skipping that file. -CG
+
Thu Jul 30 11:14:02 PM CEST 2026
Add new RTF and msoffice plugins.
Fix interesting bugs and performance issues in ancient unzip logic.
diff --git a/Makefile.am b/Makefile.am
@@ -1,7 +1,8 @@
# This Makefile.am is in the public domain
SUBDIRS = m4 po src doc .
EXTRA_DIST = config.rpath \
- ABOUT-NLS
+ ABOUT-NLS \
+ contrib/gen_testmedia.sh
pkgconfigdatadir = $(libdir)/pkgconfig
pkgconfigdata_DATA = libextractor.pc
diff --git a/README b/README
@@ -55,6 +55,8 @@ The following dependencies are all optional, but should be
available in order for maximum coverage:
* libarchive
+* libavformat / libavcodec / libavutil / libswscale / libswresample
+ (FFmpeg 5.1 or later; for the video thumbnail and audio preview plugins)
* libbz2 (bzip2)
* libexiv2
* libflac
@@ -62,7 +64,11 @@ available in order for maximum coverage:
* libglib (glib)
* libgtk+
* libgsf
-* libgstreamer
+* libgstreamer (1.8 or later, with gst-plugins-base and gst-plugins-good;
+ the video thumbnail and audio preview plugins additionally need the
+ gstreamer-video, gstreamer-app and gstreamer-controller libraries, and
+ at run time the "playback", "videoconvertscale", "jpeg", "opus" and
+ "ogg" GStreamer plugins)
* libjpeg (v8 or later)
* libmagic (file)
* libmpeg2
diff --git a/configure.ac b/configure.ac
@@ -428,9 +428,11 @@ AC_CHECK_LIB(gif, DGifOpen,
AC_MSG_CHECKING(for magic_open -lmagic)
SAVED_AM_LDFLAGS=$AM_LDFLAGS
+have_magic=no
AC_CHECK_LIB(magic, magic_open,
[AC_CHECK_HEADERS([magic.h],
- AM_CONDITIONAL(HAVE_MAGIC, true),
+ [AM_CONDITIONAL(HAVE_MAGIC, true)
+ have_magic=yes],
AM_CONDITIONAL(HAVE_MAGIC, false))],
AM_CONDITIONAL(HAVE_MAGIC, false))
@@ -629,6 +631,46 @@ AS_IF([test "x$with_gstreamer" = "xyes"], [
AM_CONDITIONAL(HAVE_GSTREAMER, test x$have_gstreamer = xyes -a x$have_gstreamer_pbutils = xyes -a x$have_gstreamer_tag = xyes -a x$have_gstreamer_app = xyes -a ! x$without_glib = xtrue)
+# The video thumbnailer and the audio preview plugin need more of
+# GStreamer than the metadata plugin does: gstreamer-video-1.0 for
+# gst_video_convert_sample() and gstreamer-controller-1.0 for the fade.
+# 1.8 (2016) is the first release that ships opusenc in -base.
+have_gstreamer_video=no
+have_gstreamer_controller=no
+AS_IF([test "x$with_gstreamer" = "xyes"], [
+ PKG_CHECK_MODULES([GSTREAMER_VIDEO], [gstreamer-video-1.0 >= 1.8], [have_gstreamer_video=yes], [have_gstreamer_video=no])
+ PKG_CHECK_MODULES([GSTREAMER_CONTROLLER], [gstreamer-controller-1.0 >= 1.8], [have_gstreamer_controller=yes], [have_gstreamer_controller=no])
+])
+gstreamer_media=no
+AS_IF([test "x$have_gstreamer" = "xyes" -a "x$have_gstreamer_app" = "xyes" -a "x$have_gstreamer_video" = "xyes" -a "x$have_gstreamer_controller" = "xyes" -a "x$have_magic" = "xyes" -a ! "x$without_glib" = "xtrue"],
+ [gstreamer_media=yes])
+AM_CONDITIONAL(HAVE_GSTREAMER_MEDIA, test "x$gstreamer_media" = "xyes")
+
+# FFmpeg-based video thumbnailer and audio preview plugin. Note the
+# version floor: FFmpeg 5.1 is the first release with the AVChannelLayout
+# API, and supporting anything older would mean re-introducing the
+# per-version compatibility code that got the predecessors of these two
+# plugins deleted in 2021.
+AC_ARG_WITH([ffmpeg],
+ AS_HELP_STRING([--without-ffmpeg], [do not build the FFmpeg-based plugins]),
+ [], [with_ffmpeg=yes])
+have_ffmpeg=no
+have_swresample=no
+AS_IF([test "x$with_ffmpeg" = "xyes"], [
+ PKG_CHECK_MODULES([FFMPEG],
+ [libavformat >= 59.27 libavcodec >= 59.37 libavutil >= 57.28 libswscale >= 6.7],
+ [have_ffmpeg=yes], [have_ffmpeg=no])
+ PKG_CHECK_MODULES([FFMPEG_SWR], [libswresample >= 4.7], [have_swresample=yes], [have_swresample=no])
+])
+ffmpeg_video=no
+AS_IF([test "x$have_ffmpeg" = "xyes" -a "x$have_magic" = "xyes"],
+ [ffmpeg_video=yes])
+AM_CONDITIONAL(HAVE_FFMPEG, test "x$ffmpeg_video" = "xyes")
+ffmpeg_audio=no
+AS_IF([test "x$ffmpeg_video" = "xyes" -a "x$have_swresample" = "xyes"],
+ [ffmpeg_audio=yes])
+AM_CONDITIONAL(HAVE_FFMPEG_AUDIO, test "x$ffmpeg_audio" = "xyes")
+
ABI_GSF
AM_CONDITIONAL(HAVE_GSF, test "x$have_gsf" = "xtrue")
@@ -790,5 +832,17 @@ AS_IF([test x$have_gstreamer = xyes -a x$have_gstreamer_pbutils = xyes -a x$have
[AC_MSG_NOTICE([NOTICE: gstreamer enabled])])],
[AC_MSG_NOTICE([NOTICE: gstreamer not found, gstreamer support disabled])])
+AS_IF([test "x$gstreamer_media" = "xyes"],
+ [AC_MSG_NOTICE([NOTICE: gstreamer video thumbnail and audio preview plugins enabled])],
+ [AC_MSG_NOTICE([NOTICE: gstreamer-video/-controller/-app or libmagic missing, gstreamer thumbnail and preview plugins disabled])])
+
+AS_IF([test "x$ffmpeg_video" = "xyes"],
+ [AC_MSG_NOTICE([NOTICE: FFmpeg video thumbnail plugin enabled])],
+ [AC_MSG_NOTICE([NOTICE: FFmpeg >= 5.1 or libmagic not found, FFmpeg thumbnail plugin disabled])])
+
+AS_IF([test "x$ffmpeg_audio" = "xyes"],
+ [AC_MSG_NOTICE([NOTICE: FFmpeg opus audio preview plugin enabled])],
+ [AC_MSG_NOTICE([NOTICE: libswresample not found, FFmpeg opus audio preview plugin disabled])])
+
AS_IF([test "x$HAVE_APPARMOR_TRUE" = "x#"],
[AC_MSG_NOTICE([NOTICE: libapparmor not found, apparmor support disabled])])
diff --git a/contrib/apparmor/libextractor b/contrib/apparmor/libextractor
@@ -14,6 +14,14 @@ profile libextractor {
@{PROC}/fd/ r,
/sys/devices/system/cpu/ r,
/usr/lib/gstreamer-1.0/gst-plugin-scanner rix,
+ # the GStreamer-based plugins (thumbnailgst, previewgst) load their
+ # decoders and encoders from here at run time
+ /usr/lib/gstreamer-1.0/*.so mr,
+ /usr/lib/@{multiarch}/gstreamer-1.0/*.so mr,
+ /usr/lib/@{multiarch}/gstreamer-1.0/gst-plugin-scanner rix,
+ # note that no network rule appears anywhere in this profile: the
+ # media plugins feed FFmpeg and GStreamer from a pipe and must never
+ # open a stream of their own
/usr/local/lib/libextractor/libextractor_*.so* mr,
/usr/local/lib/libextractor_common.so.* mr,
/usr/share/file/misc/magic.mgc r,
diff --git a/contrib/gen_testmedia.sh b/contrib/gen_testmedia.sh
@@ -0,0 +1,58 @@
+#!/bin/sh
+# This file is in the public domain.
+#
+# Regenerate the media files that the video thumbnail and audio preview
+# plugins are tested against. They are checked into the repository (and
+# shipped in the tarball), so you only need this if you want to change
+# them -- or if you want to convince yourself that they really are
+# synthetic and carry nobody's copyright.
+#
+# Everything here comes out of FFmpeg's lavfi test sources (solid colours,
+# a box, a sine wave), so the result is not derived from any third party
+# work. The files are dedicated to the public domain (CC0); see
+# src/plugins/testdata/README.media.
+#
+# Usage: contrib/gen_testmedia.sh [output-directory]
+set -e
+
+OUT="${1:-src/plugins/testdata}"
+FFMPEG="${FFMPEG:-ffmpeg}"
+
+if ! command -v "$FFMPEG" > /dev/null 2>&1; then
+ echo "$0: $FFMPEG not found" >&2
+ exit 1
+fi
+
+# The video changes colour 20s in, and the thumbnailers are expected to
+# sample around 30s, so a thumbnail of the wrong moment is a thumbnail of
+# the wrong colour and the test notices. The white box keeps the frames
+# from being perfectly flat, which the thumbnailers deliberately skip.
+$FFMPEG -hide_banner -loglevel error -y \
+ -f lavfi -i "color=c=black:s=160x120:r=5:d=20,drawbox=x=10:y=10:w=24:h=24:color=white:t=fill" \
+ -f lavfi -i "color=c=red:s=160x120:r=5:d=70,drawbox=x=116:y=76:w=24:h=24:color=white:t=fill" \
+ -filter_complex "[0:v][1:v]concat=n=2:v=1:a=0[v]" \
+ -map "[v]" -c:v libtheora -q:v 3 \
+ "$OUT/thumbnail_pattern.ogv"
+
+# A 30s tone that changes pitch halfway through, so that a preview taken
+# from the wrong place is at least in principle detectable. 8kHz mono is
+# plenty for something that gets re-encoded at 24 kbps.
+$FFMPEG -hide_banner -loglevel error -y \
+ -f lavfi -i "sine=f=440:d=15:r=8000" \
+ -f lavfi -i "sine=f=880:d=15:r=8000" \
+ -filter_complex "[0:a][1:a]concat=n=2:v=0:a=1[a]" \
+ -map "[a]" -c:a libvorbis -q:a -2 -ac 1 \
+ "$OUT/preview_tone.ogg"
+
+# The same audio in a file that also has a video stream, so that the
+# preview plugins are exercised on a video container too.
+$FFMPEG -hide_banner -loglevel error -y \
+ -f lavfi -i "color=c=black:s=160x120:r=5:d=10,drawbox=x=10:y=10:w=24:h=24:color=white:t=fill" \
+ -f lavfi -i "color=c=red:s=160x120:r=5:d=20,drawbox=x=116:y=76:w=24:h=24:color=white:t=fill" \
+ -f lavfi -i "sine=f=440:d=15:r=8000" \
+ -f lavfi -i "sine=f=880:d=15:r=8000" \
+ -filter_complex "[0:v][1:v]concat=n=2:v=1:a=0[v];[2:a][3:a]concat=n=2:v=0:a=1[a]" \
+ -map "[v]" -map "[a]" -c:v libtheora -q:v 3 -c:a libvorbis -q:a -2 -ac 1 \
+ "$OUT/preview_tone.ogv"
+
+ls -l "$OUT/thumbnail_pattern.ogv" "$OUT/preview_tone.ogg" "$OUT/preview_tone.ogv"
diff --git a/doc/libextractor.texi b/doc/libextractor.texi
@@ -200,7 +200,11 @@ libavformat-dev
@item
libswscale-dev
@item
+libswresample-dev
+@item
libgstreamer1.0-dev
+@item
+libgstreamer-plugins-base1.0-dev
@end itemize
For Subversion access and compilation one also needs:
@@ -838,9 +842,16 @@ S3M
@item
SID
@item
-ThumbnailFFMPEG (using libavformat and related libav-libraries, including libswscale)
+PreviewGst (audio preview, using GStreamer)
+@item
+PreviewOpus (audio preview, using FFmpeg 5.1 or later)
+@item
+ThumbnailFFMPEG (video thumbnail, using libavformat, libavcodec and
+libswscale of FFmpeg 5.1 or later)
@item
-ThumbnailGtk (using libgtk)
+ThumbnailGst (video thumbnail, using GStreamer 1.8 or later)
+@item
+ThumbnailGtk (still image thumbnail, using gdk-pixbuf)
@item
TIFF (with libtiff, tested with v4)
@item
@@ -855,6 +866,79 @@ ZIP
also supported (as well as meta data embedded by @file{gzip} itself)
if zlib or libbz2 are available.
+@section Video thumbnails and audio previews
+
+Four of the plugins in that list do not report meta data that was found
+in the file, but meta data computed @emph{from} the file: a small image
+taken from a video (@code{EXTRACTOR_METATYPE_THUMBNAIL}) and a short
+audio excerpt re-encoded as Opus in an Ogg container
+(@code{EXTRACTOR_METATYPE_AUDIO_PREVIEW}). Both are reported as
+@code{EXTRACTOR_METAFORMAT_BINARY}.
+
+Each feature comes in two implementations, one on top of GStreamer and
+one on top of FFmpeg, and which of them are built depends on what
+@command{configure} found:
+
+@table @code
+@item thumbnailgst
+@itemx thumbnailffmpeg
+Decode the video and emit a frame from about 30 seconds in, scaled to
+fit a 128x128 box, as JPEG. Frames that are a perfectly flat surface
+(a black leader, a fade-in) are skipped.
+@item previewgst
+@itemx previewopus
+Decode about 15 seconds of audio from about 30 seconds in, fade it in
+and out, and emit it as a mono 24 kbit/s Ogg Opus stream with the MIME
+type @code{audio/ogg}.
+@end table
+
+In both cases the position is limited to a third of the duration, so a
+short file is sampled proportionally earlier.
+
+If both backends are installed, both plugins run and you get two
+thumbnails (or two previews) for the same file. Which one you want is
+an application decision; disable the other one in the configuration
+string, as in
+
+@example
+pl = EXTRACTOR_plugin_add_defaults (EXTRACTOR_OPTION_DEFAULT_POLICY);
+pl = EXTRACTOR_plugin_add_config (pl,
+ "-thumbnailffmpeg:-previewopus",
+ EXTRACTOR_OPTION_DEFAULT_POLICY);
+@end example
+
+All four accept a comma separated list of options in the plugin
+configuration string:
+
+@table @code
+@item size=@var{pixels}
+Edge length of the box the thumbnail must fit into (default: 128).
+@item format=jpeg|png
+Image format of the thumbnail (default: @code{jpeg}).
+@item offset=@var{seconds}
+How far into the media to sample (default: 30).
+@item length=@var{seconds}
+Length of the audio preview (default: 15).
+@item bitrate=@var{bits-per-second}
+Opus bitrate of the audio preview (default: 24000).
+@item deadline=@var{milliseconds}
+Wall clock budget for the whole extraction; when it runs out, the
+plugin reports nothing rather than keeping the application waiting
+(default: 10000).
+@end table
+
+For example, @code{"thumbnailgst(size=256,format=png)"} asks for a
+256x256 PNG.
+
+Two caveats apply to the GStreamer variants. Their decoders and
+encoders are themselves GStreamer plugins, resolved at run time, so a
+system that lacks the @code{jpeg} or @code{opus} GStreamer plugin
+builds them happily and then gets no results; and because GStreamer
+does not survive a @code{fork()} of a process that has already
+initialized it, an application that runs these plugins
+@code{EXTRACTOR_OPTION_IN_PROCESS} should not also use out-of-process
+plugins in the same process afterwards.
+
@node Writing new Plugins
@chapter Writing new Plugins
diff --git a/src/fuzz/Makefile.am b/src/fuzz/Makefile.am
@@ -346,6 +346,40 @@ fuzz_mime_LDADD = $(XLIB) -lmagic
nodist_fuzz_mime_SOURCES = $(PLUGIN_DIR)/mime_extractor.c
endif
+# The FFmpeg-based media plugins. What gets instrumented here is our own
+# glue -- the AVIO callbacks against the fault-injecting extraction
+# context of fuzz_ec.h, the option parser and the size arithmetic -- and
+# not FFmpeg's demuxers, which upstream fuzzes far more thoroughly than
+# we could. Their GStreamer counterparts are deliberately absent: they
+# dlopen() their decoders and start threads, which does not fit an
+# in-process deterministic harness (the gstreamer plugin has no target
+# here either).
+if HAVE_FFMPEG
+THUMBNAILFFMPEG_FUZZER = fuzz_thumbnailffmpeg
+fuzz_thumbnailffmpeg_SOURCES = fuzz_plugin.c
+fuzz_thumbnailffmpeg_CPPFLAGS = $(AM_CPPFLAGS) -I$(PLUGIN_DIR) \
+ $(FFMPEG_CFLAGS) \
+ -DLE_FUZZ_PLUGIN=thumbnailffmpeg -DLE_FUZZ_ID=THUMBNAILFFMPEG
+fuzz_thumbnailffmpeg_LDADD = $(XLIB) $(FFMPEG_LIBS) -lmagic
+nodist_fuzz_thumbnailffmpeg_SOURCES = \
+ $(PLUGIN_DIR)/thumbnailffmpeg_extractor.c \
+ $(PLUGIN_DIR)/mediaffmpeg.c \
+ $(PLUGIN_DIR)/mediautil.c
+endif
+
+if HAVE_FFMPEG_AUDIO
+PREVIEWOPUS_FUZZER = fuzz_previewopus
+fuzz_previewopus_SOURCES = fuzz_plugin.c
+fuzz_previewopus_CPPFLAGS = $(AM_CPPFLAGS) -I$(PLUGIN_DIR) \
+ $(FFMPEG_CFLAGS) $(FFMPEG_SWR_CFLAGS) \
+ -DLE_FUZZ_PLUGIN=previewopus -DLE_FUZZ_ID=PREVIEWOPUS
+fuzz_previewopus_LDADD = $(XLIB) $(FFMPEG_LIBS) $(FFMPEG_SWR_LIBS) -lmagic
+nodist_fuzz_previewopus_SOURCES = \
+ $(PLUGIN_DIR)/previewopus_extractor.c \
+ $(PLUGIN_DIR)/mediaffmpeg.c \
+ $(PLUGIN_DIR)/mediautil.c
+endif
+
if HAVE_GSF
OLE2_FUZZER = fuzz_ole2
fuzz_ole2_SOURCES = fuzz_plugin.c
@@ -369,7 +403,9 @@ check_PROGRAMS = \
$(OGG_FUZZER) \
$(ARCHIVE_FUZZER) \
$(MIME_FUZZER) \
- $(OLE2_FUZZER)
+ $(OLE2_FUZZER) \
+ $(THUMBNAILFFMPEG_FUZZER) \
+ $(PREVIEWOPUS_FUZZER)
TESTS = $(check_PROGRAMS)
diff --git a/src/fuzz/fuzz_plugin_name.h b/src/fuzz/fuzz_plugin_name.h
@@ -111,6 +111,8 @@
#define LE_ID_XM 29
#define LE_ID_ZIP 30
#define LE_ID_OLE2 31
+#define LE_ID_THUMBNAILFFMPEG 32
+#define LE_ID_PREVIEWOPUS 33
#define LE_FUZZ_IDVAL LE_FUZZ_CAT (LE_ID_, LE_FUZZ_ID)
@@ -166,6 +168,16 @@
#define LE_FUZZ_MAGIC ""
#define LE_FUZZ_SHAPE LE_SHAPE_RAW
+#elif LE_FUZZ_IDVAL == LE_ID_THUMBNAILFFMPEG
+/* Ogg, which is what the media plugins are tested against; libmagic has
+ to recognize it as video/* or the plugin declines to look at all */
+#define LE_FUZZ_MAGIC "OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00"
+#define LE_FUZZ_SHAPE LE_SHAPE_RAW
+
+#elif LE_FUZZ_IDVAL == LE_ID_PREVIEWOPUS
+#define LE_FUZZ_MAGIC "OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00"
+#define LE_FUZZ_SHAPE LE_SHAPE_RAW
+
#elif LE_FUZZ_IDVAL == LE_ID_MPEG
#define LE_FUZZ_MAGIC "\x00\x00\x01\xba"
#define LE_FUZZ_SHAPE LE_SHAPE_RAW
diff --git a/src/main/extractor.c b/src/main/extractor.c
@@ -413,9 +413,24 @@ do_extract (struct EXTRACTOR_PluginList *plugins,
&start,
sizeof (start)) ) )
{
- LOG ("Failed to send EXTRACT_START message to plugin\n");
+ /* Plugins that asked for "force-kill" exit after every file, so a
+ channel that is gone here is not an error at all -- but unless we
+ restart the plugin now, this file would silently be skipped by
+ it, and only every other file would ever be processed. */
EXTRACTOR_IPC_channel_destroy_ (pos->channel);
pos->channel = NULL;
+ if (NULL != shm)
+ pos->channel = EXTRACTOR_IPC_channel_create_ (pos,
+ shm);
+ if ( (NULL != pos->channel) &&
+ (-1 == EXTRACTOR_IPC_channel_send_ (pos->channel,
+ &start,
+ sizeof (start)) ) )
+ {
+ LOG ("Failed to send EXTRACT_START message to plugin\n");
+ EXTRACTOR_IPC_channel_destroy_ (pos->channel);
+ pos->channel = NULL;
+ }
}
}
done = 0;
@@ -506,7 +521,8 @@ do_extract (struct EXTRACTOR_PluginList *plugins,
(data_available = EXTRACTOR_IPC_shared_memory_set_ (shm,
ds,
min_seek,
- DEFAULT_SHM_SIZE)))
+ DEFAULT_SHM_SIZE))
+ )
{
LOG ("Failed to seek; full reset\n");
abort_all_channels (plugins);
diff --git a/src/main/extractor_ipc_gnu.c b/src/main/extractor_ipc_gnu.c
@@ -549,6 +549,8 @@ EXTRACTOR_IPC_channel_recv_ (struct EXTRACTOR_Channel **channels,
if (MAX_META_DATA == channel->mdata_size)
{
LOG ("Inbound message from channel too large, aborting\n");
+ channel->plugin->channel = NULL;
+ channel->plugin->round_finished = 1;
EXTRACTOR_IPC_channel_destroy_ (channel);
channels[i] = NULL;
continue;
@@ -560,6 +562,8 @@ EXTRACTOR_IPC_channel_recv_ (struct EXTRACTOR_Channel **channels,
channel->mdata_size)))
{
LOG_STRERROR ("realloc");
+ channel->plugin->channel = NULL;
+ channel->plugin->round_finished = 1;
EXTRACTOR_IPC_channel_destroy_ (channel);
channels[i] = NULL;
continue;
@@ -584,6 +588,12 @@ EXTRACTOR_IPC_channel_recv_ (struct EXTRACTOR_Channel **channels,
LOG_STRERROR ("read");
LOG ("Read error from channel, closing channel %s\n",
channel->plugin->libname);
+ /* The plugin is gone -- a crash in a plugin is exactly what this
+ library is built to survive. Forget the channel on the plugin
+ as well, or the next file would be handed to a
+ `struct EXTRACTOR_Channel' that has already been freed. */
+ channel->plugin->channel = NULL;
+ channel->plugin->round_finished = 1;
EXTRACTOR_IPC_channel_destroy_ (channel);
channels[i] = NULL;
continue;
diff --git a/src/main/extractor_ipc_w32.c b/src/main/extractor_ipc_w32.c
@@ -789,6 +789,8 @@ EXTRACTOR_IPC_channel_recv_ (struct EXTRACTOR_Channel **channels,
if (MAX_META_DATA == channels[i]->mdata_size)
{
LOG ("Inbound message from channel too large, aborting\n");
+ channels[i]->plugin->channel = NULL;
+ channels[i]->plugin->round_finished = 1;
EXTRACTOR_IPC_channel_destroy_ (channels[i]);
channels[i] = NULL;
continue;
@@ -800,6 +802,8 @@ EXTRACTOR_IPC_channel_recv_ (struct EXTRACTOR_Channel **channels,
channels[i]->mdata_size)))
{
LOG_STRERROR ("realloc");
+ channels[i]->plugin->channel = NULL;
+ channels[i]->plugin->round_finished = 1;
EXTRACTOR_IPC_channel_destroy_ (channels[i]);
channels[i] = NULL;
continue;
@@ -822,6 +826,11 @@ EXTRACTOR_IPC_channel_recv_ (struct EXTRACTOR_Channel **channels,
SetErrnoFromWinError (error);
if (! bresult)
LOG_STRERROR ("ReadFile");
+ /* the plugin is gone; forget the channel here as well, or the
+ next file would be handed to a `struct EXTRACTOR_Channel'
+ that has already been freed */
+ channels[i]->plugin->channel = NULL;
+ channels[i]->plugin->round_finished = 1;
EXTRACTOR_IPC_channel_destroy_ (channels[i]);
channels[i] = NULL;
continue;
diff --git a/src/plugins/Makefile.am b/src/plugins/Makefile.am
@@ -18,6 +18,14 @@ endif
PLUGINFLAGS = $(makesymbolic) $(LE_PLUGIN_LDFLAGS)
+# The media tests decode the PNG thumbnail they get back to check that the
+# plugin sampled the right moment of the video. Without zlib they fall
+# back to checking the magic number only.
+if HAVE_ZLIB
+TEST_MEDIA_CFLAGS = -DTEST_MEDIA_HAVE_PNG=1
+TEST_MEDIA_LIBS = -lz
+endif
+
SUBDIRS = .
EXTRA_DIST = \
@@ -59,6 +67,8 @@ EXTRA_DIST = \
testdata/ogg_courseclear.ogg \
testdata/pdf_extract.pdf \
testdata/png_image.png \
+ testdata/preview_tone.ogg \
+ testdata/preview_tone.ogv \
testdata/ps_bloomfilter.ps \
testdata/ps_wallace.ps \
testdata/ra3.ra \
@@ -73,13 +83,15 @@ EXTRA_DIST = \
testdata/s3m_2nd_pm.s3m \
testdata/applefile_test.applesingle \
testdata/sid_wizball.sid \
+ testdata/thumbnail_pattern.ogv \
testdata/thumbnail_torsten.jpg \
testdata/tiff_haute.tiff \
testdata/wav_noise.wav \
testdata/wav_alert.wav \
testdata/xm_diesel.xm \
testdata/zip_test.zip \
- testdata/README
+ testdata/README \
+ testdata/README.media
if HAVE_MAGIC
@@ -130,6 +142,28 @@ PLUGIN_GSTREAMER=libextractor_gstreamer.la
TEST_GSTREAMER=test_gstreamer
endif
+# The video thumbnailer and the audio preview plugin need libmagic and
+# more of GStreamer than the metadata plugin does; configure folds all of
+# that into HAVE_GSTREAMER_MEDIA.
+if HAVE_GSTREAMER_MEDIA
+PLUGIN_THUMBGST=libextractor_thumbnailgst.la
+TEST_THUMBGST=test_thumbnailgst
+PLUGIN_PREVGST=libextractor_previewgst.la
+TEST_PREVGST=test_previewgst
+endif
+
+# ... and the same two features on top of FFmpeg (>= 5.1), which is a
+# separate build option because the two backends are interchangeable.
+if HAVE_FFMPEG
+PLUGIN_THUMBFFMPEG=libextractor_thumbnailffmpeg.la
+TEST_THUMBFFMPEG=test_thumbnailffmpeg
+endif
+
+if HAVE_FFMPEG_AUDIO
+PLUGIN_PREVIEWOPUS=libextractor_previewopus.la
+TEST_PREVIEWOPUS=test_previewopus
+endif
+
if HAVE_JPEG
PLUGIN_JPEG=libextractor_jpeg.la
TEST_JPEG=test_jpeg
@@ -213,7 +247,11 @@ plugin_LTLIBRARIES = \
$(PLUGIN_MPEG) \
$(PLUGIN_OGG) \
$(PLUGIN_PDF) \
+ $(PLUGIN_PREVGST) \
+ $(PLUGIN_PREVIEWOPUS) \
$(PLUGIN_RPM) \
+ $(PLUGIN_THUMBFFMPEG) \
+ $(PLUGIN_THUMBGST) \
$(PLUGIN_VLC) \
$(PLUGIN_TIFF) \
$(PLUGIN_ZLIB)
@@ -252,8 +290,12 @@ check_PROGRAMS = \
$(TEST_MSOFFICE) \
$(TEST_OGG) \
$(TEST_PDF) \
+ $(TEST_PREVGST) \
+ $(TEST_PREVIEWOPUS) \
$(TEST_QT) \
$(TEST_RPM) \
+ $(TEST_THUMBFFMPEG) \
+ $(TEST_THUMBGST) \
$(TEST_TIFF) \
$(TEST_ZLIB)
@@ -389,6 +431,92 @@ test_gstreamer_CFLAGS = \
$(GSTREAMER_CFLAGS) $(GSTREAMER_PBUTILS_CFLAGS)
+libextractor_thumbnailgst_la_SOURCES = \
+ thumbnailgst_extractor.c \
+ mediagst.c mediagst.h \
+ mediautil.c mediautil.h
+libextractor_thumbnailgst_la_LDFLAGS = \
+ $(PLUGINFLAGS)
+libextractor_thumbnailgst_la_LIBADD = \
+ $(GSTREAMER_LIBS) $(GSTREAMER_APP_LIBS) $(GSTREAMER_VIDEO_LIBS) \
+ -lmagic $(XLIB) -lpthread
+libextractor_thumbnailgst_la_CFLAGS = \
+ $(GSTREAMER_CFLAGS) $(GSTREAMER_APP_CFLAGS) $(GSTREAMER_VIDEO_CFLAGS)
+
+test_thumbnailgst_SOURCES = \
+ test_thumbnailgst.c \
+ test_media_lib.c test_media_lib.h
+test_thumbnailgst_LDADD = \
+ $(top_builddir)/src/plugins/libtest.la \
+ $(TEST_MEDIA_LIBS)
+test_thumbnailgst_CFLAGS = \
+ $(AM_CFLAGS) $(TEST_MEDIA_CFLAGS)
+
+
+libextractor_previewgst_la_SOURCES = \
+ previewgst_extractor.c \
+ mediagst.c mediagst.h \
+ mediautil.c mediautil.h
+libextractor_previewgst_la_LDFLAGS = \
+ $(PLUGINFLAGS)
+libextractor_previewgst_la_LIBADD = \
+ $(GSTREAMER_LIBS) $(GSTREAMER_APP_LIBS) $(GSTREAMER_CONTROLLER_LIBS) \
+ -lmagic $(XLIB) -lpthread
+libextractor_previewgst_la_CFLAGS = \
+ $(GSTREAMER_CFLAGS) $(GSTREAMER_APP_CFLAGS) $(GSTREAMER_CONTROLLER_CFLAGS)
+
+test_previewgst_SOURCES = \
+ test_previewgst.c \
+ test_media_lib.c test_media_lib.h
+test_previewgst_LDADD = \
+ $(top_builddir)/src/plugins/libtest.la \
+ $(TEST_MEDIA_LIBS)
+test_previewgst_CFLAGS = \
+ $(AM_CFLAGS) $(TEST_MEDIA_CFLAGS)
+
+
+libextractor_thumbnailffmpeg_la_SOURCES = \
+ thumbnailffmpeg_extractor.c \
+ mediaffmpeg.c mediaffmpeg.h \
+ mediautil.c mediautil.h
+libextractor_thumbnailffmpeg_la_LDFLAGS = \
+ $(PLUGINFLAGS)
+libextractor_thumbnailffmpeg_la_LIBADD = \
+ $(FFMPEG_LIBS) -lmagic $(XLIB)
+libextractor_thumbnailffmpeg_la_CFLAGS = \
+ $(FFMPEG_CFLAGS)
+
+test_thumbnailffmpeg_SOURCES = \
+ test_thumbnailffmpeg.c \
+ test_media_lib.c test_media_lib.h
+test_thumbnailffmpeg_LDADD = \
+ $(top_builddir)/src/plugins/libtest.la \
+ $(TEST_MEDIA_LIBS)
+test_thumbnailffmpeg_CFLAGS = \
+ $(AM_CFLAGS) $(TEST_MEDIA_CFLAGS)
+
+
+libextractor_previewopus_la_SOURCES = \
+ previewopus_extractor.c \
+ mediaffmpeg.c mediaffmpeg.h \
+ mediautil.c mediautil.h
+libextractor_previewopus_la_LDFLAGS = \
+ $(PLUGINFLAGS)
+libextractor_previewopus_la_LIBADD = \
+ $(FFMPEG_LIBS) $(FFMPEG_SWR_LIBS) -lmagic $(XLIB)
+libextractor_previewopus_la_CFLAGS = \
+ $(FFMPEG_CFLAGS) $(FFMPEG_SWR_CFLAGS)
+
+test_previewopus_SOURCES = \
+ test_previewopus.c \
+ test_media_lib.c test_media_lib.h
+test_previewopus_LDADD = \
+ $(top_builddir)/src/plugins/libtest.la \
+ $(TEST_MEDIA_LIBS)
+test_previewopus_CFLAGS = \
+ $(AM_CFLAGS) $(TEST_MEDIA_CFLAGS)
+
+
libextractor_html_la_SOURCES = \
html_extractor.c
libextractor_html_la_CFLAGS = \
diff --git a/src/plugins/mediaffmpeg.c b/src/plugins/mediaffmpeg.c
@@ -0,0 +1,276 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+ */
+/**
+ * @file plugins/mediaffmpeg.c
+ * @brief plumbing between an extraction context and FFmpeg's demuxer
+ * @author Christian Grothoff
+ */
+#include "platform.h"
+#include "mediaffmpeg.h"
+
+
+/**
+ * Implementation of AVIOContext's read callback.
+ *
+ * @param opaque our `struct EXTRACTOR_FFmpegIO`
+ * @param buf where to write the data
+ * @param buf_size number of bytes to read
+ * @return number of bytes read, AVERROR_EOF at the end of the file
+ */
+static int
+read_packet (void *opaque,
+ uint8_t *buf,
+ int buf_size)
+{
+ struct EXTRACTOR_FFmpegIO *io = opaque;
+ void *data;
+ ssize_t ret;
+
+ ret = io->ec->read (io->ec->cls,
+ &data,
+ buf_size);
+ if (ret <= 0)
+ return AVERROR_EOF;
+ /* the buffer handed back by read() is only valid until the next call */
+ memcpy (buf,
+ data,
+ ret);
+ return (int) ret;
+}
+
+
+/**
+ * Implementation of AVIOContext's seek callback.
+ *
+ * @param opaque our `struct EXTRACTOR_FFmpegIO`
+ * @param offset position to seek to
+ * @param whence SEEK_SET, SEEK_CUR, SEEK_END or AVSEEK_SIZE
+ * @return new position, or a negative error code
+ */
+static int64_t
+seek_packet (void *opaque,
+ int64_t offset,
+ int whence)
+{
+ struct EXTRACTOR_FFmpegIO *io = opaque;
+ uint64_t size;
+
+ if (AVSEEK_SIZE == (whence & ~AVSEEK_FORCE))
+ {
+ size = io->ec->get_size (io->ec->cls);
+ if (UINT64_MAX == size)
+ return AVERROR (ESPIPE);
+ return (int64_t) size;
+ }
+ return io->ec->seek (io->ec->cls,
+ offset,
+ whence & ~AVSEEK_FORCE);
+}
+
+
+/**
+ * Implementation of AVIOInterruptCB; lets us get out of a decoder that is
+ * taking too long instead of being killed by the parent process.
+ *
+ * @param opaque our `struct EXTRACTOR_FFmpegIO`
+ * @return 1 if FFmpeg should abort
+ */
+static int
+interrupt_callback (void *opaque)
+{
+ struct EXTRACTOR_FFmpegIO *io = opaque;
+
+ return EXTRACTOR_media_deadline_expired_ (&io->deadline);
+}
+
+
+/**
+ * Swallow the diagnostics FFmpeg would otherwise write to stderr.
+ *
+ * @param ptr unused
+ * @param level unused
+ * @param fmt unused
+ * @param vl unused
+ */
+static void
+log_callback (void *ptr,
+ int level,
+ const char *fmt,
+ va_list vl)
+{
+ (void) ptr;
+ (void) level;
+ (void) fmt;
+ (void) vl;
+}
+
+
+void
+EXTRACTOR_ffmpeg_silence_ ()
+{
+ /* When something goes wrong in here, FFmpeg's log is the only thing
+ that will tell you what; keep it available for debugging, since in
+ the normal (out-of-process) case stderr is closed anyway. */
+ if (NULL != getenv ("LIBEXTRACTOR_DEBUG"))
+ return;
+ av_log_set_callback (&log_callback);
+}
+
+
+int
+EXTRACTOR_ffmpeg_open_ (struct EXTRACTOR_FFmpegIO *io)
+{
+ uint8_t *buffer;
+
+ io->fmt = NULL;
+ io->avio = NULL;
+ if (NULL == (buffer = av_malloc (EXTRACTOR_FFMPEG_AVIO_BUFFER)))
+ return -1;
+ if (NULL == (io->avio = avio_alloc_context (buffer,
+ EXTRACTOR_FFMPEG_AVIO_BUFFER,
+ 0,
+ io,
+ &read_packet,
+ NULL,
+ &seek_packet)))
+ {
+ av_free (buffer);
+ return -1;
+ }
+ if (NULL == (io->fmt = avformat_alloc_context ()))
+ {
+ EXTRACTOR_ffmpeg_close_ (io);
+ return -1;
+ }
+ io->fmt->pb = io->avio;
+ io->fmt->flags |= AVFMT_FLAG_CUSTOM_IO;
+ io->fmt->probesize = EXTRACTOR_FFMPEG_PROBE_SIZE;
+ io->fmt->max_analyze_duration = EXTRACTOR_FFMPEG_MAX_ANALYZE;
+ io->fmt->interrupt_callback.callback = &interrupt_callback;
+ io->fmt->interrupt_callback.opaque = io;
+ if (0 != avformat_open_input (&io->fmt,
+ NULL,
+ NULL,
+ NULL))
+ {
+ /* avformat_open_input() freed the context for us */
+ io->fmt = NULL;
+ EXTRACTOR_ffmpeg_close_ (io);
+ return -1;
+ }
+ if (0 > avformat_find_stream_info (io->fmt,
+ NULL))
+ {
+ EXTRACTOR_ffmpeg_close_ (io);
+ return -1;
+ }
+ return 0;
+}
+
+
+void
+EXTRACTOR_ffmpeg_close_ (struct EXTRACTOR_FFmpegIO *io)
+{
+ if (NULL != io->fmt)
+ avformat_close_input (&io->fmt);
+ if (NULL != io->avio)
+ {
+ /* we passed AVFMT_FLAG_CUSTOM_IO, so this is still ours to free; note
+ that avio may have replaced the buffer we originally handed it */
+ av_free (io->avio->buffer);
+ avio_context_free (&io->avio);
+ }
+}
+
+
+int
+EXTRACTOR_ffmpeg_open_stream_ (struct EXTRACTOR_FFmpegIO *io,
+ enum AVMediaType type,
+ AVCodecContext **ctx)
+{
+ const AVCodec *codec;
+ AVCodecContext *cc;
+ int stream;
+
+ *ctx = NULL;
+ stream = av_find_best_stream (io->fmt,
+ type,
+ -1,
+ -1,
+ &codec,
+ 0);
+ if ( (0 > stream) ||
+ (NULL == codec) )
+ return -1;
+ if (NULL == (cc = avcodec_alloc_context3 (codec)))
+ return -1;
+ if (0 > avcodec_parameters_to_context (cc,
+ io->fmt->streams[stream]->codecpar))
+ {
+ avcodec_free_context (&cc);
+ return -1;
+ }
+ /* one thread: we are in a forked child that is expected to talk to its
+ parent regularly, and there is nothing here worth parallelizing */
+ cc->thread_count = 1;
+ if (0 != avcodec_open2 (cc, codec, NULL))
+ {
+ avcodec_free_context (&cc);
+ return -1;
+ }
+ *ctx = cc;
+ return stream;
+}
+
+
+double
+EXTRACTOR_ffmpeg_seek_ (struct EXTRACTOR_FFmpegIO *io,
+ int stream,
+ AVCodecContext *ctx,
+ unsigned int offset)
+{
+ double duration;
+ double target;
+ int64_t ts;
+
+ duration = (AV_NOPTS_VALUE == io->fmt->duration)
+ ? -1.0
+ : ((double) io->fmt->duration) / AV_TIME_BASE;
+ target = offset;
+ if ( (duration > 0.0) &&
+ (target > duration / 3.0) )
+ target = duration / 3.0;
+ if ( (target <= 1.0) ||
+ (duration <= 0.0) )
+ return (target <= 1.0) ? 0.0 : target;
+ ts = av_rescale_q ((int64_t) (target * AV_TIME_BASE),
+ AV_TIME_BASE_Q,
+ io->fmt->streams[stream]->time_base);
+ if (0 > av_seek_frame (io->fmt,
+ stream,
+ ts,
+ AVSEEK_FLAG_BACKWARD))
+ return 0.0; /* seeking failed; caller decodes from wherever we are */
+ avcodec_flush_buffers (ctx);
+ return target;
+}
+
+
+/* end of mediaffmpeg.c */
diff --git a/src/plugins/mediaffmpeg.h b/src/plugins/mediaffmpeg.h
@@ -0,0 +1,144 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+ */
+/**
+ * @file plugins/mediaffmpeg.h
+ * @brief plumbing between an extraction context and FFmpeg's demuxer
+ * @author Christian Grothoff
+ *
+ * Shared by thumbnailffmpeg and previewopus. Requires FFmpeg 5.1 or
+ * newer; there is deliberately no compatibility code for older versions.
+ */
+#ifndef MEDIAFFMPEG_H
+#define MEDIAFFMPEG_H
+
+#include "extractor.h"
+#include "mediautil.h"
+
+#include <libavcodec/avcodec.h>
+#include <libavformat/avformat.h>
+#include <libavutil/avutil.h>
+
+/**
+ * Size of the buffer we give to the AVIOContext. Small on purpose: each
+ * refill turns into a read on the extraction context, which is what keeps
+ * the IPC channel from looking hung to the parent process (which kills
+ * plugins that stay silent for 500ms).
+ */
+#define EXTRACTOR_FFMPEG_AVIO_BUFFER (16 * 1024)
+
+/**
+ * Do not look at more than this much data to identify the streams.
+ */
+#define EXTRACTOR_FFMPEG_PROBE_SIZE (1024 * 1024)
+
+/**
+ * Do not spend more than this much media time on stream analysis.
+ */
+#define EXTRACTOR_FFMPEG_MAX_ANALYZE (2 * AV_TIME_BASE)
+
+
+/**
+ * State of an FFmpeg demuxer reading from an extraction context.
+ */
+struct EXTRACTOR_FFmpegIO
+{
+ /**
+ * Extraction context we are reading from.
+ */
+ struct EXTRACTOR_ExtractContext *ec;
+
+ /**
+ * When we have to give up.
+ */
+ struct EXTRACTOR_MediaDeadline deadline;
+
+ /**
+ * Format context, NULL if not open.
+ */
+ AVFormatContext *fmt;
+
+ /**
+ * I/O context wrapping @e ec.
+ */
+ AVIOContext *avio;
+};
+
+
+/**
+ * Open the input of @a io->ec with FFmpeg and identify its streams.
+ * @a io->ec and @a io->deadline must already be set.
+ *
+ * @param[in,out] io I/O state to complete
+ * @return 0 on success
+ */
+int
+EXTRACTOR_ffmpeg_open_ (struct EXTRACTOR_FFmpegIO *io);
+
+
+/**
+ * Close what EXTRACTOR_ffmpeg_open_() opened.
+ *
+ * @param[in,out] io I/O state to clean up
+ */
+void
+EXTRACTOR_ffmpeg_close_ (struct EXTRACTOR_FFmpegIO *io);
+
+
+/**
+ * Find the best stream of @a type and open a decoder for it.
+ *
+ * @param io open I/O state
+ * @param type media type to look for
+ * @param[out] ctx set to the open decoder
+ * @return index of the stream, or a negative value on error
+ */
+int
+EXTRACTOR_ffmpeg_open_stream_ (struct EXTRACTOR_FFmpegIO *io,
+ enum AVMediaType type,
+ AVCodecContext **ctx);
+
+
+/**
+ * Determine where in the media we should sample, honouring both the
+ * requested offset and the (possibly unknown) duration, and seek there.
+ *
+ * @param io open I/O state
+ * @param stream stream to seek in
+ * @param ctx decoder to flush after seeking
+ * @param offset desired position in seconds
+ * @return the position we actually aim for, in seconds; 0 if we could
+ * not seek and the caller should just decode from the start
+ */
+double
+EXTRACTOR_ffmpeg_seek_ (struct EXTRACTOR_FFmpegIO *io,
+ int stream,
+ AVCodecContext *ctx,
+ unsigned int offset);
+
+
+/**
+ * Suppress FFmpeg's logging.
+ */
+void
+EXTRACTOR_ffmpeg_silence_ (void);
+
+#endif
+
+/* end of mediaffmpeg.h */
diff --git a/src/plugins/mediagst.c b/src/plugins/mediagst.c
@@ -0,0 +1,278 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+ */
+/**
+ * @file plugins/mediagst.c
+ * @brief plumbing between an extraction context and a GStreamer pipeline
+ * @author Christian Grothoff
+ */
+#include "platform.h"
+#include "mediagst.h"
+
+#include <gst/app/gstappsrc.h>
+
+
+/**
+ * Implementation of GstAppSrc's "need-data" callback. Reads data from
+ * the extraction context and passes it to GStreamer.
+ *
+ * @param appsrc the source that ran out of data
+ * @param size number of bytes requested
+ * @param io our execution context
+ */
+static void
+feed_data (GstElement *appsrc,
+ guint size,
+ struct EXTRACTOR_GstIO *io)
+{
+ GstBuffer *buffer;
+ GstMemory *mem;
+ GstMapInfo mi;
+ guint accumulated;
+ ssize_t data_len;
+ uint8_t *le_data;
+
+ if ( (0 != io->length) &&
+ (io->offset >= io->length) )
+ {
+ gst_app_src_end_of_stream (GST_APP_SRC (appsrc));
+ return;
+ }
+ if ( (0 != io->length) &&
+ (io->offset + size > io->length) )
+ size = io->length - io->offset;
+ if (0 == size)
+ {
+ gst_app_src_end_of_stream (GST_APP_SRC (appsrc));
+ return;
+ }
+ mem = gst_allocator_alloc (NULL, size, NULL);
+ if (! gst_memory_map (mem, &mi, GST_MAP_WRITE))
+ {
+ gst_memory_unref (mem);
+ gst_app_src_end_of_stream (GST_APP_SRC (appsrc));
+ return;
+ }
+ accumulated = 0;
+ data_len = 1;
+ while ( (accumulated < size) &&
+ (data_len > 0) )
+ {
+ data_len = io->ec->read (io->ec->cls,
+ (void **) &le_data,
+ size - accumulated);
+ if (data_len > 0)
+ {
+ memcpy (&mi.data[accumulated],
+ le_data,
+ data_len);
+ accumulated += data_len;
+ }
+ }
+ gst_memory_unmap (mem, &mi);
+ if (0 == accumulated)
+ {
+ gst_memory_unref (mem);
+ gst_app_src_end_of_stream (GST_APP_SRC (appsrc));
+ return;
+ }
+ gst_memory_resize (mem, 0, accumulated);
+ buffer = gst_buffer_new ();
+ gst_buffer_append_memory (buffer, mem);
+ /* the offsets matter: they are what makes random access work */
+ GST_BUFFER_OFFSET (buffer) = io->offset;
+ GST_BUFFER_OFFSET_END (buffer) = io->offset + accumulated;
+ io->offset += accumulated;
+ gst_app_src_push_buffer (GST_APP_SRC (appsrc), buffer);
+}
+
+
+/**
+ * Implementation of GstAppSrc's "seek-data" callback.
+ *
+ * @param appsrc the source that wants to seek
+ * @param position new desired absolute position in the file
+ * @param io our execution context
+ * @return TRUE if seeking succeeded
+ */
+static gboolean
+seek_data (GstElement *appsrc,
+ guint64 position,
+ struct EXTRACTOR_GstIO *io)
+{
+ int64_t ret;
+
+ (void) appsrc;
+ ret = io->ec->seek (io->ec->cls,
+ position,
+ SEEK_SET);
+ if (0 > ret)
+ return FALSE;
+ io->offset = (uint64_t) ret;
+ return io->offset == position;
+}
+
+
+/**
+ * Implementation of playbin's "source-setup" signal: configure the
+ * appsrc it created for our "appsrc://" URI.
+ *
+ * @param playbin the pipeline
+ * @param source the appsrc to configure
+ * @param io our execution context
+ */
+static void
+source_setup (GstElement *playbin,
+ GstElement *source,
+ struct EXTRACTOR_GstIO *io)
+{
+ (void) playbin;
+ if (0 != io->length)
+ g_object_set (source,
+ "size", (gint64) io->length,
+ "stream-type", GST_APP_STREAM_TYPE_RANDOM_ACCESS,
+ NULL);
+ else
+ g_object_set (source,
+ "size", (gint64) - 1,
+ "stream-type", GST_APP_STREAM_TYPE_STREAM,
+ NULL);
+ g_object_set (source,
+ "format", GST_FORMAT_BYTES,
+ NULL);
+ g_signal_connect (source,
+ "need-data",
+ G_CALLBACK (&feed_data),
+ io);
+ g_signal_connect (source,
+ "seek-data",
+ G_CALLBACK (&seek_data),
+ io);
+}
+
+
+GstElement *
+EXTRACTOR_gst_playbin_ (struct EXTRACTOR_GstIO *io,
+ struct EXTRACTOR_ExtractContext *ec,
+ unsigned int flags,
+ GstElement *video_sink,
+ GstElement *audio_sink)
+{
+ GstElement *pipeline;
+ uint64_t size;
+
+ if (! gst_is_initialized ())
+ gst_init (NULL, NULL);
+ io->ec = ec;
+ io->offset = 0;
+ size = ec->get_size (ec->cls);
+ io->length = (UINT64_MAX == size) ? 0 : size;
+ pipeline = gst_element_factory_make ("playbin", NULL);
+ if ( (NULL == pipeline) ||
+ (NULL == video_sink) ||
+ (NULL == audio_sink) )
+ {
+ /* gst-plugins-base is not installed, or not completely */
+ if (NULL != video_sink)
+ gst_object_unref (video_sink);
+ if (NULL != audio_sink)
+ gst_object_unref (audio_sink);
+ if (NULL != pipeline)
+ gst_object_unref (pipeline);
+ return NULL;
+ }
+ g_object_set (pipeline,
+ "uri", "appsrc://",
+ "video-sink", video_sink,
+ "audio-sink", audio_sink,
+ "flags", flags,
+ NULL);
+ g_signal_connect (pipeline,
+ "source-setup",
+ G_CALLBACK (&source_setup),
+ io);
+ return pipeline;
+}
+
+
+int
+EXTRACTOR_gst_wait_async_ (GstElement *pipeline,
+ GstClockTime timeout)
+{
+ GstBus *bus;
+ GstMessage *msg;
+ int ret = 0;
+
+ if (0 == timeout)
+ return 0;
+ if (NULL == (bus = gst_element_get_bus (pipeline)))
+ return 0;
+ while (1)
+ {
+ msg = gst_bus_timed_pop_filtered (bus,
+ timeout,
+ GST_MESSAGE_ASYNC_DONE
+ | GST_MESSAGE_ERROR
+ | GST_MESSAGE_EOS);
+ if (NULL == msg)
+ break; /* timed out */
+ if (GST_MESSAGE_ASYNC_DONE != GST_MESSAGE_TYPE (msg))
+ {
+ /* an error, or the file ended before we ever saw a frame */
+ gst_message_unref (msg);
+ break;
+ }
+ if (GST_OBJECT (pipeline) == GST_MESSAGE_SRC (msg))
+ {
+ gst_message_unref (msg);
+ ret = 1;
+ break;
+ }
+ /* ASYNC_DONE from some element inside the pipeline; keep waiting */
+ gst_message_unref (msg);
+ }
+ gst_object_unref (bus);
+ return ret;
+}
+
+
+gint64
+EXTRACTOR_gst_sample_position_ (GstElement *pipeline,
+ unsigned int offset,
+ gint64 *duration)
+{
+ gint64 target;
+ gint64 dur = 0;
+
+ target = ((gint64) offset) * GST_SECOND;
+ if (! gst_element_query_duration (pipeline,
+ GST_FORMAT_TIME,
+ &dur))
+ dur = 0;
+ if (dur < 0)
+ dur = 0;
+ if ( (0 != dur) &&
+ (target > dur / 3) )
+ target = dur / 3;
+ *duration = dur;
+ return target;
+}
+
+
+/* end of mediagst.c */
diff --git a/src/plugins/mediagst.h b/src/plugins/mediagst.h
@@ -0,0 +1,120 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+ */
+/**
+ * @file plugins/mediagst.h
+ * @brief plumbing between an extraction context and a GStreamer pipeline
+ * @author Christian Grothoff
+ *
+ * Shared by thumbnailgst and previewgst. Both drive a "playbin" reading
+ * from an "appsrc://" URI that we feed from the extraction context;
+ * playbin is used rather than a hand-built decodebin pipeline because it
+ * already knows how to pick a stream and how not to stall on the streams
+ * we are not interested in.
+ */
+#ifndef MEDIAGST_H
+#define MEDIAGST_H
+
+#include "extractor.h"
+#include "mediautil.h"
+
+#include <gst/gst.h>
+
+/**
+ * Values of playbin's "flags" property. The enum is not part of any
+ * installed header, so we spell out the bits we use.
+ */
+#define EXTRACTOR_GST_PLAY_FLAG_VIDEO 0x00000001
+
+/**
+ * Play the audio stream.
+ */
+#define EXTRACTOR_GST_PLAY_FLAG_AUDIO 0x00000002
+
+
+/**
+ * State of a GStreamer pipeline reading from an extraction context.
+ */
+struct EXTRACTOR_GstIO
+{
+ /**
+ * Extraction context we are reading from.
+ */
+ struct EXTRACTOR_ExtractContext *ec;
+
+ /**
+ * Current read position in the file.
+ */
+ uint64_t offset;
+
+ /**
+ * Size of the file, 0 if unknown.
+ */
+ uint64_t length;
+};
+
+
+/**
+ * Create a playbin that reads from @a ec, with the given sinks. The
+ * pipeline is returned in state NULL; the caller sets it to PAUSED.
+ *
+ * @param[out] io I/O state to initialize
+ * @param ec extraction context to read from
+ * @param flags value for playbin's "flags" property
+ * @param video_sink sink to use for video, consumed
+ * @param audio_sink sink to use for audio, consumed
+ * @return the pipeline, or NULL if GStreamer could not provide one
+ */
+GstElement *
+EXTRACTOR_gst_playbin_ (struct EXTRACTOR_GstIO *io,
+ struct EXTRACTOR_ExtractContext *ec,
+ unsigned int flags,
+ GstElement *video_sink,
+ GstElement *audio_sink);
+
+
+/**
+ * Wait for @a pipeline to finish its current asynchronous state change.
+ *
+ * @param pipeline pipeline to watch
+ * @param timeout how long to wait, in nanoseconds
+ * @return 1 if the pipeline is ready, 0 on error, EOS or timeout
+ */
+int
+EXTRACTOR_gst_wait_async_ (GstElement *pipeline,
+ GstClockTime timeout);
+
+
+/**
+ * Determine where in the media we should start sampling: @a offset
+ * seconds in, but never past a third of the duration.
+ *
+ * @param pipeline prerolled pipeline
+ * @param offset desired position in seconds
+ * @param[out] duration set to the duration in nanoseconds, 0 if unknown
+ * @return position to sample at, in nanoseconds
+ */
+gint64
+EXTRACTOR_gst_sample_position_ (GstElement *pipeline,
+ unsigned int offset,
+ gint64 *duration);
+
+#endif
+
+/* end of mediagst.h */
diff --git a/src/plugins/mediautil.c b/src/plugins/mediautil.c
@@ -0,0 +1,284 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+ */
+/**
+ * @file plugins/mediautil.c
+ * @brief helpers shared by the video thumbnail and audio preview plugins
+ * @author Christian Grothoff
+ */
+#include "platform.h"
+#include "mediautil.h"
+#include <magic.h>
+
+/**
+ * How many bytes we give libmagic to make up its mind.
+ */
+#define SNIFF_SIZE (16 * 1024)
+
+
+void
+EXTRACTOR_media_options_parse_ (const char *config,
+ struct EXTRACTOR_MediaOptions *opt)
+{
+ const char *pos;
+
+ opt->size = 128;
+ opt->png = 0;
+ opt->offset = 30;
+ opt->length = 15;
+ opt->bitrate = 24000;
+ opt->deadline = 10000;
+ if (NULL == config)
+ return;
+ pos = config;
+ while ('\0' != *pos)
+ {
+ unsigned long long v;
+ char *endp;
+
+ if (0 == strncmp (pos,
+ "format=",
+ strlen ("format=")))
+ {
+ pos += strlen ("format=");
+ opt->png = (0 == strncmp (pos,
+ "png",
+ strlen ("png")));
+ }
+ else if (0 == strncmp (pos,
+ "size=",
+ strlen ("size=")))
+ {
+ pos += strlen ("size=");
+ v = strtoull (pos, &endp, 10);
+ if ( (endp != pos) &&
+ (v >= 8) &&
+ (v <= 1024) )
+ opt->size = (unsigned int) v;
+ }
+ else if (0 == strncmp (pos,
+ "offset=",
+ strlen ("offset=")))
+ {
+ pos += strlen ("offset=");
+ v = strtoull (pos, &endp, 10);
+ if ( (endp != pos) &&
+ (v <= 24 * 60 * 60) )
+ opt->offset = (unsigned int) v;
+ }
+ else if (0 == strncmp (pos,
+ "length=",
+ strlen ("length=")))
+ {
+ pos += strlen ("length=");
+ v = strtoull (pos, &endp, 10);
+ if ( (endp != pos) &&
+ (v >= 1) &&
+ (v <= 60) )
+ opt->length = (unsigned int) v;
+ }
+ else if (0 == strncmp (pos,
+ "bitrate=",
+ strlen ("bitrate=")))
+ {
+ pos += strlen ("bitrate=");
+ v = strtoull (pos, &endp, 10);
+ if ( (endp != pos) &&
+ (v >= 6000) &&
+ (v <= 128000) )
+ opt->bitrate = (unsigned int) v;
+ }
+ else if (0 == strncmp (pos,
+ "deadline=",
+ strlen ("deadline=")))
+ {
+ pos += strlen ("deadline=");
+ v = strtoull (pos, &endp, 10);
+ if ( (endp != pos) &&
+ (v >= 100) &&
+ (v <= 120000) )
+ opt->deadline = (unsigned int) v;
+ }
+ /* advance to the next assignment */
+ pos = strchr (pos, ',');
+ if (NULL == pos)
+ break;
+ pos++;
+ }
+}
+
+
+int
+EXTRACTOR_media_is_media_ (struct EXTRACTOR_ExtractContext *ec,
+ int want_audio)
+{
+ magic_t magic;
+ void *data;
+ ssize_t iret;
+ const char *mime;
+ int ret;
+
+ if (-1 == (iret = ec->read (ec->cls,
+ &data,
+ SNIFF_SIZE)))
+ return 0;
+ ret = 0;
+ if (NULL == (magic = magic_open (MAGIC_MIME_TYPE)))
+ return 0;
+ if (0 == magic_load (magic, NULL))
+ {
+ mime = magic_buffer (magic,
+ data,
+ iret);
+ if (NULL != mime)
+ {
+ if (0 == strncmp (mime,
+ "video/",
+ strlen ("video/")))
+ ret = 1;
+ if ( (0 != want_audio) &&
+ (0 == strncmp (mime,
+ "audio/",
+ strlen ("audio/"))) )
+ ret = 1;
+ /* Ogg and Matroska files carrying video are frequently reported as
+ "application/ogg" or "video/x-matroska"; the former would be lost
+ here, so let it through and let the demuxer decide. */
+ if (0 == strcmp (mime,
+ "application/ogg"))
+ ret = 1;
+ }
+ }
+ magic_close (magic);
+ if (0 != ec->seek (ec->cls,
+ 0,
+ SEEK_SET))
+ return 0;
+ return ret;
+}
+
+
+/**
+ * Current time in milliseconds since an arbitrary epoch.
+ *
+ * @return the time
+ */
+static uint64_t
+now_ms (void)
+{
+ struct timespec ts;
+
+ if (0 != clock_gettime (CLOCK_MONOTONIC,
+ &ts))
+ return 0;
+ return ((uint64_t) ts.tv_sec) * 1000ULL
+ + ((uint64_t) ts.tv_nsec) / 1000000ULL;
+}
+
+
+void
+EXTRACTOR_media_deadline_start_ (struct EXTRACTOR_MediaDeadline *dl,
+ unsigned int ms)
+{
+ dl->expiration = now_ms () + ms;
+}
+
+
+int
+EXTRACTOR_media_deadline_expired_ (const struct EXTRACTOR_MediaDeadline *dl)
+{
+ return now_ms () >= dl->expiration;
+}
+
+
+uint64_t
+EXTRACTOR_media_deadline_remaining_ (const struct EXTRACTOR_MediaDeadline *dl)
+{
+ uint64_t now = now_ms ();
+
+ if (now >= dl->expiration)
+ return 0;
+ return dl->expiration - now;
+}
+
+
+int
+EXTRACTOR_media_thumbnail_size_ (unsigned int width,
+ unsigned int height,
+ unsigned int box,
+ unsigned int *rwidth,
+ unsigned int *rheight)
+{
+ uint64_t w = width;
+ uint64_t h = height;
+
+ if ( (0 == width) ||
+ (0 == height) ||
+ (width > EXTRACTOR_MEDIA_MAX_DIMENSION) ||
+ (height > EXTRACTOR_MEDIA_MAX_DIMENSION) ||
+ (((uint64_t) width) * height > EXTRACTOR_MEDIA_MAX_PIXELS) )
+ return 0;
+ if (h > box)
+ {
+ w = w * box / h;
+ h = box;
+ }
+ if (w > box)
+ {
+ h = h * box / w;
+ w = box;
+ }
+ if ( (0 == w) ||
+ (0 == h) )
+ return 0;
+ /* many encoders dislike odd dimensions, and we lose nothing by rounding */
+ w &= ~1ULL;
+ h &= ~1ULL;
+ if ( (0 == w) ||
+ (0 == h) )
+ return 0;
+ *rwidth = (unsigned int) w;
+ *rheight = (unsigned int) h;
+ return 1;
+}
+
+
+void
+EXTRACTOR_media_fade_s16_ (int16_t *samples,
+ size_t num_samples,
+ size_t fade_samples)
+{
+ size_t i;
+
+ if (0 == fade_samples)
+ return;
+ if (fade_samples * 2 > num_samples)
+ fade_samples = num_samples / 2;
+ for (i = 0; i < fade_samples; i++)
+ {
+ samples[i] = (int16_t) (((int32_t) samples[i]) * (int32_t) i
+ / (int32_t) fade_samples);
+ samples[num_samples - 1 - i] =
+ (int16_t) (((int32_t) samples[num_samples - 1 - i]) * (int32_t) i
+ / (int32_t) fade_samples);
+ }
+}
+
+
+/* end of mediautil.c */
diff --git a/src/plugins/mediautil.h b/src/plugins/mediautil.h
@@ -0,0 +1,207 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+ */
+/**
+ * @file plugins/mediautil.h
+ * @brief helpers shared by the video thumbnail and audio preview plugins
+ * @author Christian Grothoff
+ *
+ * The four media plugins (thumbnailffmpeg, thumbnailgst, previewopus and
+ * previewgst) all have to do the same three boring things before they can
+ * get to work: parse the plugin option string, make sure the input even
+ * looks like media, and keep an eye on the clock. This file is compiled
+ * into each of them (plugins are modules and must not depend on each
+ * other, so this is deliberately a source-level, not a library-level,
+ * dependency).
+ */
+#ifndef MEDIAUTIL_H
+#define MEDIAUTIL_H
+
+#include "extractor.h"
+
+/**
+ * Maximum number of bytes we are willing to emit for a thumbnail.
+ */
+#define EXTRACTOR_MEDIA_MAX_THUMBNAIL (100 * 1024)
+
+/**
+ * Maximum number of bytes we are willing to emit for an audio preview.
+ */
+#define EXTRACTOR_MEDIA_MAX_PREVIEW (64 * 1024)
+
+/**
+ * Largest image we are willing to scale down, in pixels. Guards against
+ * absurd dimensions in a hostile file header.
+ */
+#define EXTRACTOR_MEDIA_MAX_PIXELS (64 * 1024 * 1024)
+
+/**
+ * Largest width or height we accept for the source video.
+ */
+#define EXTRACTOR_MEDIA_MAX_DIMENSION 16384
+
+
+/**
+ * Options a media plugin understands, as given in the plugin
+ * configuration string, i.e. "thumbnailffmpeg(size=256,format=png)".
+ */
+struct EXTRACTOR_MediaOptions
+{
+ /**
+ * Edge length of the box the thumbnail must fit into, in pixels.
+ */
+ unsigned int size;
+
+ /**
+ * Emit PNG instead of JPEG (option "format=png").
+ */
+ int png;
+
+ /**
+ * How many seconds into the media we would like to sample.
+ */
+ unsigned int offset;
+
+ /**
+ * Length of the audio preview in seconds.
+ */
+ unsigned int length;
+
+ /**
+ * Target bitrate of the Opus encoder, in bits per second.
+ */
+ unsigned int bitrate;
+
+ /**
+ * Wall clock budget for the entire extraction, in milliseconds.
+ */
+ unsigned int deadline;
+};
+
+
+/**
+ * A wall clock deadline.
+ */
+struct EXTRACTOR_MediaDeadline
+{
+ /**
+ * When we must be done, in milliseconds since some arbitrary epoch.
+ */
+ uint64_t expiration;
+};
+
+
+/**
+ * Initialize @a opt with the defaults, then apply the comma-separated
+ * "key=value" assignments from @a config. Unknown keys and malformed
+ * values are ignored; the resulting values are always in range.
+ *
+ * @param config configuration string of the plugin, may be NULL
+ * @param[out] opt set to the resulting options
+ */
+void
+EXTRACTOR_media_options_parse_ (const char *config,
+ struct EXTRACTOR_MediaOptions *opt);
+
+
+/**
+ * Check whether the input of @a ec looks like media we should touch,
+ * using libmagic on the first few kilobytes. Always leaves the
+ * extraction context rewound to the beginning of the file.
+ *
+ * Handing arbitrary bytes to a media framework is expensive and grows
+ * the attack surface for no gain, so both plugin families gate on this.
+ *
+ * @param ec extraction context to inspect
+ * @param want_audio also accept "audio/*" (in addition to "video/*")
+ * @return 1 if the input should be processed, 0 if not
+ */
+int
+EXTRACTOR_media_is_media_ (struct EXTRACTOR_ExtractContext *ec,
+ int want_audio);
+
+
+/**
+ * Start the clock.
+ *
+ * @param[out] dl deadline to initialize
+ * @param ms budget in milliseconds from now
+ */
+void
+EXTRACTOR_media_deadline_start_ (struct EXTRACTOR_MediaDeadline *dl,
+ unsigned int ms);
+
+
+/**
+ * Check whether the budget is used up.
+ *
+ * @param dl deadline to check
+ * @return 1 if we are out of time
+ */
+int
+EXTRACTOR_media_deadline_expired_ (const struct EXTRACTOR_MediaDeadline *dl);
+
+
+/**
+ * Time left before @a dl expires.
+ *
+ * @param dl deadline to check
+ * @return remaining milliseconds, 0 if expired
+ */
+uint64_t
+EXTRACTOR_media_deadline_remaining_ (const struct EXTRACTOR_MediaDeadline *dl);
+
+
+/**
+ * Compute the size of a thumbnail with the aspect ratio of a
+ * @a width x @a height image that fits into a @a box x @a box square.
+ * Never scales up.
+ *
+ * @param width width of the source image
+ * @param height height of the source image
+ * @param box edge length of the target square
+ * @param[out] rwidth width of the thumbnail
+ * @param[out] rheight height of the thumbnail
+ * @return 1 on success, 0 if the source dimensions are unusable
+ */
+int
+EXTRACTOR_media_thumbnail_size_ (unsigned int width,
+ unsigned int height,
+ unsigned int box,
+ unsigned int *rwidth,
+ unsigned int *rheight);
+
+
+/**
+ * Apply a linear fade-in and fade-out to 16-bit signed mono PCM.
+ * Without this, a preview cut out of the middle of a track starts and
+ * ends with an audible click.
+ *
+ * @param[in,out] samples PCM data to modify
+ * @param num_samples number of samples in @a samples
+ * @param fade_samples length of each ramp in samples
+ */
+void
+EXTRACTOR_media_fade_s16_ (int16_t *samples,
+ size_t num_samples,
+ size_t fade_samples);
+
+#endif
+
+/* end of mediautil.h */
diff --git a/src/plugins/previewgst_extractor.c b/src/plugins/previewgst_extractor.c
@@ -0,0 +1,408 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+ */
+/**
+ * @file plugins/previewgst_extractor.c
+ * @author Christian Grothoff
+ * @brief this extractor produces a short audio preview: about 15 seconds
+ * taken from the middle of the track, re-encoded as Opus in an Ogg
+ * container, which is the audio equivalent of a thumbnail
+ *
+ * This is the GStreamer counterpart of previewopus. The entire job is
+ * done by the pipeline
+ *
+ * playbin(appsrc://) ! audioconvert ! volume ! audioresample
+ * ! opusenc ! oggmux ! appsink
+ *
+ * where the segment boundaries come from a seek with an explicit stop
+ * position, so that the pipeline reaches EOS -- and thus finishes the Ogg
+ * stream -- exactly where we want the preview to end. The volume element
+ * carries a control source that fades the preview in and out; without it
+ * a segment cut from the middle of a track begins with a click.
+ */
+#include "platform.h"
+#include "extractor.h"
+#include "mediautil.h"
+#include "mediagst.h"
+
+#include <gst/app/gstappsink.h>
+#include <gst/controller/gstinterpolationcontrolsource.h>
+#include <gst/controller/gstdirectcontrolbinding.h>
+
+/**
+ * Sample rate we encode at. Opus works internally at 48kHz, so anything
+ * else would just mean resampling twice.
+ */
+#define OUTPUT_SAMPLE_RATE 48000
+
+/**
+ * Length of the fade-in and fade-out, in nanoseconds.
+ */
+#define FADE_NS (250 * GST_MSECOND)
+
+/**
+ * Refuse to emit a preview shorter than this.
+ */
+#define MIN_PREVIEW_NS (500 * GST_MSECOND)
+
+
+/**
+ * Build the audio sink bin that encodes what playbin decodes.
+ *
+ * @param opt plugin options (bitrate)
+ * @param[out] appsink set to the sink at the end of the bin
+ * @param[out] volume set to the element carrying the fade
+ * @return the bin, or NULL if an element is missing
+ */
+static GstElement *
+build_encoder_bin (const struct EXTRACTOR_MediaOptions *opt,
+ GstElement **appsink,
+ GstElement **volume)
+{
+ GstElement *bin;
+ GstElement *convert;
+ GstElement *vol;
+ GstElement *resample;
+ GstElement *filter;
+ GstElement *encoder;
+ GstElement *muxer;
+ GstElement *sink;
+ GstCaps *caps;
+ GstPad *pad;
+
+ convert = gst_element_factory_make ("audioconvert", NULL);
+ vol = gst_element_factory_make ("volume", NULL);
+ resample = gst_element_factory_make ("audioresample", NULL);
+ filter = gst_element_factory_make ("capsfilter", NULL);
+ encoder = gst_element_factory_make ("opusenc", NULL);
+ muxer = gst_element_factory_make ("oggmux", NULL);
+ sink = gst_element_factory_make ("appsink", NULL);
+ if ( (NULL == convert) ||
+ (NULL == vol) ||
+ (NULL == resample) ||
+ (NULL == filter) ||
+ (NULL == encoder) ||
+ (NULL == muxer) ||
+ (NULL == sink) )
+ {
+ /* gst-plugins-base is not installed, or not completely */
+ if (NULL != convert)
+ gst_object_unref (convert);
+ if (NULL != vol)
+ gst_object_unref (vol);
+ if (NULL != resample)
+ gst_object_unref (resample);
+ if (NULL != filter)
+ gst_object_unref (filter);
+ if (NULL != encoder)
+ gst_object_unref (encoder);
+ if (NULL != muxer)
+ gst_object_unref (muxer);
+ if (NULL != sink)
+ gst_object_unref (sink);
+ return NULL;
+ }
+ caps = gst_caps_new_simple ("audio/x-raw",
+ "rate", G_TYPE_INT, OUTPUT_SAMPLE_RATE,
+ "channels", G_TYPE_INT, 1,
+ NULL);
+ g_object_set (filter,
+ "caps", caps,
+ NULL);
+ gst_caps_unref (caps);
+ g_object_set (encoder,
+ "bitrate", (gint) opt->bitrate,
+ NULL);
+ g_object_set (sink,
+ "sync", FALSE,
+ "max-buffers", 0,
+ NULL);
+ bin = gst_bin_new (NULL);
+ gst_bin_add_many (GST_BIN (bin),
+ convert,
+ vol,
+ resample,
+ filter,
+ encoder,
+ muxer,
+ sink,
+ NULL);
+ if (! gst_element_link_many (convert,
+ vol,
+ resample,
+ filter,
+ encoder,
+ muxer,
+ sink,
+ NULL))
+ {
+ gst_object_unref (bin);
+ return NULL;
+ }
+ if (NULL == (pad = gst_element_get_static_pad (convert, "sink")))
+ {
+ gst_object_unref (bin);
+ return NULL;
+ }
+ if (! gst_element_add_pad (bin,
+ gst_ghost_pad_new ("sink", pad)))
+ {
+ gst_object_unref (pad);
+ gst_object_unref (bin);
+ return NULL;
+ }
+ gst_object_unref (pad);
+ *appsink = sink;
+ *volume = vol;
+ return bin;
+}
+
+
+/**
+ * Make @a volume ramp up at the start of the segment and down at its end.
+ *
+ * @param volume the volume element
+ * @param length length of the segment, in nanoseconds
+ */
+static void
+setup_fade (GstElement *volume,
+ gint64 length)
+{
+ GstControlSource *cs;
+ GstTimedValueControlSource *tv;
+ gint64 fade = FADE_NS;
+
+ if (length <= 0)
+ return;
+ if (fade * 2 > length)
+ fade = length / 2;
+ if (0 == fade)
+ return;
+ if (NULL == (cs = gst_interpolation_control_source_new ()))
+ return;
+ g_object_set (cs,
+ "mode", GST_INTERPOLATION_MODE_LINEAR,
+ NULL);
+ if (! gst_object_add_control_binding (
+ GST_OBJECT (volume),
+ gst_direct_control_binding_new_absolute (GST_OBJECT (volume),
+ "volume",
+ cs)))
+ {
+ /* not fatal: the preview is merely less pleasant to listen to */
+ gst_object_unref (cs);
+ return;
+ }
+ tv = GST_TIMED_VALUE_CONTROL_SOURCE (cs);
+ gst_timed_value_control_source_set (tv, 0, 0.0);
+ gst_timed_value_control_source_set (tv, fade, 1.0);
+ gst_timed_value_control_source_set (tv, length - fade, 1.0);
+ gst_timed_value_control_source_set (tv, length, 0.0);
+ gst_object_unref (cs);
+}
+
+
+/**
+ * Collect everything the appsink hands us until it reaches EOS.
+ *
+ * @param sink the appsink at the end of our pipeline
+ * @param deadline when to give up
+ * @param limit maximum number of bytes to collect
+ * @return the Ogg stream, or NULL; free with g_byte_array_unref()
+ */
+static GByteArray *
+collect_output (GstElement *sink,
+ const struct EXTRACTOR_MediaDeadline *deadline,
+ size_t limit)
+{
+ GByteArray *out;
+
+ out = g_byte_array_new ();
+ while (1)
+ {
+ GstSample *sample;
+ GstBuffer *buffer;
+ GstMapInfo mi;
+ uint64_t remaining;
+
+ remaining = EXTRACTOR_media_deadline_remaining_ (deadline);
+ if (0 == remaining)
+ break;
+ sample = gst_app_sink_try_pull_sample (GST_APP_SINK (sink),
+ remaining * GST_MSECOND);
+ if (NULL == sample)
+ break; /* end of stream, or we ran out of time */
+ buffer = gst_sample_get_buffer (sample);
+ if ( (NULL != buffer) &&
+ (gst_buffer_map (buffer, &mi, GST_MAP_READ)) )
+ {
+ if (out->len + mi.size <= limit)
+ g_byte_array_append (out,
+ mi.data,
+ mi.size);
+ else
+ {
+ /* the stream would be truncated, and a truncated Ogg file is
+ worse than none at all */
+ gst_buffer_unmap (buffer, &mi);
+ gst_sample_unref (sample);
+ g_byte_array_unref (out);
+ return NULL;
+ }
+ gst_buffer_unmap (buffer, &mi);
+ }
+ gst_sample_unref (sample);
+ }
+ return out;
+}
+
+
+/**
+ * Main method for the gstreamer audio preview plugin.
+ *
+ * @param ec extraction context
+ */
+void
+EXTRACTOR_previewgst_extract_method (struct EXTRACTOR_ExtractContext *ec);
+
+void
+EXTRACTOR_previewgst_extract_method (struct EXTRACTOR_ExtractContext *ec)
+{
+ struct EXTRACTOR_MediaOptions opt;
+ struct EXTRACTOR_MediaDeadline deadline;
+ struct EXTRACTOR_GstIO io;
+ GstElement *pipeline;
+ GstElement *bin;
+ GstElement *sink;
+ GstElement *volume;
+ GByteArray *output = NULL;
+ gint64 duration;
+ gint64 start;
+ gint64 stop;
+ size_t limit;
+
+ EXTRACTOR_media_options_parse_ (ec->config,
+ &opt);
+ EXTRACTOR_media_deadline_start_ (&deadline,
+ opt.deadline);
+ if (! EXTRACTOR_media_is_media_ (ec, 1))
+ return;
+ if (! gst_is_initialized ())
+ gst_init (NULL, NULL);
+ if (NULL == (bin = build_encoder_bin (&opt,
+ &sink,
+ &volume)))
+ return;
+ if (NULL == (pipeline =
+ EXTRACTOR_gst_playbin_ (&io,
+ ec,
+ EXTRACTOR_GST_PLAY_FLAG_AUDIO,
+ gst_element_factory_make ("fakesink",
+ NULL),
+ bin)))
+ return;
+ if (GST_STATE_CHANGE_FAILURE ==
+ gst_element_set_state (pipeline,
+ GST_STATE_PAUSED))
+ goto cleanup;
+ if (! EXTRACTOR_gst_wait_async_ (pipeline,
+ EXTRACTOR_media_deadline_remaining_ (
+ &deadline) * GST_MSECOND))
+ goto cleanup;
+ start = EXTRACTOR_gst_sample_position_ (pipeline,
+ opt.offset,
+ &duration);
+ stop = start + ((gint64) opt.length) * GST_SECOND;
+ if ( (0 != duration) &&
+ (stop > duration) )
+ stop = duration;
+ if ( (0 != duration) &&
+ (stop - start < MIN_PREVIEW_NS) )
+ goto cleanup; /* too short to be worth anything */
+ setup_fade (volume,
+ stop - start);
+ if (! gst_element_seek (pipeline,
+ 1.0,
+ GST_FORMAT_TIME,
+ GST_SEEK_FLAG_FLUSH
+ | GST_SEEK_FLAG_ACCURATE,
+ GST_SEEK_TYPE_SET,
+ start,
+ GST_SEEK_TYPE_SET,
+ stop))
+ goto cleanup;
+ if (! EXTRACTOR_gst_wait_async_ (pipeline,
+ EXTRACTOR_media_deadline_remaining_ (
+ &deadline) * GST_MSECOND))
+ goto cleanup;
+ if (GST_STATE_CHANGE_FAILURE ==
+ gst_element_set_state (pipeline,
+ GST_STATE_PLAYING))
+ goto cleanup;
+ /* the caller asked for a preview of a given length and bitrate, so let
+ that decide the limit, but never emit something absurd */
+ limit = (size_t) opt.length * opt.bitrate / 8 + 8192;
+ if (limit < EXTRACTOR_MEDIA_MAX_PREVIEW)
+ limit = EXTRACTOR_MEDIA_MAX_PREVIEW;
+ output = collect_output (sink,
+ &deadline,
+ limit);
+ if (NULL == output)
+ goto cleanup;
+ if (0 == output->len)
+ goto cleanup;
+ ec->proc (ec->cls,
+ "previewgst",
+ EXTRACTOR_METATYPE_AUDIO_PREVIEW,
+ EXTRACTOR_METAFORMAT_BINARY,
+ "audio/ogg",
+ (const char *) output->data,
+ output->len);
+cleanup:
+ if (NULL != output)
+ g_byte_array_unref (output);
+ gst_element_set_state (pipeline,
+ GST_STATE_NULL);
+ gst_object_unref (pipeline);
+}
+
+
+/**
+ * Keep the diagnostics of the media framework out of the output of the
+ * calling application; a decoder complaining about a broken file is not
+ * something the application asked to see.
+ *
+ * ("force-kill" would be tempting here, to get a fresh process for every
+ * file, but the host cannot know about that special without loading the
+ * plugin in-process: it only finds out that the child is gone once it has
+ * already handed it the next file, which is then silently lost.)
+ *
+ * @return special options for this plugin
+ */
+const char *
+EXTRACTOR_previewgst_options (void);
+
+const char *
+EXTRACTOR_previewgst_options ()
+{
+ return "close-stderr";
+}
+
+
+/* end of previewgst_extractor.c */
diff --git a/src/plugins/previewopus_extractor.c b/src/plugins/previewopus_extractor.c
@@ -0,0 +1,570 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2013, 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+ */
+/**
+ * @file plugins/previewopus_extractor.c
+ * @author Bruno Cabral
+ * @author Christian Grothoff
+ * @brief this extractor produces a short audio preview: about 15 seconds
+ * taken from the middle of the track, re-encoded as Opus in an Ogg
+ * container, which is the audio equivalent of a thumbnail
+ *
+ * Uses FFmpeg for demuxing, decoding and resampling and libopus (through
+ * FFmpeg) for encoding. Requires FFmpeg 5.1 or newer; there is
+ * deliberately no compatibility code for older versions, since that is
+ * what got the previous incarnation of this plugin deleted in 2021.
+ */
+#include "platform.h"
+#include "extractor.h"
+#include "mediautil.h"
+#include "mediaffmpeg.h"
+
+#include <libavutil/opt.h>
+#include <libswresample/swresample.h>
+
+/**
+ * Sample rate we encode at. Opus works internally at 48kHz, so anything
+ * else would just mean resampling twice.
+ */
+#define OUTPUT_SAMPLE_RATE 48000
+
+/**
+ * Length of the fade-in and fade-out, in milliseconds. Without this a
+ * preview cut from the middle of a track starts with a click.
+ */
+#define FADE_MS 250
+
+/**
+ * Refuse to emit a preview shorter than this many milliseconds.
+ */
+#define MIN_PREVIEW_MS 500
+
+/**
+ * Give up after this many decoded packets.
+ */
+#define MAX_PACKETS 100000
+
+
+/**
+ * State of the Opus encoder and its Ogg muxer.
+ */
+struct Encoder
+{
+ /**
+ * Muxer producing the Ogg stream.
+ */
+ AVFormatContext *oc;
+
+ /**
+ * The Opus encoder.
+ */
+ AVCodecContext *ctx;
+
+ /**
+ * Stream in @e oc.
+ */
+ AVStream *st;
+};
+
+
+/**
+ * Decode the audio stream into 48kHz mono 16 bit PCM, starting at
+ * @a target seconds and stopping after @a max_samples samples.
+ *
+ * @param io open I/O state
+ * @param ctx open decoder for @a stream
+ * @param stream index of the audio stream
+ * @param target position we are aiming for, in seconds
+ * @param max_samples size of @a pcm in samples
+ * @param[out] pcm where to write the samples
+ * @return number of samples written
+ */
+static size_t
+decode_pcm (struct EXTRACTOR_FFmpegIO *io,
+ AVCodecContext *ctx,
+ int stream,
+ double target,
+ size_t max_samples,
+ int16_t *pcm)
+{
+ struct SwrContext *swr = NULL;
+ AVChannelLayout mono = AV_CHANNEL_LAYOUT_MONO;
+ AVPacket *pkt;
+ AVFrame *frame;
+ AVRational tb = io->fmt->streams[stream]->time_base;
+ size_t have = 0;
+ unsigned int packets = 0;
+
+ if (NULL == (pkt = av_packet_alloc ()))
+ return 0;
+ if (NULL == (frame = av_frame_alloc ()))
+ {
+ av_packet_free (&pkt);
+ return 0;
+ }
+ while ( (have < max_samples) &&
+ (packets < MAX_PACKETS) )
+ {
+ int r;
+
+ if (EXTRACTOR_media_deadline_expired_ (&io->deadline))
+ break;
+ packets++;
+ r = av_read_frame (io->fmt, pkt);
+ if (0 != r)
+ {
+ avcodec_send_packet (ctx, NULL);
+ }
+ else if (pkt->stream_index != stream)
+ {
+ av_packet_unref (pkt);
+ continue;
+ }
+ else if (0 != avcodec_send_packet (ctx, pkt))
+ {
+ av_packet_unref (pkt);
+ continue;
+ }
+ av_packet_unref (pkt);
+ while (0 == avcodec_receive_frame (ctx, frame))
+ {
+ uint8_t *out[1];
+ int room;
+ int got;
+ double ts;
+
+ ts = (AV_NOPTS_VALUE == frame->best_effort_timestamp)
+ ? -1.0
+ : frame->best_effort_timestamp * av_q2d (tb);
+ if ( (ts >= 0.0) &&
+ (ts + frame->nb_samples / (double) ((frame->sample_rate > 0)
+ ? frame->sample_rate
+ : OUTPUT_SAMPLE_RATE) < target) )
+ {
+ /* still before the segment we want */
+ av_frame_unref (frame);
+ continue;
+ }
+ if (NULL == swr)
+ {
+ if (0 != swr_alloc_set_opts2 (&swr,
+ &mono,
+ AV_SAMPLE_FMT_S16,
+ OUTPUT_SAMPLE_RATE,
+ &frame->ch_layout,
+ frame->format,
+ frame->sample_rate,
+ 0,
+ NULL))
+ {
+ av_frame_unref (frame);
+ goto done;
+ }
+ if (0 > swr_init (swr))
+ {
+ av_frame_unref (frame);
+ goto done;
+ }
+ }
+ room = (int) (max_samples - have);
+ out[0] = (uint8_t *) &pcm[have];
+ got = swr_convert (swr,
+ out,
+ room,
+ (const uint8_t **) frame->extended_data,
+ frame->nb_samples);
+ av_frame_unref (frame);
+ if (got <= 0)
+ continue;
+ have += (size_t) got;
+ if (have >= max_samples)
+ goto done;
+ }
+ if (0 != r)
+ break;
+ }
+done:
+ if (NULL != swr)
+ swr_free (&swr);
+ av_frame_free (&frame);
+ av_packet_free (&pkt);
+ return have;
+}
+
+
+/**
+ * Set up the Opus encoder and the Ogg muxer, writing into a dynamic
+ * memory buffer.
+ *
+ * @param opt plugin options (bitrate)
+ * @param[out] enc encoder state to initialize
+ * @return 0 on success
+ */
+static int
+encoder_start (const struct EXTRACTOR_MediaOptions *opt,
+ struct Encoder *enc)
+{
+ const AVCodec *codec;
+ AVChannelLayout mono = AV_CHANNEL_LAYOUT_MONO;
+ int experimental = 0;
+
+ memset (enc, 0, sizeof (*enc));
+ codec = avcodec_find_encoder_by_name ("libopus");
+ if (NULL == codec)
+ {
+ /* FFmpeg's own Opus encoder is still marked experimental, but it is
+ better than emitting nothing at all */
+ codec = avcodec_find_encoder (AV_CODEC_ID_OPUS);
+ experimental = 1;
+ }
+ if (NULL == codec)
+ return -1;
+ if (0 > avformat_alloc_output_context2 (&enc->oc,
+ NULL,
+ "ogg",
+ NULL))
+ return -1;
+ if (NULL == enc->oc)
+ return -1;
+ if (NULL == (enc->ctx = avcodec_alloc_context3 (codec)))
+ return -1;
+ enc->ctx->sample_rate = OUTPUT_SAMPLE_RATE;
+ enc->ctx->bit_rate = opt->bitrate;
+ enc->ctx->time_base = (AVRational) { 1, OUTPUT_SAMPLE_RATE };
+ enc->ctx->thread_count = 1;
+ if (0 != av_channel_layout_copy (&enc->ctx->ch_layout,
+ &mono))
+ return -1;
+ if (experimental)
+ {
+ enc->ctx->sample_fmt = AV_SAMPLE_FMT_FLTP;
+ enc->ctx->strict_std_compliance = FF_COMPLIANCE_EXPERIMENTAL;
+ }
+ else
+ {
+ enc->ctx->sample_fmt = AV_SAMPLE_FMT_S16;
+ }
+ /* the Ogg muxer needs the OpusHead/OpusTags headers as extradata */
+ enc->ctx->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
+ /* libopus defaults to unconstrained VBR, where the bitrate we asked for
+ is little more than a suggestion -- a pure tone happily comes out at
+ twice the target. We care about the size of the result, so ask for
+ the constrained variant. Not an option on the native encoder, which
+ simply ignores it. */
+ if (NULL != enc->ctx->priv_data)
+ (void) av_opt_set (enc->ctx->priv_data,
+ "vbr",
+ "constrained",
+ 0);
+ if (0 != avcodec_open2 (enc->ctx, codec, NULL))
+ return -1;
+ if (NULL == (enc->st = avformat_new_stream (enc->oc, NULL)))
+ return -1;
+ enc->st->time_base = enc->ctx->time_base;
+ if (0 > avcodec_parameters_from_context (enc->st->codecpar,
+ enc->ctx))
+ return -1;
+ if (0 != avio_open_dyn_buf (&enc->oc->pb))
+ return -1;
+ if (0 > avformat_write_header (enc->oc, NULL))
+ return -1;
+ return 0;
+}
+
+
+/**
+ * Hand one packet from the encoder to the muxer.
+ *
+ * @param enc encoder state
+ * @param pkt packet to write, unreferenced on return
+ * @return 0 on success
+ */
+static int
+encoder_write (struct Encoder *enc,
+ AVPacket *pkt)
+{
+ av_packet_rescale_ts (pkt,
+ enc->ctx->time_base,
+ enc->st->time_base);
+ pkt->stream_index = enc->st->index;
+ return (0 > av_interleaved_write_frame (enc->oc, pkt)) ? -1 : 0;
+}
+
+
+/**
+ * Encode @a num_samples of 48kHz mono PCM and finish the Ogg stream.
+ *
+ * @param enc encoder state
+ * @param pcm samples to encode
+ * @param num_samples number of samples in @a pcm
+ * @param[out] output set to the Ogg stream, to be freed with av_free()
+ * @param[out] output_size set to the number of bytes in @a output
+ * @return 0 on success
+ */
+static int
+encoder_finish (struct Encoder *enc,
+ const int16_t *pcm,
+ size_t num_samples,
+ uint8_t **output,
+ size_t *output_size)
+{
+ AVFrame *frame = NULL;
+ AVPacket *pkt = NULL;
+ size_t pos = 0;
+ int frame_size;
+ int ret = -1;
+ int size;
+
+ frame_size = (enc->ctx->frame_size > 0)
+ ? enc->ctx->frame_size
+ : OUTPUT_SAMPLE_RATE / 50;
+ if (NULL == (pkt = av_packet_alloc ()))
+ goto cleanup;
+ if (NULL == (frame = av_frame_alloc ()))
+ goto cleanup;
+ frame->format = enc->ctx->sample_fmt;
+ frame->sample_rate = OUTPUT_SAMPLE_RATE;
+ frame->nb_samples = frame_size;
+ if (0 != av_channel_layout_copy (&frame->ch_layout,
+ &enc->ctx->ch_layout))
+ goto cleanup;
+ if (0 != av_frame_get_buffer (frame, 0))
+ goto cleanup;
+ while (pos < num_samples)
+ {
+ size_t n = num_samples - pos;
+ int r;
+
+ if (n > (size_t) frame_size)
+ n = frame_size;
+ if (0 != av_frame_make_writable (frame))
+ goto cleanup;
+ if (AV_SAMPLE_FMT_S16 == enc->ctx->sample_fmt)
+ {
+ memcpy (frame->data[0],
+ &pcm[pos],
+ n * sizeof (int16_t));
+ if (n < (size_t) frame_size)
+ memset (frame->data[0] + n * sizeof (int16_t),
+ 0,
+ (frame_size - n) * sizeof (int16_t));
+ }
+ else
+ {
+ float *dst = (float *) frame->data[0];
+ size_t i;
+
+ for (i = 0; i < n; i++)
+ dst[i] = pcm[pos + i] / 32768.0f;
+ for (i = n; i < (size_t) frame_size; i++)
+ dst[i] = 0.0f;
+ }
+ frame->pts = (int64_t) pos;
+ if (0 != avcodec_send_frame (enc->ctx, frame))
+ goto cleanup;
+ while (0 == (r = avcodec_receive_packet (enc->ctx, pkt)))
+ if (0 != encoder_write (enc, pkt))
+ goto cleanup;
+ if (AVERROR (EAGAIN) != r)
+ goto cleanup;
+ pos += n;
+ }
+ if (0 != avcodec_send_frame (enc->ctx, NULL))
+ goto cleanup;
+ while (0 == avcodec_receive_packet (enc->ctx, pkt))
+ if (0 != encoder_write (enc, pkt))
+ goto cleanup;
+ if (0 > av_write_trailer (enc->oc))
+ goto cleanup;
+ size = avio_close_dyn_buf (enc->oc->pb,
+ output);
+ enc->oc->pb = NULL;
+ if (size <= 0)
+ {
+ if (NULL != *output)
+ av_free (*output);
+ *output = NULL;
+ goto cleanup;
+ }
+ *output_size = (size_t) size;
+ ret = 0;
+cleanup:
+ if (NULL != frame)
+ av_frame_free (&frame);
+ if (NULL != pkt)
+ av_packet_free (&pkt);
+ return ret;
+}
+
+
+/**
+ * Clean up the encoder state.
+ *
+ * @param enc encoder state to free
+ */
+static void
+encoder_cleanup (struct Encoder *enc)
+{
+ if (NULL != enc->ctx)
+ avcodec_free_context (&enc->ctx);
+ if (NULL != enc->oc)
+ {
+ if (NULL != enc->oc->pb)
+ {
+ uint8_t *leftover = NULL;
+
+ (void) avio_close_dyn_buf (enc->oc->pb,
+ &leftover);
+ enc->oc->pb = NULL;
+ if (NULL != leftover)
+ av_free (leftover);
+ }
+ avformat_free_context (enc->oc);
+ enc->oc = NULL;
+ }
+}
+
+
+/**
+ * Main method for the opus-preview plugin.
+ *
+ * @param ec extraction context
+ */
+void
+EXTRACTOR_previewopus_extract_method (struct EXTRACTOR_ExtractContext *ec);
+
+void
+EXTRACTOR_previewopus_extract_method (struct EXTRACTOR_ExtractContext *ec)
+{
+ struct EXTRACTOR_MediaOptions opt;
+ struct EXTRACTOR_FFmpegIO io;
+ struct Encoder enc;
+ AVCodecContext *ctx = NULL;
+ int16_t *pcm = NULL;
+ uint8_t *preview = NULL;
+ size_t preview_size = 0;
+ size_t max_samples;
+ size_t have;
+ size_t limit;
+ double target;
+ int stream;
+
+ EXTRACTOR_media_options_parse_ (ec->config,
+ &opt);
+ EXTRACTOR_media_deadline_start_ (&io.deadline,
+ opt.deadline);
+ io.ec = ec;
+ memset (&enc, 0, sizeof (enc));
+ if (! EXTRACTOR_media_is_media_ (ec, 1))
+ return;
+ if (0 != EXTRACTOR_ffmpeg_open_ (&io))
+ return;
+ stream = EXTRACTOR_ffmpeg_open_stream_ (&io,
+ AVMEDIA_TYPE_AUDIO,
+ &ctx);
+ if (0 > stream)
+ goto cleanup;
+ target = EXTRACTOR_ffmpeg_seek_ (&io,
+ stream,
+ ctx,
+ opt.offset);
+ max_samples = (size_t) opt.length * OUTPUT_SAMPLE_RATE;
+ if (NULL == (pcm = malloc (max_samples * sizeof (int16_t))))
+ goto cleanup;
+ have = decode_pcm (&io,
+ ctx,
+ stream,
+ target,
+ max_samples,
+ pcm);
+ if (have < (size_t) MIN_PREVIEW_MS * OUTPUT_SAMPLE_RATE / 1000)
+ goto cleanup; /* too short to be worth anything */
+ EXTRACTOR_media_fade_s16_ (pcm,
+ have,
+ FADE_MS * OUTPUT_SAMPLE_RATE / 1000);
+ if (0 != encoder_start (&opt,
+ &enc))
+ goto cleanup;
+ if (0 != encoder_finish (&enc,
+ pcm,
+ have,
+ &preview,
+ &preview_size))
+ goto cleanup;
+ /* the caller asked for a preview of a given length and bitrate, so let
+ that decide the limit, but never emit something absurd */
+ limit = (size_t) opt.length * opt.bitrate / 8 + 8192;
+ if (limit < EXTRACTOR_MEDIA_MAX_PREVIEW)
+ limit = EXTRACTOR_MEDIA_MAX_PREVIEW;
+ if (preview_size > limit)
+ goto cleanup;
+ ec->proc (ec->cls,
+ "previewopus",
+ EXTRACTOR_METATYPE_AUDIO_PREVIEW,
+ EXTRACTOR_METAFORMAT_BINARY,
+ "audio/ogg",
+ (const char *) preview,
+ preview_size);
+cleanup:
+ if (NULL != preview)
+ av_free (preview);
+ encoder_cleanup (&enc);
+ free (pcm);
+ if (NULL != ctx)
+ avcodec_free_context (&ctx);
+ EXTRACTOR_ffmpeg_close_ (&io);
+}
+
+
+/**
+ * Keep the diagnostics of the media framework out of the output of the
+ * calling application; a decoder complaining about a broken file is not
+ * something the application asked to see.
+ *
+ * ("force-kill" would be tempting here, to get a fresh process for every
+ * file, but the host cannot know about that special without loading the
+ * plugin in-process: it only finds out that the child is gone once it has
+ * already handed it the next file, which is then silently lost.)
+ *
+ * @return special options for this plugin
+ */
+const char *
+EXTRACTOR_previewopus_options (void);
+
+const char *
+EXTRACTOR_previewopus_options ()
+{
+ return "close-stderr";
+}
+
+
+/**
+ * Silence FFmpeg's logging.
+ */
+void __attribute__ ((constructor))
+previewopus_init (void);
+
+void __attribute__ ((constructor))
+previewopus_init ()
+{
+ EXTRACTOR_ffmpeg_silence_ ();
+}
+
+
+/* end of previewopus_extractor.c */
diff --git a/src/plugins/test_lib.c b/src/plugins/test_lib.c
@@ -113,6 +113,10 @@ process_replies (void *cls,
if (NULL != data_mime_type)
continue;
}
+ if ( (NULL != sd[i].validate) &&
+ (0 == sd[i].validate (data,
+ data_len)) )
+ continue;
sd[i].solved = 1;
return 0;
}
diff --git a/src/plugins/test_lib.h b/src/plugins/test_lib.h
@@ -68,6 +68,20 @@ struct SolutionData
* Treat solution as a regex that must match.
*/
int regex;
+
+ /**
+ * Optional additional check on the meta data, for values that cannot
+ * be spelled out in a test (an encoder's output, say, of which only
+ * the header is predictable). Called after type, format, mime type
+ * and the @e data prefix matched; the item only counts as solved if
+ * this returns non-zero. NULL to skip.
+ *
+ * @param data the meta data the plugin produced
+ * @param data_len number of bytes in @a data
+ * @return non-zero if @a data is acceptable
+ */
+ int (*validate)(const void *data,
+ size_t data_len);
};
diff --git a/src/plugins/test_media_lib.c b/src/plugins/test_media_lib.c
@@ -0,0 +1,321 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+ */
+/**
+ * @file plugins/test_media_lib.c
+ * @brief helpers for testing the video thumbnail and audio preview plugins
+ * @author Christian Grothoff
+ */
+#include "platform.h"
+#include "test_media_lib.h"
+
+#if TEST_MEDIA_HAVE_PNG
+#include <zlib.h>
+#endif
+
+/**
+ * How far a decoded colour may be off before we call it a mismatch.
+ */
+#define COLOR_TOLERANCE 48
+
+
+/**
+ * Read a big endian 32 bit number.
+ *
+ * @param p bytes to read
+ * @return the number
+ */
+static uint32_t
+get_u32 (const uint8_t *p)
+{
+ return (((uint32_t) p[0]) << 24)
+ | (((uint32_t) p[1]) << 16)
+ | (((uint32_t) p[2]) << 8)
+ | ((uint32_t) p[3]);
+}
+
+
+#if TEST_MEDIA_HAVE_PNG
+/**
+ * The Paeth predictor of the PNG specification.
+ *
+ * @param a pixel to the left
+ * @param b pixel above
+ * @param c pixel above left
+ * @return the predicted value
+ */
+static uint8_t
+paeth (uint8_t a,
+ uint8_t b,
+ uint8_t c)
+{
+ int p = (int) a + (int) b - (int) c;
+ int pa = abs (p - (int) a);
+ int pb = abs (p - (int) b);
+ int pc = abs (p - (int) c);
+
+ if ( (pa <= pb) &&
+ (pa <= pc) )
+ return a;
+ if (pb <= pc)
+ return b;
+ return c;
+}
+
+
+/**
+ * Undo the per-scanline filters of a decompressed PNG image.
+ *
+ * @param[in,out] raw the filtered scanlines, overwritten with the image
+ * @param width width of the image in pixels
+ * @param height height of the image in pixels
+ * @param bpp number of bytes per pixel
+ * @return 1 on success
+ */
+static int
+unfilter (uint8_t *raw,
+ uint32_t width,
+ uint32_t height,
+ uint32_t bpp)
+{
+ uint32_t stride = width * bpp;
+ uint8_t *prev = NULL;
+ uint32_t y;
+
+ for (y = 0; y < height; y++)
+ {
+ uint8_t *row = &raw[y * (stride + 1)];
+ uint8_t ft = row[0];
+ uint32_t x;
+
+ row++;
+ for (x = 0; x < stride; x++)
+ {
+ uint8_t a = (x >= bpp) ? row[x - bpp] : 0;
+ uint8_t b = (NULL != prev) ? prev[x] : 0;
+ uint8_t c = ( (x >= bpp) && (NULL != prev) ) ? prev[x - bpp] : 0;
+
+ switch (ft)
+ {
+ case 0:
+ break;
+ case 1:
+ row[x] += a;
+ break;
+ case 2:
+ row[x] += b;
+ break;
+ case 3:
+ row[x] += (uint8_t) (((int) a + (int) b) / 2);
+ break;
+ case 4:
+ row[x] += paeth (a, b, c);
+ break;
+ default:
+ return 0;
+ }
+ }
+ prev = row;
+ }
+ return 1;
+}
+
+
+#endif
+
+
+int
+TEST_media_png_center (const void *data,
+ size_t data_len,
+ uint8_t rgb[3])
+{
+#if ! TEST_MEDIA_HAVE_PNG
+ (void) data;
+ (void) data_len;
+ (void) rgb;
+ return 0;
+#else
+ static const uint8_t magic[] = { 137, 'P', 'N', 'G', '\r', '\n', 26, '\n' };
+ const uint8_t *d = data;
+ uint8_t *idat = NULL;
+ uint8_t *raw = NULL;
+ size_t idat_len = 0;
+ size_t pos = sizeof (magic);
+ uLongf raw_len;
+ uint32_t width = 0;
+ uint32_t height = 0;
+ uint32_t bpp = 0;
+ uint32_t stride;
+ const uint8_t *px;
+ int ret = 0;
+
+ if ( (data_len < sizeof (magic) + 12) ||
+ (0 != memcmp (d, magic, sizeof (magic))) )
+ return 0;
+ while (pos + 12 <= data_len)
+ {
+ uint32_t clen = get_u32 (&d[pos]);
+ const char *ctype = (const char *) &d[pos + 4];
+ const uint8_t *cdata = &d[pos + 8];
+
+ if ( (clen > data_len) ||
+ (pos + 12 + clen > data_len) )
+ break;
+ if (0 == memcmp (ctype, "IHDR", 4))
+ {
+ if (clen < 13)
+ goto cleanup;
+ width = get_u32 (&cdata[0]);
+ height = get_u32 (&cdata[4]);
+ if ( (8 != cdata[8]) ||
+ (0 != cdata[12]) )
+ goto cleanup; /* not 8 bits per channel, or interlaced */
+ if (2 == cdata[9])
+ bpp = 3;
+ else if (6 == cdata[9])
+ bpp = 4;
+ else
+ goto cleanup; /* palette or greyscale; our encoders emit neither */
+ if ( (0 == width) ||
+ (0 == height) ||
+ (width > 4096) ||
+ (height > 4096) )
+ goto cleanup;
+ }
+ else if (0 == memcmp (ctype, "IDAT", 4))
+ {
+ uint8_t *n = realloc (idat, idat_len + clen);
+
+ if (NULL == n)
+ goto cleanup;
+ idat = n;
+ memcpy (&idat[idat_len],
+ cdata,
+ clen);
+ idat_len += clen;
+ }
+ else if (0 == memcmp (ctype, "IEND", 4))
+ {
+ break;
+ }
+ pos += 12 + clen;
+ }
+ if ( (0 == bpp) ||
+ (NULL == idat) )
+ goto cleanup;
+ stride = width * bpp;
+ raw_len = (uLongf) height * (stride + 1);
+ if (NULL == (raw = malloc (raw_len)))
+ goto cleanup;
+ if (Z_OK != uncompress (raw,
+ &raw_len,
+ idat,
+ (uLong) idat_len))
+ goto cleanup;
+ if (raw_len != (uLongf) height * (stride + 1))
+ goto cleanup;
+ if (! unfilter (raw, width, height, bpp))
+ goto cleanup;
+ px = &raw[(height / 2) * (stride + 1) + 1 + (width / 2) * bpp];
+ rgb[0] = px[0];
+ rgb[1] = px[1];
+ rgb[2] = px[2];
+ ret = 1;
+cleanup:
+ free (raw);
+ free (idat);
+ return ret;
+#endif
+}
+
+
+int
+TEST_media_png_center_is (const void *data,
+ size_t data_len,
+ uint8_t r,
+ uint8_t g,
+ uint8_t b)
+{
+ uint8_t rgb[3];
+
+ if (! TEST_media_png_center (data,
+ data_len,
+ rgb))
+ {
+#if TEST_MEDIA_HAVE_PNG
+ fprintf (stderr,
+ "Failed to decode the PNG the plugin produced\n");
+ return 0;
+#else
+ /* without zlib we cannot look inside; the magic number check that
+ the caller already did will have to do */
+ return 1;
+#endif
+ }
+ if ( (abs ((int) rgb[0] - (int) r) > COLOR_TOLERANCE) ||
+ (abs ((int) rgb[1] - (int) g) > COLOR_TOLERANCE) ||
+ (abs ((int) rgb[2] - (int) b) > COLOR_TOLERANCE) )
+ {
+ fprintf (stderr,
+ "Thumbnail centre is rgb(%u,%u,%u), expected rgb(%u,%u,%u); "
+ "the plugin most likely sampled the wrong position\n",
+ rgb[0],
+ rgb[1],
+ rgb[2],
+ r,
+ g,
+ b);
+ return 0;
+ }
+ return 1;
+}
+
+
+int
+TEST_media_is_ogg_opus (const void *data,
+ size_t data_len)
+{
+ const uint8_t *d = data;
+ uint8_t segments;
+ size_t payload;
+
+ /* an Ogg page header is 27 bytes plus one byte per segment */
+ if (data_len < 28 + 8)
+ return 0;
+ if (0 != memcmp (d, "OggS", 4))
+ return 0;
+ if (0 != d[4])
+ return 0; /* stream structure version */
+ segments = d[26];
+ payload = 27 + (size_t) segments;
+ if (payload + 8 > data_len)
+ return 0;
+ if (0 != memcmp (&d[payload],
+ "OpusHead",
+ 8))
+ {
+ fprintf (stderr,
+ "Ogg stream does not start with an OpusHead header\n");
+ return 0;
+ }
+ return 1;
+}
+
+
+/* end of test_media_lib.c */
diff --git a/src/plugins/test_media_lib.h b/src/plugins/test_media_lib.h
@@ -0,0 +1,89 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+ */
+/**
+ * @file plugins/test_media_lib.h
+ * @brief helpers for testing the video thumbnail and audio preview plugins
+ * @author Christian Grothoff
+ *
+ * Checking that a thumbnailer emitted *a* JPEG only proves that the
+ * plugin ran, not that it looked where it was told to. The test videos
+ * therefore change colour at a known point in time and these helpers
+ * inspect the pixel that comes back.
+ */
+#ifndef TEST_MEDIA_LIB_H
+#define TEST_MEDIA_LIB_H
+
+#include <stdint.h>
+#include <stdlib.h>
+
+/**
+ * Decode the PNG in @a data and return its centre pixel.
+ *
+ * Only the subset of PNG our own encoders produce is understood: 8 bits
+ * per channel, RGB or RGBA, not interlaced. Requires zlib; without it
+ * this always fails and the caller should treat the check as skipped
+ * (see #TEST_MEDIA_HAVE_PNG).
+ *
+ * @param data the PNG image
+ * @param data_len number of bytes in @a data
+ * @param[out] rgb set to the red, green and blue value of the centre pixel
+ * @return 1 on success
+ */
+int
+TEST_media_png_center (const void *data,
+ size_t data_len,
+ uint8_t rgb[3]);
+
+
+/**
+ * Check that @a data is an Ogg stream whose first page carries the
+ * OpusHead identification header of RFC 7845.
+ *
+ * @param data the stream
+ * @param data_len number of bytes in @a data
+ * @return 1 if this is Ogg Opus
+ */
+int
+TEST_media_is_ogg_opus (const void *data,
+ size_t data_len);
+
+
+/**
+ * Is the centre pixel of the PNG in @a data approximately @a r, @a g,
+ * @a b? Scaling and re-encoding move colours around a little, hence the
+ * generous tolerance.
+ *
+ * @param data the PNG image
+ * @param data_len number of bytes in @a data
+ * @param r expected red value
+ * @param g expected green value
+ * @param b expected blue value
+ * @return 1 if the pixel matches (or if PNG support was compiled out)
+ */
+int
+TEST_media_png_center_is (const void *data,
+ size_t data_len,
+ uint8_t r,
+ uint8_t g,
+ uint8_t b);
+
+#endif
+
+/* end of test_media_lib.h */
diff --git a/src/plugins/test_previewgst.c b/src/plugins/test_previewgst.c
@@ -0,0 +1,108 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+*/
+/**
+ * @file plugins/test_previewgst.c
+ * @brief testcase for the previewgst plugin
+ * @author Christian Grothoff
+ *
+ * What comes out is an Opus stream, and comparing that byte for byte
+ * would mean pinning down a particular version of libopus. So we check
+ * that it is a well formed Ogg stream carrying the OpusHead
+ * identification header of RFC 7845, and that it is neither empty nor
+ * absurdly large.
+ */
+#include "platform.h"
+#include "test_lib.h"
+#include "test_media_lib.h"
+
+
+/**
+ * Check that the preview is a plausible Ogg Opus stream.
+ *
+ * @param data the preview the plugin produced
+ * @param data_len number of bytes in @a data
+ * @return non-zero if the preview looks right
+ */
+static int
+validate_opus (const void *data,
+ size_t data_len)
+{
+ if (data_len < 1024)
+ {
+ fprintf (stderr,
+ "Audio preview of %u bytes is too short to be 15s of Opus\n",
+ (unsigned int) data_len);
+ return 0;
+ }
+ return TEST_media_is_ogg_opus (data,
+ data_len);
+}
+
+
+/**
+ * Main function for the previewgst testcase.
+ *
+ * @param argc number of arguments (ignored)
+ * @param argv arguments (ignored)
+ * @return 0 on success
+ */
+int
+main (int argc, char *argv[])
+{
+ struct SolutionData previewgst_sol[] = {
+ {
+ EXTRACTOR_METATYPE_AUDIO_PREVIEW,
+ EXTRACTOR_METAFORMAT_BINARY,
+ "audio/ogg",
+ "OggS",
+ strlen ("OggS"),
+ 0,
+ 0,
+ &validate_opus
+ },
+ { 0, 0, NULL, NULL, 0, -1 }
+ };
+ struct SolutionData previewgst_video_sol[] = {
+ {
+ EXTRACTOR_METATYPE_AUDIO_PREVIEW,
+ EXTRACTOR_METAFORMAT_BINARY,
+ "audio/ogg",
+ "OggS",
+ strlen ("OggS"),
+ 0,
+ 0,
+ &validate_opus
+ },
+ { 0, 0, NULL, NULL, 0, -1 }
+ };
+ struct ProblemSet ps[] = {
+ { "testdata/preview_tone.ogg",
+ previewgst_sol },
+ { "testdata/preview_tone.ogv",
+ previewgst_video_sol },
+ { NULL, NULL }
+ };
+
+ return ET_main ("previewgst",
+ ps);
+}
+
+
+/* end of test_previewgst.c */
diff --git a/src/plugins/test_previewopus.c b/src/plugins/test_previewopus.c
@@ -0,0 +1,109 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2013, 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+*/
+/**
+ * @file plugins/test_previewopus.c
+ * @brief testcase for the previewopus plugin
+ * @author Bruno Cabral
+ * @author Christian Grothoff
+ *
+ * What comes out is an Opus stream, and comparing that byte for byte
+ * would mean pinning down a particular version of libopus. So we check
+ * that it is a well formed Ogg stream carrying the OpusHead
+ * identification header of RFC 7845, and that it is neither empty nor
+ * absurdly large.
+ */
+#include "platform.h"
+#include "test_lib.h"
+#include "test_media_lib.h"
+
+
+/**
+ * Check that the preview is a plausible Ogg Opus stream.
+ *
+ * @param data the preview the plugin produced
+ * @param data_len number of bytes in @a data
+ * @return non-zero if the preview looks right
+ */
+static int
+validate_opus (const void *data,
+ size_t data_len)
+{
+ if (data_len < 1024)
+ {
+ fprintf (stderr,
+ "Audio preview of %u bytes is too short to be 15s of Opus\n",
+ (unsigned int) data_len);
+ return 0;
+ }
+ return TEST_media_is_ogg_opus (data,
+ data_len);
+}
+
+
+/**
+ * Main function for the previewopus testcase.
+ *
+ * @param argc number of arguments (ignored)
+ * @param argv arguments (ignored)
+ * @return 0 on success
+ */
+int
+main (int argc, char *argv[])
+{
+ struct SolutionData previewopus_sol[] = {
+ {
+ EXTRACTOR_METATYPE_AUDIO_PREVIEW,
+ EXTRACTOR_METAFORMAT_BINARY,
+ "audio/ogg",
+ "OggS",
+ strlen ("OggS"),
+ 0,
+ 0,
+ &validate_opus
+ },
+ { 0, 0, NULL, NULL, 0, -1 }
+ };
+ struct SolutionData previewopus_video_sol[] = {
+ {
+ EXTRACTOR_METATYPE_AUDIO_PREVIEW,
+ EXTRACTOR_METAFORMAT_BINARY,
+ "audio/ogg",
+ "OggS",
+ strlen ("OggS"),
+ 0,
+ 0,
+ &validate_opus
+ },
+ { 0, 0, NULL, NULL, 0, -1 }
+ };
+ struct ProblemSet ps[] = {
+ { "testdata/preview_tone.ogg",
+ previewopus_sol },
+ { "testdata/preview_tone.ogv",
+ previewopus_video_sol },
+ { NULL, NULL }
+ };
+
+ return ET_main ("previewopus",
+ ps);
+}
+
+
+/* end of test_previewopus.c */
diff --git a/src/plugins/test_thumbnailffmpeg.c b/src/plugins/test_thumbnailffmpeg.c
@@ -0,0 +1,112 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2013, 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+*/
+/**
+ * @file plugins/test_thumbnailffmpeg.c
+ * @brief testcase for the thumbnailffmpeg plugin
+ * @author Christian Grothoff
+ *
+ * The encoder's output depends on the FFmpeg version, so the JPEG can
+ * only be checked for its magic number. The PNG round, on the other
+ * hand, is decoded: testdata/thumbnail_pattern.ogv is black for the
+ * first 20 seconds and red afterwards, so the colour of the thumbnail
+ * says whether the plugin really sampled where it claims to.
+ */
+#include "platform.h"
+#include "test_lib.h"
+#include "test_media_lib.h"
+
+
+/**
+ * Check that the thumbnail was taken from the red part of the video.
+ *
+ * @param data the PNG the plugin produced
+ * @param data_len number of bytes in @a data
+ * @return non-zero if the thumbnail looks right
+ */
+static int
+validate_red (const void *data,
+ size_t data_len)
+{
+ return TEST_media_png_center_is (data,
+ data_len,
+ 0xFE,
+ 0x00,
+ 0x00);
+}
+
+
+/**
+ * Main function for the thumbnailffmpeg testcase.
+ *
+ * @param argc number of arguments (ignored)
+ * @param argv arguments (ignored)
+ * @return 0 on success
+ */
+int
+main (int argc, char *argv[])
+{
+ uint8_t jpeg_magic[] = { 0xFF, 0xD8, 0xFF };
+ uint8_t png_magic[] = { 137, 'P', 'N', 'G', '\r', '\n', 26, '\n' };
+ struct SolutionData thumbnail_jpeg_sol[] = {
+ {
+ EXTRACTOR_METATYPE_THUMBNAIL,
+ EXTRACTOR_METAFORMAT_BINARY,
+ "image/jpeg",
+ (const char *) jpeg_magic,
+ sizeof (jpeg_magic),
+ 0
+ },
+ { 0, 0, NULL, NULL, 0, -1 }
+ };
+ struct SolutionData thumbnail_png_sol[] = {
+ {
+ EXTRACTOR_METATYPE_THUMBNAIL,
+ EXTRACTOR_METAFORMAT_BINARY,
+ "image/png",
+ (const char *) png_magic,
+ sizeof (png_magic),
+ 0,
+ 0,
+ &validate_red
+ },
+ { 0, 0, NULL, NULL, 0, -1 }
+ };
+ struct ProblemSet ps_jpeg[] = {
+ { "testdata/thumbnail_pattern.ogv",
+ thumbnail_jpeg_sol },
+ { NULL, NULL }
+ };
+ struct ProblemSet ps_png[] = {
+ { "testdata/thumbnail_pattern.ogv",
+ thumbnail_png_sol },
+ { NULL, NULL }
+ };
+ int ret;
+
+ ret = ET_main ("thumbnailffmpeg",
+ ps_jpeg);
+ if (0 != ret)
+ return ret;
+ return ET_main ("thumbnailffmpeg(format=png,size=32)",
+ ps_png);
+}
+
+
+/* end of test_thumbnailffmpeg.c */
diff --git a/src/plugins/test_thumbnailgst.c b/src/plugins/test_thumbnailgst.c
@@ -0,0 +1,141 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+*/
+/**
+ * @file plugins/test_thumbnailgst.c
+ * @brief testcase for the thumbnailgst plugin
+ * @author Christian Grothoff
+ *
+ * The encoder's output depends on the GStreamer version, so the JPEG can
+ * only be checked for its magic number. The PNG round, on the other
+ * hand, is decoded: testdata/thumbnail_pattern.ogv is black for the
+ * first 20 seconds and red afterwards, so the colour of the thumbnail
+ * says whether the plugin really sampled where it claims to.
+ */
+#include "platform.h"
+#include "test_lib.h"
+#include "test_media_lib.h"
+#ifndef WINDOWS
+#include <sys/wait.h>
+#endif
+
+
+/**
+ * Check that the thumbnail was taken from the red part of the video.
+ *
+ * @param data the PNG the plugin produced
+ * @param data_len number of bytes in @a data
+ * @return non-zero if the thumbnail looks right
+ */
+static int
+validate_red (const void *data,
+ size_t data_len)
+{
+ return TEST_media_png_center_is (data,
+ data_len,
+ 0xFE,
+ 0x00,
+ 0x00);
+}
+
+
+/**
+ * Main function for the thumbnailgst testcase.
+ *
+ * @param argc number of arguments (ignored)
+ * @param argv arguments (ignored)
+ * @return 0 on success
+ */
+int
+main (int argc, char *argv[])
+{
+ uint8_t jpeg_magic[] = { 0xFF, 0xD8, 0xFF };
+ uint8_t png_magic[] = { 137, 'P', 'N', 'G', '\r', '\n', 26, '\n' };
+ struct SolutionData thumbnail_jpeg_sol[] = {
+ {
+ EXTRACTOR_METATYPE_THUMBNAIL,
+ EXTRACTOR_METAFORMAT_BINARY,
+ "image/jpeg",
+ (const char *) jpeg_magic,
+ sizeof (jpeg_magic),
+ 0
+ },
+ { 0, 0, NULL, NULL, 0, -1 }
+ };
+ struct SolutionData thumbnail_png_sol[] = {
+ {
+ EXTRACTOR_METATYPE_THUMBNAIL,
+ EXTRACTOR_METAFORMAT_BINARY,
+ "image/png",
+ (const char *) png_magic,
+ sizeof (png_magic),
+ 0,
+ 0,
+ &validate_red
+ },
+ { 0, 0, NULL, NULL, 0, -1 }
+ };
+ struct ProblemSet ps_jpeg[] = {
+ { "testdata/thumbnail_pattern.ogv",
+ thumbnail_jpeg_sol },
+ { NULL, NULL }
+ };
+ struct ProblemSet ps_png[] = {
+ { "testdata/thumbnail_pattern.ogv",
+ thumbnail_png_sol },
+ { NULL, NULL }
+ };
+#ifndef WINDOWS
+ pid_t pid;
+ int status;
+
+ /* The two rounds must not share a process: ET_main() runs each round
+ out-of-process and then in-process, and once GStreamer has been
+ initialized in this process (by the in-process leg of the first
+ round), the fork() that the out-of-process leg of the second round
+ performs inherits a GStreamer that no longer works in the child.
+ So run the first round in a child of our own, before any of that
+ has happened. */
+ pid = fork ();
+ if (0 == pid)
+ _exit (ET_main ("thumbnailgst",
+ ps_jpeg));
+ if (-1 == pid)
+ return 77; /* cannot fork, skip */
+ if (pid != waitpid (pid,
+ &status,
+ 0))
+ return 1;
+ if ( (! WIFEXITED (status)) ||
+ (0 != WEXITSTATUS (status)) )
+ return 1;
+#else
+ int ret;
+
+ ret = ET_main ("thumbnailgst",
+ ps_jpeg);
+ if (0 != ret)
+ return ret;
+#endif
+ return ET_main ("thumbnailgst(format=png,size=32)",
+ ps_png);
+}
+
+
+/* end of test_thumbnailgst.c */
diff --git a/src/plugins/testdata/README.media b/src/plugins/testdata/README.media
@@ -0,0 +1,42 @@
+Provenance of the media samples
+===============================
+
+Unlike the rest of the files in this directory, the three files below
+were not found anywhere: they are generated, and the recipe that
+generates them is `contrib/gen_testmedia.sh' in this repository. Every
+frame and every sample comes out of FFmpeg's synthetic `lavfi' sources
+(a solid colour, a box, a sine wave), so there is no third party work
+involved and nothing to attribute. They are dedicated to the public
+domain (CC0 1.0).
+
+They exist because the video thumbnail plugins (thumbnailffmpeg,
+thumbnailgst) and the audio preview plugins (previewopus, previewgst)
+need input that is long enough to seek around in, and none of the other
+test files here is even 30 seconds long. Keeping them synthetic also
+keeps them small: all three together are smaller than a single one of
+the photographs in this directory.
+
+Theora and Vorbis in an Ogg container were chosen because both plugin
+backends can decode them without any additional codec package: FFmpeg has
+both built in, and for GStreamer they live in gst-plugins-base.
+
+ thumbnail_pattern.ogv 90s, Theora, 160x120, 5fps, no audio (14K)
+
+ Solid black for the first 20 seconds, then solid red. The
+ thumbnailers sample about 30 seconds in (bounded by a third of the
+ duration, which is why the file is 90 and not 35 seconds long), so
+ a thumbnail taken from the wrong moment comes out black instead of
+ red, and test_thumbnailffmpeg/test_thumbnailgst notice. Each frame
+ also carries a small white box: the thumbnailers deliberately skip
+ perfectly flat frames, which a fade-in leader would produce, and
+ the box keeps the test from tripping over that.
+
+ preview_tone.ogg 30s, Vorbis, mono, 8kHz (21K)
+
+ A 440Hz tone for 15 seconds followed by an 880Hz tone. 8kHz mono
+ is more than enough for something that gets re-encoded at 24 kbps.
+
+ preview_tone.ogv 30s, Theora + Vorbis (28K)
+
+ The same audio in a file that also carries video, so that the
+ preview plugins are exercised on a video container as well.
diff --git a/src/plugins/testdata/preview_tone.ogg b/src/plugins/testdata/preview_tone.ogg
Binary files differ.
diff --git a/src/plugins/testdata/preview_tone.ogv b/src/plugins/testdata/preview_tone.ogv
Binary files differ.
diff --git a/src/plugins/testdata/thumbnail_pattern.ogv b/src/plugins/testdata/thumbnail_pattern.ogv
Binary files differ.
diff --git a/src/plugins/thumbnailffmpeg_extractor.c b/src/plugins/thumbnailffmpeg_extractor.c
@@ -0,0 +1,460 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2008, 2012, 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+ */
+/**
+ * @file plugins/thumbnailffmpeg_extractor.c
+ * @author Christian Grothoff
+ * @brief this extractor produces a binary encoded thumbnail of a frame
+ * taken from a video, using FFmpeg for demuxing, decoding and
+ * scaling
+ *
+ * Unlike the gtk thumbnailer, which can only re-scale a still image, this
+ * plugin seeks about 30s into a video and encodes the frame it finds
+ * there as a small JPEG (or PNG). A previous incarnation of this plugin
+ * was removed in 2021 because it had accumulated compatibility code for
+ * every libav and FFmpeg API since 2008; this one deliberately requires
+ * FFmpeg 5.1 or newer and uses only the send/receive APIs, so that there
+ * is nothing to keep in sync with upstream churn.
+ */
+#include "platform.h"
+#include "extractor.h"
+#include "mediautil.h"
+#include "mediaffmpeg.h"
+
+#include <libavutil/imgutils.h>
+#include <libswscale/swscale.h>
+
+/**
+ * Give up after this many decoded frames, no matter what the timestamps
+ * say. A malformed file can otherwise keep us decoding forever.
+ */
+#define MAX_FRAMES 2048
+
+/**
+ * Mean absolute deviation below which we consider a frame to be a flat
+ * surface (a fade-in, a black leader) and not worth showing.
+ */
+#define FLAT_FRAME_MAD 2
+
+/**
+ * How far to jump ahead when we hit a flat frame, in seconds.
+ */
+#define FLAT_FRAME_SKIP 5
+
+/**
+ * How often we are willing to do that.
+ */
+#define MAX_FLAT_RETRIES 2
+
+/**
+ * JPEG quantizer scale; smaller is better and bigger.
+ */
+#define JPEG_QSCALE 5
+
+
+/**
+ * Is @a frame a flat surface? Cheap heuristic over the first plane; we
+ * only want to catch the completely uniform black frame of a fade-in.
+ *
+ * @param frame frame to inspect
+ * @return 1 if the frame carries no discernible detail
+ */
+static int
+is_flat (const AVFrame *frame)
+{
+ const uint8_t *p = frame->data[0];
+ int stride = frame->linesize[0];
+ uint64_t sum = 0;
+ uint64_t dev = 0;
+ unsigned int n = 0;
+ int x;
+ int y;
+
+ if ( (NULL == p) ||
+ (stride <= 0) ||
+ (frame->height <= 0) )
+ return 0;
+ for (y = 0; y < frame->height; y += 4)
+ for (x = 0; x < stride; x += 4)
+ {
+ sum += p[y * stride + x];
+ n++;
+ }
+ if (0 == n)
+ return 0;
+ sum /= n;
+ for (y = 0; y < frame->height; y += 4)
+ for (x = 0; x < stride; x += 4)
+ {
+ uint8_t v = p[y * stride + x];
+
+ dev += (v > sum) ? (v - sum) : (sum - v);
+ }
+ return (dev / n) < FLAT_FRAME_MAD;
+}
+
+
+/**
+ * Scale @a frame down and encode it as JPEG or PNG.
+ *
+ * @param frame the decoded frame to convert
+ * @param opt plugin options (target size and format)
+ * @param[out] output set to the encoded image, to be freed with av_free()
+ * @param[out] output_size set to the number of bytes in @a output
+ * @return 0 on success
+ */
+static int
+encode_thumbnail (const AVFrame *frame,
+ const struct EXTRACTOR_MediaOptions *opt,
+ uint8_t **output,
+ size_t *output_size)
+{
+ const AVCodec *codec;
+ AVCodecContext *ctx = NULL;
+ struct SwsContext *sws = NULL;
+ AVFrame *dst = NULL;
+ AVPacket *pkt = NULL;
+ enum AVPixelFormat dst_fmt;
+ unsigned int width;
+ unsigned int height;
+ int ret = -1;
+ int r;
+
+ if (! EXTRACTOR_media_thumbnail_size_ (frame->width,
+ frame->height,
+ opt->size,
+ &width,
+ &height))
+ return -1;
+ if (! sws_isSupportedInput (frame->format))
+ return -1;
+ if (opt->png)
+ {
+ dst_fmt = AV_PIX_FMT_RGB24;
+ codec = avcodec_find_encoder (AV_CODEC_ID_PNG);
+ }
+ else
+ {
+ dst_fmt = AV_PIX_FMT_YUVJ420P;
+ codec = avcodec_find_encoder (AV_CODEC_ID_MJPEG);
+ }
+ if (NULL == codec)
+ return -1;
+ if (NULL == (sws = sws_getContext (frame->width,
+ frame->height,
+ frame->format,
+ (int) width,
+ (int) height,
+ dst_fmt,
+ SWS_BILINEAR,
+ NULL,
+ NULL,
+ NULL)))
+ return -1;
+ if (NULL == (dst = av_frame_alloc ()))
+ goto cleanup;
+ dst->format = dst_fmt;
+ dst->width = (int) width;
+ dst->height = (int) height;
+ dst->pts = 0;
+ if (0 != av_frame_get_buffer (dst, 0))
+ goto cleanup;
+ if (0 > sws_scale (sws,
+ (const uint8_t *const *) frame->data,
+ frame->linesize,
+ 0,
+ frame->height,
+ dst->data,
+ dst->linesize))
+ goto cleanup;
+ if (NULL == (ctx = avcodec_alloc_context3 (codec)))
+ goto cleanup;
+ ctx->width = (int) width;
+ ctx->height = (int) height;
+ ctx->pix_fmt = dst_fmt;
+ ctx->time_base = (AVRational) { 1, 25 };
+ ctx->thread_count = 1;
+ if (opt->png)
+ {
+ ctx->compression_level = 9;
+ }
+ else
+ {
+ ctx->color_range = AVCOL_RANGE_JPEG;
+ ctx->flags |= AV_CODEC_FLAG_QSCALE;
+ ctx->global_quality = FF_QP2LAMBDA * JPEG_QSCALE;
+ }
+ if (0 != avcodec_open2 (ctx, codec, NULL))
+ goto cleanup;
+ if (NULL == (pkt = av_packet_alloc ()))
+ goto cleanup;
+ dst->quality = ctx->global_quality;
+ if (0 != avcodec_send_frame (ctx, dst))
+ goto cleanup;
+ r = avcodec_receive_packet (ctx, pkt);
+ if (AVERROR (EAGAIN) == r)
+ {
+ /* encoder wants to be flushed before it hands anything back */
+ if (0 != avcodec_send_frame (ctx, NULL))
+ goto cleanup;
+ r = avcodec_receive_packet (ctx, pkt);
+ }
+ if (0 != r)
+ goto cleanup;
+ if ( (pkt->size <= 0) ||
+ (pkt->size > EXTRACTOR_MEDIA_MAX_THUMBNAIL) )
+ goto cleanup;
+ if (NULL == (*output = av_malloc (pkt->size)))
+ goto cleanup;
+ memcpy (*output,
+ pkt->data,
+ pkt->size);
+ *output_size = (size_t) pkt->size;
+ ret = 0;
+cleanup:
+ if (NULL != pkt)
+ av_packet_free (&pkt);
+ if (NULL != ctx)
+ avcodec_free_context (&ctx);
+ if (NULL != dst)
+ av_frame_free (&dst);
+ sws_freeContext (sws);
+ return ret;
+}
+
+
+/**
+ * Decode the video stream until we reach @a target seconds, and hand back
+ * the frame we find there. If we never get there (short file, broken
+ * timestamps), the last frame we did decode is returned instead.
+ *
+ * @param io open I/O state
+ * @param ctx open decoder for @a stream
+ * @param stream index of the video stream
+ * @param target position we are aiming for, in seconds
+ * @param[out] frame frame to fill in
+ * @return 0 if @a frame was filled in
+ */
+static int
+decode_frame_at (struct EXTRACTOR_FFmpegIO *io,
+ AVCodecContext *ctx,
+ int stream,
+ double target,
+ AVFrame *frame)
+{
+ AVPacket *pkt;
+ AVFrame *last;
+ AVRational tb = io->fmt->streams[stream]->time_base;
+ unsigned int frames = 0;
+ unsigned int retries = 0;
+ int have_last = 0;
+ int ret = -1;
+
+ if (NULL == (pkt = av_packet_alloc ()))
+ return -1;
+ if (NULL == (last = av_frame_alloc ()))
+ {
+ av_packet_free (&pkt);
+ return -1;
+ }
+ while (frames < MAX_FRAMES)
+ {
+ int r;
+
+ if (EXTRACTOR_media_deadline_expired_ (&io->deadline))
+ break;
+ r = av_read_frame (io->fmt, pkt);
+ if (0 != r)
+ {
+ /* end of file: flush the decoder and take what is left */
+ avcodec_send_packet (ctx, NULL);
+ }
+ else if (pkt->stream_index != stream)
+ {
+ av_packet_unref (pkt);
+ continue;
+ }
+ else if (0 != avcodec_send_packet (ctx, pkt))
+ {
+ av_packet_unref (pkt);
+ continue;
+ }
+ av_packet_unref (pkt);
+ while (0 == avcodec_receive_frame (ctx, frame))
+ {
+ double ts;
+
+ frames++;
+ ts = (AV_NOPTS_VALUE == frame->best_effort_timestamp)
+ ? -1.0
+ : frame->best_effort_timestamp * av_q2d (tb);
+ if ( (ts < target) &&
+ (frames < MAX_FRAMES) )
+ {
+ /* not there yet; hold on to it in case we never arrive */
+ av_frame_unref (last);
+ if (0 == av_frame_ref (last, frame))
+ have_last = 1;
+ av_frame_unref (frame);
+ continue;
+ }
+ if ( (retries < MAX_FLAT_RETRIES) &&
+ is_flat (frame) )
+ {
+ /* a black leader or a fade-in; try a bit further in */
+ retries++;
+ target = ((ts < 0.0) ? target : ts) + FLAT_FRAME_SKIP;
+ av_frame_unref (last);
+ if (0 == av_frame_ref (last, frame))
+ have_last = 1;
+ av_frame_unref (frame);
+ continue;
+ }
+ av_frame_unref (last);
+ av_frame_free (&last);
+ av_packet_free (&pkt);
+ return 0;
+ }
+ if (0 != r)
+ break; /* end of file, and the decoder had nothing left either */
+ }
+ if (have_last)
+ {
+ av_frame_unref (frame);
+ if (0 == av_frame_ref (frame, last))
+ ret = 0;
+ }
+ av_frame_unref (last);
+ av_frame_free (&last);
+ av_packet_free (&pkt);
+ return ret;
+}
+
+
+/**
+ * Main method for the ffmpeg-thumbnailer plugin.
+ *
+ * @param ec extraction context
+ */
+void
+EXTRACTOR_thumbnailffmpeg_extract_method (struct EXTRACTOR_ExtractContext *ec);
+
+void
+EXTRACTOR_thumbnailffmpeg_extract_method (struct EXTRACTOR_ExtractContext *ec)
+{
+ struct EXTRACTOR_MediaOptions opt;
+ struct EXTRACTOR_FFmpegIO io;
+ AVCodecContext *ctx = NULL;
+ AVFrame *frame = NULL;
+ uint8_t *thumbnail = NULL;
+ size_t thumbnail_size = 0;
+ double target;
+ int stream;
+
+ EXTRACTOR_media_options_parse_ (ec->config,
+ &opt);
+ EXTRACTOR_media_deadline_start_ (&io.deadline,
+ opt.deadline);
+ io.ec = ec;
+ if (! EXTRACTOR_media_is_media_ (ec, 0))
+ return;
+ if (0 != EXTRACTOR_ffmpeg_open_ (&io))
+ return;
+ stream = EXTRACTOR_ffmpeg_open_stream_ (&io,
+ AVMEDIA_TYPE_VIDEO,
+ &ctx);
+ if (0 > stream)
+ goto cleanup;
+ if ( (ctx->width <= 0) ||
+ (ctx->height <= 0) ||
+ (ctx->width > EXTRACTOR_MEDIA_MAX_DIMENSION) ||
+ (ctx->height > EXTRACTOR_MEDIA_MAX_DIMENSION) ||
+ (((int64_t) ctx->width) * ctx->height > EXTRACTOR_MEDIA_MAX_PIXELS) )
+ goto cleanup;
+ target = EXTRACTOR_ffmpeg_seek_ (&io,
+ stream,
+ ctx,
+ opt.offset);
+ if (NULL == (frame = av_frame_alloc ()))
+ goto cleanup;
+ if (0 != decode_frame_at (&io,
+ ctx,
+ stream,
+ target,
+ frame))
+ goto cleanup;
+ if (0 != encode_thumbnail (frame,
+ &opt,
+ &thumbnail,
+ &thumbnail_size))
+ goto cleanup;
+ ec->proc (ec->cls,
+ "thumbnailffmpeg",
+ EXTRACTOR_METATYPE_THUMBNAIL,
+ EXTRACTOR_METAFORMAT_BINARY,
+ opt.png ? "image/png" : "image/jpeg",
+ (const char *) thumbnail,
+ thumbnail_size);
+cleanup:
+ if (NULL != thumbnail)
+ av_free (thumbnail);
+ if (NULL != frame)
+ av_frame_free (&frame);
+ if (NULL != ctx)
+ avcodec_free_context (&ctx);
+ EXTRACTOR_ffmpeg_close_ (&io);
+}
+
+
+/**
+ * Keep the diagnostics of the media framework out of the output of the
+ * calling application; a decoder complaining about a broken file is not
+ * something the application asked to see.
+ *
+ * ("force-kill" would be tempting here, to get a fresh process for every
+ * file, but the host cannot know about that special without loading the
+ * plugin in-process: it only finds out that the child is gone once it has
+ * already handed it the next file, which is then silently lost.)
+ *
+ * @return special options for this plugin
+ */
+const char *
+EXTRACTOR_thumbnailffmpeg_options (void);
+
+const char *
+EXTRACTOR_thumbnailffmpeg_options ()
+{
+ return "close-stderr";
+}
+
+
+/**
+ * Silence FFmpeg's logging.
+ */
+void __attribute__ ((constructor))
+thumbnailffmpeg_init (void);
+
+void __attribute__ ((constructor))
+thumbnailffmpeg_init ()
+{
+ EXTRACTOR_ffmpeg_silence_ ();
+}
+
+
+/* end of thumbnailffmpeg_extractor.c */
diff --git a/src/plugins/thumbnailgst_extractor.c b/src/plugins/thumbnailgst_extractor.c
@@ -0,0 +1,224 @@
+/*
+ This file is part of libextractor.
+ Copyright (C) 2026 Vidyut Samanta and Christian Grothoff
+
+ libextractor is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published
+ by the Free Software Foundation; either version 3, or (at your
+ option) any later version.
+
+ libextractor 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
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with libextractor; see the file COPYING. If not, write to the
+ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+ */
+/**
+ * @file plugins/thumbnailgst_extractor.c
+ * @author Christian Grothoff
+ * @brief this extractor produces a binary encoded thumbnail of a frame
+ * taken from a video, using GStreamer
+ *
+ * This is the GStreamer counterpart of thumbnailffmpeg: it seeks about
+ * 30s into a video and encodes the frame it finds there as a small JPEG
+ * (or PNG). Which of the two is built depends on what was found at
+ * configure time; if both are installed, both will report a thumbnail and
+ * it is up to the application to disable one of them in its plugin
+ * configuration string.
+ *
+ * The decoders and the image encoder are themselves GStreamer plugins,
+ * resolved at run time, so a missing "jpeg" or "playback" plugin cannot
+ * be detected when libextractor is built. We simply report nothing.
+ */
+#include "platform.h"
+#include "extractor.h"
+#include "mediautil.h"
+#include "mediagst.h"
+
+#include <gst/video/video.h>
+
+
+/**
+ * Scale and encode the raw video @a sample.
+ *
+ * @param sample raw frame to convert
+ * @param opt plugin options (target size and format)
+ * @param timeout how long the conversion may take, in nanoseconds
+ * @return the encoded image, NULL on error
+ */
+static GstSample *
+encode_thumbnail (GstSample *sample,
+ const struct EXTRACTOR_MediaOptions *opt,
+ GstClockTime timeout)
+{
+ GstCaps *caps;
+ GstCaps *to_caps;
+ GstStructure *s;
+ GstSample *out;
+ gint width;
+ gint height;
+ unsigned int tw;
+ unsigned int th;
+
+ if (NULL == (caps = gst_sample_get_caps (sample)))
+ return NULL;
+ if (NULL == (s = gst_caps_get_structure (caps, 0)))
+ return NULL;
+ if ( (! gst_structure_get_int (s, "width", &width)) ||
+ (! gst_structure_get_int (s, "height", &height)) ||
+ (0 >= width) ||
+ (0 >= height) )
+ return NULL;
+ if (! EXTRACTOR_media_thumbnail_size_ ((unsigned int) width,
+ (unsigned int) height,
+ opt->size,
+ &tw,
+ &th))
+ return NULL;
+ to_caps = gst_caps_new_simple (opt->png ? "image/png" : "image/jpeg",
+ "width", G_TYPE_INT, (gint) tw,
+ "height", G_TYPE_INT, (gint) th,
+ NULL);
+ out = gst_video_convert_sample (sample,
+ to_caps,
+ timeout,
+ NULL);
+ gst_caps_unref (to_caps);
+ return out;
+}
+
+
+/**
+ * Main method for the gstreamer-thumbnailer plugin.
+ *
+ * @param ec extraction context
+ */
+void
+EXTRACTOR_thumbnailgst_extract_method (struct EXTRACTOR_ExtractContext *ec);
+
+void
+EXTRACTOR_thumbnailgst_extract_method (struct EXTRACTOR_ExtractContext *ec)
+{
+ struct EXTRACTOR_MediaOptions opt;
+ struct EXTRACTOR_MediaDeadline deadline;
+ struct EXTRACTOR_GstIO io;
+ GstElement *pipeline;
+ GstElement *sink;
+ GstSample *sample = NULL;
+ GstSample *thumbnail = NULL;
+ GstBuffer *buffer;
+ GstMapInfo mi;
+ GstClockTime timeout;
+ gint64 duration;
+ gint64 target;
+
+ EXTRACTOR_media_options_parse_ (ec->config,
+ &opt);
+ EXTRACTOR_media_deadline_start_ (&deadline,
+ opt.deadline);
+ if (! EXTRACTOR_media_is_media_ (ec, 0))
+ return;
+ if (! gst_is_initialized ())
+ gst_init (NULL, NULL);
+ sink = gst_element_factory_make ("fakesink", NULL);
+ if (NULL != sink)
+ g_object_set (sink,
+ "sync", FALSE,
+ NULL);
+ if (NULL == (pipeline =
+ EXTRACTOR_gst_playbin_ (&io,
+ ec,
+ EXTRACTOR_GST_PLAY_FLAG_VIDEO,
+ sink,
+ gst_element_factory_make ("fakesink",
+ NULL))))
+ return;
+ if (GST_STATE_CHANGE_FAILURE ==
+ gst_element_set_state (pipeline,
+ GST_STATE_PAUSED))
+ goto cleanup;
+ if (! EXTRACTOR_gst_wait_async_ (pipeline,
+ EXTRACTOR_media_deadline_remaining_ (
+ &deadline) * GST_MSECOND))
+ goto cleanup;
+ target = EXTRACTOR_gst_sample_position_ (pipeline,
+ opt.offset,
+ &duration);
+ if (target >= GST_SECOND)
+ {
+ /* a key frame is good enough for a thumbnail and much cheaper than
+ decoding forward to an exact position */
+ if (gst_element_seek_simple (pipeline,
+ GST_FORMAT_TIME,
+ GST_SEEK_FLAG_FLUSH
+ | GST_SEEK_FLAG_KEY_UNIT,
+ target))
+ (void) EXTRACTOR_gst_wait_async_ (pipeline,
+ EXTRACTOR_media_deadline_remaining_ (
+ &deadline) * GST_MSECOND);
+ }
+ g_object_get (sink,
+ "last-sample",
+ &sample,
+ NULL);
+ if (NULL == sample)
+ goto cleanup;
+ timeout = EXTRACTOR_media_deadline_remaining_ (&deadline) * GST_MSECOND;
+ if (0 == timeout)
+ goto cleanup;
+ if (NULL == (thumbnail = encode_thumbnail (sample,
+ &opt,
+ timeout)))
+ goto cleanup;
+ if (NULL == (buffer = gst_sample_get_buffer (thumbnail)))
+ goto cleanup;
+ if (! gst_buffer_map (buffer, &mi, GST_MAP_READ))
+ goto cleanup;
+ if ( (0 < mi.size) &&
+ (EXTRACTOR_MEDIA_MAX_THUMBNAIL >= mi.size) )
+ ec->proc (ec->cls,
+ "thumbnailgst",
+ EXTRACTOR_METATYPE_THUMBNAIL,
+ EXTRACTOR_METAFORMAT_BINARY,
+ opt.png ? "image/png" : "image/jpeg",
+ (const char *) mi.data,
+ mi.size);
+ gst_buffer_unmap (buffer, &mi);
+cleanup:
+ if (NULL != thumbnail)
+ gst_sample_unref (thumbnail);
+ if (NULL != sample)
+ gst_sample_unref (sample);
+ gst_element_set_state (pipeline,
+ GST_STATE_NULL);
+ gst_object_unref (pipeline);
+}
+
+
+/**
+ * Keep the diagnostics of the media framework out of the output of the
+ * calling application; a decoder complaining about a broken file is not
+ * something the application asked to see.
+ *
+ * ("force-kill" would be tempting here, to get a fresh process for every
+ * file, but the host cannot know about that special without loading the
+ * plugin in-process: it only finds out that the child is gone once it has
+ * already handed it the next file, which is then silently lost.)
+ *
+ * @return special options for this plugin
+ */
+const char *
+EXTRACTOR_thumbnailgst_options (void);
+
+const char *
+EXTRACTOR_thumbnailgst_options ()
+{
+ return "close-stderr";
+}
+
+
+/* end of thumbnailgst_extractor.c */