What it does
I built this as an on-camera video encoder for Axis cameras. It takes a sequence of JPEG images already stored on the camera's SD card and turns them into an H.264 MP4. The interesting part is that the images never leave the camera during encoding. Everything happens on the device itself.
The application is written in C and packaged as an Axis ACAP application. FFmpeg and x264 are cross-compiled and included in the package, so there's no need to install them separately on the camera.
A companion application sends an authenticated FastCGI request to start an encoding job and then checks the status files to see how it's progressing. The request returns immediately rather than waiting for the video to finish. Before starting anything, the encoder validates the paths and frame parameters, then launches FFmpeg as a background process. The MP4 only gets its final filename once FFmpeg has finished successfully.
Working within the camera's limits
The camera is doing other work besides encoding video, so I couldn't just let FFmpeg use whatever resources it wanted. Frame capture still needs CPU and memory, and the encoder shouldn't get in its way.
I ran FFmpeg at nice +10, set oom_score_adj=1000, and limited x264 to three threads. With those settings, peak memory usage was around 145 MB and encoding ran at 2.9× realtime on 1080p footage. FFmpeg's default behaviour used between 262 and 803 MB in the comparisons. That difference matters on a camera where encoding is only one of several things happening at once.
I also spent time on output handling. A failed encode shouldn't leave behind a file that looks like a finished video. FFmpeg writes to a temporary .part file, and the encoder renames it to <job>.mp4 in the same directory only after a clean exit with status 0. Retries remove stale output first, while cancellation, process termination and power loss leave the temporary file and a .done status containing either signal=N or exit=N.
Frame timing caused another issue. The original manifest used rounded frame durations, which meant neighbouring frames could end up with the same timestamp. That resulted in roughly one frame being dropped for every six frames. I replaced that timing approach with setpts=N/(fps*TB), so each frame gets a timestamp based on its actual position in the sequence: frame N is placed at N/fps.
Building FFmpeg for the job
Using a full FFmpeg build would have brought in a lot of functionality this application doesn't need. I cross-compiled a pinned release, verified its SHA-256 checksum, and built only the components required for this workflow: the concat and image2 demuxers, MJPEG decoder, libx264 encoder, MP4 muxer and scale filter.
Networking and unrelated functionality are compiled out. This keeps the packaged binary focused on processing the images already stored on the camera rather than providing a general-purpose media tool.
Packaging also needed some attention because FFmpeg and x264 are GPL-licensed components. The release packages include their corresponding source code alongside the binaries.
GitHub Actions builds both supported ARM architectures on every push, and tagging a release publishes the resulting .eap packages. That gives the project a repeatable build and release process instead of relying on manually preparing binaries for each camera architecture.
Testing on actual hardware
Both architectures build locally and in CI. During development, I also measured the behaviour on real Axis camera hardware, including frame timing, memory consumption, process priority and lock handling.
There isn't an automated test suite. The most important behaviour depends on the camera itself, so a successful CI build only tells me that the application compiles and packages correctly. It doesn't prove that encoding behaves properly on the device. For this project, I needed to run it on actual hardware and measure what happened.