Skip to content

feat: add a native extension API and an FFmpeg-based media module - #291

Open
devopvoid wants to merge 7 commits into
mainfrom
feat/native-extension-api
Open

devopvoid wants to merge 7 commits into
mainfrom
feat/native-extension-api

Conversation

@devopvoid

Copy link
Copy Markdown
Owner

Closes #77.

Sends a media file over a peer connection in place of a camera and a microphone, and adds the native extension API that makes it possible without carrying every frame through Java.

The extension API

A small, versioned C function table in webrtc-jni (webrtc_java_api.h) that a native extension library calls directly. NativeApi.tableAddress() hands out its address and NativeApi.handleOf(source) the native handle of a CustomVideoSource or CustomAudioSource, with dev.onvoid.webrtc.internal qualified-exported to webrtc.java.media.

There is no link-time dependency between the two native libraries, which is the point: NativeLoader extracts to a temporary file with a generated name, so there is nothing an extension could link against. Any future extension — GStreamer, a capture SDK, a hardware encoder — can use the same table.

CustomVideoSource::PushFrame and CustomAudioSource::PushAudioData now take a capture time, exposed as pushFrame(VideoFrame, long) and pushAudio(..., long). Existing callers that pass no timestamp keep the old behaviour of being stamped with the source's own clock.

Reading VideoStreamEncoder::OnFrame changed one detail of the design worth recording: the encoder overwrites a frame's RTP timestamp with 90 * ntp_time_ms and drops any frame whose NTP capture time does not advance. The field that carries a caller's timing to the wire is therefore the NTP capture time, not rtp_timestamp.

The media module

webrtc-java-media, a JPMS module (webrtc.java.media) with its own native library, which decodes with FFmpeg and feeds the custom media sources through the table above. Nothing goes through Java: one thread demuxes and decodes, a pacer delivers in real time.

  • Zero copy where it counts. A decoder that already produced yuv420p — almost all 8-bit H.264, VP8, VP9 and MPEG-4 — has its AVFrame handed on by reference and dropped again in the release callback once WebRTC lets go of the pixels. Anything else goes through swscale first.
  • Audio in the only shape WebRTC takes. Resampled to interleaved 16-bit PCM at 48 kHz, mono or stereo, and re-cut into exact 10 ms chunks whose timestamps advance in exact 10 ms steps, so the audio is regular whatever the container's packet timing was.
  • One clock mapping, kept. Presentation times are mapped onto the WebRTC clock once, so what reaches the receiver carries the timing of the file rather than of a thread, and audio stays lined up with video however much the pacing thread is jostled.
  • Bounded queues. Decoding runs ahead only as far as they allow, so a whole file is never decoded into memory.
  • Play, pause, seek and looping. Looping keeps the mapping and adds the source's duration to every timestamp, which hides the seam and keeps capture times rising as the encoder requires.

Java API: MediaReader, MediaInfo, MediaPlayer, MediaPlayerListener, MediaPlayerState, and MediaFileSource as the turnkey class the issue asked for.

MediaFileSource source = new MediaFileSource(Path.of("movie.mp4"));

VideoTrack videoTrack = factory.createVideoTrack("video", source.getVideoSource());
AudioTrack audioTrack = factory.createAudioTrack("audio", source.getAudioSource());

peerConnection.addTrack(videoTrack, List.of("stream"));
peerConnection.addTrack(audioTrack, List.of("stream"));

source.play();

Two departures from the original design

  • One demux-and-decode thread, not a decode thread per stream. Four threads and four queues solve a problem this does not have: decoding one file runs far ahead of real time, and the pacer's bounded queues already absorb decode jitter. Splitting the decoders out later is a local change behind the same queues.
  • Video and audio are paced independently rather than as one ordered stream. They go to different sources and need no ordering between them. It also removes a starvation the ordered version would have had: with one queue full and the other empty, a pacer waiting on the empty one would never drain the full one, and the decode thread blocked on the full one would never fill the empty one.

Verified

Measured on the committed test asset — three seconds of 320x240 VP8 at 15 fps and 48 kHz mono Opus — observed at the sinks of real tracks made from the custom sources:

  • 45 video frames at 320x240, and 300 audio chunks of 480 frames at 48 kHz mono
  • 3001 ms to play 3.008 s of media, so the real-time pacing is exact

19 tests in the media module cover the frame and chunk counts, the shape the media arrives in, real-time pacing, looping, pause, seek, a missing source, and the reader being spent once a player adopts it. They pass under -Xcheck:jni with no warnings at all — not just no fatal errors — including the observer callbacks, which run on the player's own thread and attach to the JVM to make a call. mvn verify on the webrtc module stays green at 168 tests.

FFmpeg build and licensing

Built from the third-party/ffmpeg submodule pinned to n7.1.1, LGPL only: no --enable-gpl, no --enable-nonfree, --disable-everything and then explicit lists of just what this module plays. FFmpeg is linked dynamically and ships as separate files inside the platform jar, so its libraries can be replaced, which is what the LGPL asks. The wrapper code stays Apache-2.0.

Bringing the build up on Windows corrected three things, all of which would have bitten anyone else trying it:

  • find_program(bash) turns up Git for Windows' bash, which carries neither make nor nasm. MSYS2 is now looked for by path, with MSYS2_ROOT as an override.
  • configure probes the compiler, so the shell has to be entered through vcvars, and inside it the MSVC toolchain has to precede /usr/bin: MSYS2 ships a coreutils link that otherwise shadows MSVC's linker and makes configure conclude the compiler cannot create executables. This is why configure, make and make install are one generated script run once rather than three execute_process calls.
  • An MSVC build installs the import libraries into bin/ next to the DLLs, leaving only the .def files in lib/.

Docs and example

docs/guide/media/media-files.md with a sidebar entry and a section on the examples page, plus a runnable MediaFileExample. Writing the example is what turned up that webrtc-java-media declared no dependency on its own classifier artifact, so depending on the module got the Java classes and none of the native libraries.

Not done yet, deliberately

  • FFmpeg has been built for windows-x86_64 only. The other six targets and the CI caching by FFmpeg tag and platform are next.
  • The module is opt-in, behind the with-media-extension profile, and stays that way until every platform builds in CI.
  • Only local files play: file and pipe protocols. Network sources need no new code, only the protocols turned on in the build.
  • AdaptFrame on pushed video is deferred, because applying encoder resolution and frame-rate requests means cropping and scaling the buffer, which would change behaviour for existing pushFrame callers.

A factory fed by this module is sending pushed audio and cannot also send audio captured by its AudioDeviceModule, which is the same constraint as #289; the docs say so in both places.

Groundwork for the FFmpeg based media module (#77), which decodes a file
natively and must get the frames into WebRTC. Carrying every frame and every
10 ms chunk through Java would mean a copy per frame, Java in the loop of a
100 Hz audio path, and no way to ever pass through encoded media.

An extension library now calls into this library through a small C function
table instead. NativeApi.tableAddress() hands out the address of a
struct webrtc_java_api, NativeApi.handleOf() the address of the native object
behind a CustomVideoSource or CustomAudioSource, and the extension pushes
straight into that source. The table's video push wraps the caller's planes
rather than copying them and releases them through a callback, so a decoder
that already produces I420 reaches the encoder without a copy.

Neither native library links against the other, which is what makes this
work: NativeLoader extracts to a temporary file with a generated name, so
there is nothing to link to. NativeLoader also learns to load a library
together with the shared libraries it needs, under their real file names, so
that a dynamic linker finds them as the same modules.

Both sources can now be given the time a frame or chunk was captured, and the
Java API exposes that as pushFrame(VideoFrame, long) and pushAudio(..., long),
with SyncClock.currentTimeUs() as the clock they are read in. This is what
makes a media file play back at its own frame rate: the encoder derives the
outgoing RTP timestamp from the frame's NTP capture time, so a caller that
supplies presentation times keeps the sent timeline free of the jitter of the
pushing thread, and keeps audio and video lined up. Pushing without a
timestamp behaves exactly as before.

Refs #77
Scaffolding for the media extension (#77): the Maven module, the JPMS module
webrtc.java.media, the CMake build of the native library, and the CMake build
of FFmpeg itself from the third-party/ffmpeg submodule, which is now tracked
in .gitmodules pinned to n7.1.1 and marked shallow.

FFmpeg is configured LGPL only, without --enable-gpl and without
--enable-nonfree, as shared libraries so an application can replace them, and
with everything disabled except the containers, decoders, parsers and the file
protocol this module plays. It installs into ffmpeg.install.dir and a later
configure reuses it, the way the WebRTC checkout is reused.

The native library links against FFmpeg's C ABI and the JVM only. It reaches
WebRTC through the function table of webrtc_java_api.h at runtime, so it needs
neither WebRTC's headers nor its C++ standard library, and the module builds
with the platform's default toolchain.

The module stays out of the default reactor behind the with-media-extension
profile until it builds on every platform in CI: building FFmpeg needs make,
nasm and pkg-config, which the rest of the project does not.

What is here so far is a vertical slice rather than the decoder: FFmpeg.version()
and FFmpeg.license() prove that the libraries were built, packaged, loaded in
dependency order and can be called. MediaInfo is the shape the reader will
report. The Java side compiles against the qualified export of
dev.onvoid.webrtc.internal; the native side has not been built on any platform
yet, so the FFmpeg configure line and the packaging are still unproven.

Refs #77
Bringing up windows-x86_64 turned up three things the script had wrong.

find_program(bash) picked Git for Windows' bash, which carries neither
make nor nasm. MSYS2 is now looked for by path first, with MSYS2_ROOT as
an override, and make and nasm are checked for inside it rather than with
find_program, since they are only visible from its own shell.

FFmpeg's configure probes the compiler, so the shell has to be entered
through vcvars, located with vswhere. Inside it the MSVC toolchain
directory has to precede /usr/bin: MSYS2 ships a coreutils link that
otherwise shadows MSVC's linker, and configure then reports that the C
compiler cannot create executables. configure, make and make install are
therefore written out as one generated shell script and run once, instead
of three execute_process calls, because all three have to share that one
shell.

An MSVC build of FFmpeg installs the import libraries into bin/ next to
the DLLs and leaves only the .def files in lib/, so find_library searches
both.

FFmpeg n7.1.1 now builds from the submodule in about 140 seconds, and
FFmpeg.version() and FFmpeg.license() report n7.1.1 and LGPL version 2.1
or later from the packaged platform jar.
MediaReader opens anything libavformat accepts and selects the video and
audio stream that are meant to be played, which gives MediaInfo the
native code behind it: duration, video size, frame rate and codec, audio
sample rate, channel count and codec. A source holding neither video nor
audio fails to open rather than handing back an empty reader, and a
failure carries the message FFmpeg gave for it instead of a bare number.

This module builds its own natives, so unlike webrtc they are not a jar
its tests could resolve. Surefire gets the staging directory the CMake
build collects them into on the test class path instead, since
NativeLoader looks the libraries up as class path resources and a
directory serves them as well as a jar. The jni-check profile mirrors
the one in the webrtc module.

The test asset is three seconds of 320x240 VP8 and 48 kHz mono Opus in
WebM, 61 KB. Six tests cover the video, audio and duration it reports, a
missing source, closing twice and using a closed reader; they pass under
-Xcheck:jni with no warnings.
This is the part of the module that actually sends a file. Nothing goes
through Java: one thread demuxes and decodes, a pacer delivers in real
time, and both talk to webrtc-java through the C function table rather
than over JNI.

VideoDecoder hands a frame on untouched when the decoder already gave
yuv420p, which is almost all 8-bit H.264, VP8, VP9 and MPEG-4 content,
so the decoded picture reaches the encoder without a copy. What the
pacer hands over is a reference to that AVFrame, dropped again in the
release callback once WebRTC lets go of the pixels. Anything else goes
through swscale first. AudioDecoder resamples to the only shape WebRTC
takes, interleaved 16-bit PCM at 48 kHz in mono or stereo, and re-cuts
it into exact 10 ms chunks, advancing their timestamps in 10 ms steps so
the audio is regular whatever the container's packet timing was.

MediaPacer makes one mapping from the source's presentation times onto
the WebRTC clock and keeps it, so what reaches the receiver carries the
timing of the file rather than of a thread. Video and audio are paced
independently: they go to different sources, need no ordering between
them, and keeping them apart is what stops a full video queue starving
audio. The queues are bounded, so decoding runs ahead only as far as
they allow and a whole file is never decoded into memory.

Looping keeps the mapping and adds the source's duration to every
timestamp, which both hides the seam and keeps capture times rising, as
the encoder requires. A seek drops what is queued and starts a fresh
mapping.

MediaFileSource is the short way to all of it, and the one the README
already described: it opens a source, makes a custom source for each
kind of media the file has, and wires a player to feed them.

Nineteen tests cover this where an application would see it, on the
sinks of tracks made from those sources: frame and chunk counts, the
320x240 and 48 kHz mono shape they arrive in, that three seconds of
media takes three seconds to play, looping, pause, seek, and the reader
being spent once a player adopts it. They pass under -Xcheck:jni with no
warnings. The test asset gained a keyframe every second, because seeking
lands on a keyframe and it had only the one at the start.
webrtc-java-media declared no dependency on its own classifier artifact,
so a project depending on the module got the Java classes and none of
the native libraries, and failed at class-initialization time with
"Native library 'avutil-59.dll' is not on the classpath".

It now carries them the same way webrtc-java carries its own, through a
dependency on itself with the platform classifier. Running the new
example is what turned this up: it is the first thing to consume the
module the way an application would, rather than from inside its own
build where the staging directory is already on the class path.
The guide covers adding the module, sending a file with MediaFileSource,
reading a source with MediaReader, controlling playback, following it
with a listener, and driving your own media sources with MediaPlayer. It
says plainly what can be played, that only local files work so far, and
that the module is opt-in and built for windows-x86_64 only, so nobody
plans around something that is not there yet.

MediaFileExample is the same story as code: it opens a file, reports
what it contains, makes tracks, adds them to a peer connection and
follows playback to the end.

Building it needs care, because the examples are a JPMS module and the
media module is opt-in. A "requires" cannot be made conditional, and a
descriptor naming a module that is not there fails the whole compile, so
the example lives in a source directory of its own and is compiled by
its own execution into an output directory of its own. That keeps its
module descriptor off the sourcepath, leaves the modular build of the
other examples untouched, and means a build without the profile does not
see the example at all. exec-maven-plugin is pointed at that directory
so the command the docs give works as written. When the media module
joins the reactor by default, all of it collapses into one dependency
and one "requires".
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streaming a video file instead of webcam

1 participant