Plex Has No Overlay Feature: How I Wrapped the Transcoder to Burn Text, Logos, and Trivia Into Streams
The problem: Plex has no overlay feature
Plex will transcode a movie six ways from Sunday, but it gives you exactly zero ways to paint anything onto the stream yourself. No overlay API. No branding option. No "burn this text into the video." Want a watermark? A startup message? Trivia? There is no Plex feature for it, at all.
There is a seam though. Every transcode Plex performs, it performs by shelling out to its own bundled ffmpeg, installed inside the container as a binary called Plex Transcoder. That binary is just a file on disk. And a file on disk can be replaced with a program that looks at the arguments, adds a few filters of its own, and calls the real binary.
That is the entire trick. Everything below grew out of it: the "am I actually being transcoded?" banner, the corner logo, a message queue, and finally box-office trivia burned into the first ten seconds of every movie in my library.
One thing worth saying up front: the wrapper originally existed for audio, not video. Its first job was night-mode dynamic range compression (a compand curve plus a limiter injected into every audio transcode, because Plex has volume leveling for music and nothing for movies). The video overlay work is the same plumbing pointed at a different stream. I'm skipping the audio filter itself and focusing on how the overlays get made, because that's the part nobody documents.
The seam: wrap the transcoder binary
The install is a rename and a replacement:
- The real binary stays untouched, renamed to
Plex Transcoder.real. - A bash script takes over the name
Plex Transcoder. - A container init script reinstalls the wrapper at every container start, and detects a fresh binary after a Plex image update. The giveaway is simple: the real transcoder is an ELF binary, the wrapper is a script starting with
#!.
Two details that sound trivial and are not:
- You cannot cat-overwrite the binary during a live transcode. A running transcoder holds the inode and the write fails with
Text file busy. The init script writes the new copy to a temp file and force-renames it over the target instead. Rename does not care about busy inodes. - Any wrapper bug is a playback bug. If the shim dies, the stream dies with it. So the shim's default posture is paranoid pass-through: when it doesn't recognize the command shape, it execs the real binary with the original arguments untouched.
Reading Plex's command line
Plex calls its transcoder with generated ffmpeg arguments, and the arguments come in two shapes:
- Simple: audio mapped directly,
-afand-vfterritory. - DASH/fMP4: what the Xbox uses for quality-limited playback. The audio stream is fed through its own
-filter_complexchain, labeled and mapped like[0:#0x02] aresample=...[2]then-map "[2]".
The first lesson in this section cost me a real failed playback: you cannot mix simple and complex filtering for the same stream. Adding -af to a command that already carries a -filter_complex chain for that stream is fatal. The error is exactly what it says, Simple and complex filtering cannot be used together, exit 234, dead session. The compressor has to be merged INTO the existing chain, before its output label.
That idea, extend the chain that already exists and keep every label identical, is the same idea the overlays reuse later. Nothing gets re-mapped, nothing gets re-muxed, the rest of the command can't tell anything happened.
The pass-through rules the wrapper checks before touching anything: no audio encoder on any output (copy jobs), an -af already present, a label map with no matching chain, or the last argument being a flag. Any of those, leave the command alone. Plex's pre-input -codec:#0x0N hex decoder hints get deliberately ignored.
Burning the TRANSCODED banner
The first overlay was a probe: I wanted to know, by looking at the screen, whether the video I'm watching is being re-encoded right now. Direct play and direct stream never spawn the transcoder at all, so anything burned into transcoder output is automatically a transcode-only signal. Overlay visible means video transcoded.
The banner text is free, it's sitting in the invocation. scale=w=654:h=480 and -maxrate:0 1236k are right there in the arguments. The wrapper parses those and writes a per-process ASS subtitle file:
[Script Info]
PlayResX: 640
PlayResY: 360
WrapStyle: 2
[V4+ Styles]
Style: OV,DejaVu Sans,30,&H0000FFFF,&H000000FF,&H00000000,&H64000000,1,0,0,0,100,100,0,0,3,5,0,2,10,10,16,1
[Events]
Dialogue: 0,0:00:12.00,0:00:22.00,OV,,0,0,0,,TRANSCODED 654x480 1236kThen it appends exactly one filter to the video chain that's already in the graph:
format=pix_fmts=yuv420p|nv12,subtitles=f=/tmp/plex-overlay-4242.ass:fontsdir=/config/fonts[plxv]Why subtitles and not drawtext: this Plex fork has no drawtext filter. It ships freetype, fontconfig, and libass, but drawtext is absent, so the subtitles filter is the only text-render path in the entire binary. You write an ASS file, point the filter at it with a fontsdir, and libass does the rest.
Two font gotchas, both of which fail silently if you miss them:
- The container ships no
/etc/fontsat all, and fontconfig refuses to initialize without it. A minimalfonts.confgoes next to the fonts. - The font itself (DejaVu Sans Bold, copied in as
plex-overlay.ttf) lives in/config/fonts/, and the filter finds it viafontsdir, not the system font path.
PlayRes 640x360 is the trick that makes it size-independent: the same ASS renders proportionally at every output tier the client asks for. 654x480, 436x320, 160x116, same banner, right size, bottom-center, yellow on black, every time.

The TRANSCODED banner burned into a real transcode: overlay visible means video transcoded
Stream time, not wall time
An always-on overlay would get old fast, so the windows are driven from inside the ffmpeg arguments. The invocation carries -ss (where the stream starts) and -copyts, which means output timestamps begin at the seek position instead of zero. Stream time t inside a filter is therefore the position in the movie, not the time since the transcoder started.
Both overlays use enable='between(t,S,E)' with S set to the seek position: the banner shows for PLEX_OVERLAY_SECONDS (10 seconds here) from the start of the stream, the logo for PLEX_LOGO_SECONDS (60 seconds).
The side effect is a feature. Every seek and every quality change spawns a fresh transcoder, and every fresh transcoder replays the banner and the logo. That is exactly the behavior a "you are being transcoded" probe should have. Setting PLEX_OVERLAY_SECONDS=0 turns the banner off entirely, same as removing the font file.
The logo, and how a still PNG can kill a stream
Text was the easy half. A logo is an image, and images composite with movie plus overlay, merged into the same graph the banner already edited:
[0:#0x01]scale=...[0];[0]format=pix_fmts=yuv420p|nv12,subtitles=...[plxv];
movie=/config/plex-logo.png,scale=h=62,format=rgba[plxg];
[plxv][plxg]overlay=x=W-w-24:y=24:eof_action=repeat:enable='between(t,4165,4225)'[1]Everything in those three lines was learned the hard way:
- Same graph, labels preserved. The banner chain now ends in
[plxv], the logo chain feeds[plxg], and the overlay writes to the original final label[1]. Nothing downstream needs a-mapedit, because as far as the rest of the command can tell, nothing happened. - eof_action=repeat is load-bearing. A still PNG fed through
movieis a one-frame movie. It plays for one frame and it's done. The overlay filter's default when a secondary input ends is to end the whole stream, which would mean: logo appears for one frame, video dies. Expliciteof_action=repeatholds the last frame instead. Measured: 1,700+ stream-seconds of video encoded past the logo's EOF with that set. - Sized by tier.
PLEX_LOGO_H=0means auto: 13% of the output height, clamped between 20 and 64 px. That's 62 px at 480p, 41 at 320p, 20 at the 160x116 floor. - Swappable with no reinstall. The filter string references a path, the path is read fresh on every transcode. Change the PNG file, the logo changes. No restart, no reinstall, nothing.
x=W-w-24:y=24 puts it top-right with a 24-pixel margin. The current PNG is a red "LOGO TEST" placeholder, because the point of the logo was the mechanism, not the art.
The message queue
Once text could be burned on a schedule, the next want was obvious: arbitrary messages. "Server backup in 5 minutes." "Yes, the network is down, I know."
The mechanism is a spool file, /config/notifications/queue.txt, one message per line. The wrapper reads it at every transcoder spawn, word-wraps each message to 60 characters, and shows one line at a time (5 seconds per line by default, 10 on my server now) starting at the stream-time window. The box disappears when the last line ends. Consumed messages archive to sent.txt as an audit trail; spill past the 90-line cap stays queued for the next spawn.
Reading at spawn isn't a lazy design choice, it's forced. The subtitles filter loads its ASS file once at filter init, so true mid-stream injection does not exist in this fork. I've tried to fake it several ways, including killing the transcoder mid-stream and racing Plex's respawn. The server-side session recovers every time, and the client-side stream dies every time. That's its own saga for another post.
The queue also taught me about a Plex quirk worth knowing: the first spawn after a restart or seek is a throwaway. It starts, does some setup, reads the queue, and dies a couple of seconds later, and the real spawn comes next with a nearly identical command. If the throwaway consumed your message, the message is gone. So the wrapper renders the message, writes a pending file, and requeues the snapshot; a small watcher confirms the consumption only after the spawn has been alive 20 seconds. Dead spawns just drop their pending file and the message lives on for the real one.
Extending it: box-office trivia on every movie
With the overlay machinery proven, the Friday-night idea showed up: what if every movie opened with its own trivia? "Made $391M on a $19M budget (20.6x return)" for Back to the Future. How hard can it be?
Attempt 1: re-encode existing segments. Build on the post-processing experiment, catch segment 15, burn the fact in, swap the file back. Failed identically every time: Plex's verification compares codec configuration on re-encoded segments and rejects a 5-byte difference in the avcC box. stsd/avcC mismatch (encoder config differs): orig=201B new=206B (original untouched). Every. Single. Fire. The lesson wasn't "tune the parameters harder," it was that post-processing is structurally fragile, and the banner and logo work precisely because they ride the initial encode.
Attempt 2: write to the queue. The trivia belongs in the notification queue, same as any other message. First version detected new sessions by watching the transcode session directory. Wrong side of a race: the directory appears after the transcoder has already spawned and read the queue. Result: every movie displayed the previous movie's trivia. Exam opened with Road Trip's budget. Road Trip opened with Exam's. A perfect one-movie lag, nine seconds behind reality, and the queue's restore-unconsumed-lines behavior kept the stale fact sticking around.
Attempt 3: cache everything. The fix was suggested to me and it's the obvious one in hindsight: stop fetching trivia per movie. Pre-cache the entire library. A builder script walks the Plex library API, pulls box-office data from TMDb once per title, and stores it in a local SQLite database. First run indexed all 49 movies in under three minutes. Lookup during playback is now a millisecond query:
trivia = lookup_trivia("Back to the Future", "1985")
# {"budget_fact": "Made $391,546,852 on a $19,000,000 budget (20.6x return)"}The last race: detect before the spawn. Even with instant lookup, directory watching was still losing the spawn race. So the watcher stopped watching the filesystem and started polling Plex's /status/sessions API every 2 seconds instead, which reports the session before the transcoder exists. The timeline now:
- T+0s: user hits play
- T+1s: session appears in the API; watcher looks up trivia (instant), writes the queue
- T+2s: transcoder spawns, wrapper reads queue, includes the overlay
- T+10s: user sees the correct trivia
The watcher also clears the queue before each write, so a stale fact can't survive into the next movie the way Road Trip's budget did.
What the system looks like now
Architecture:
- Database:
plex-trivia-db-builder.pypre-caches all 49 movies (run weekly) - Watcher:
plex-auto-trivia.pypolls the sessions API every 2 seconds (systemd service) - Queue: trivia written to
/config/notifications/queue.txt, cleared per movie - Wrapper: the transcoder shim reads the queue at spawn and burns the overlay for 10 seconds
- Display: yellow text on black, bottom-center, same style as the TRANSCODED banner
What you see on screen: the first 10 seconds show TRANSCODED 654x480 1236k, the next appearance shows Made $1,698,863,816 on a $200,000,000 budget (8.5x return). Both ride the same injection path. No re-encoding. No codec mismatches. No race conditions left.
Numbers: 49 movies cached, average lookup under 5 ms, detection latency 1-2 seconds, overlay duration 10 seconds (configurable).
What it can't do (yet)
Direct play gets nothing. No transcode means no transcoder means no wrapper, so direct-played files never show overlays. Forcing transcode everywhere would burn CPU for nothing, so trivia is a transcoded-streams feature. Most of my devices trigger transcodes anyway (resolution caps, audio format conversion).
System messages are the wrong shape for this. The original want was on-screen alerts like "Server backup starting in 5 minutes," but the spawn race plus the transcode-only limitation makes overlays a poor fit for anything time-sensitive. Telegram notifications already cover those, and they arrive before the buffer even thinks about playing.
The trivia database needs feeding. New movies mean a builder run; TMDb corrections mean a rebuild. Currently manual, trivially cron-able, not worth automating until it annoys me.
Doing it yourself
If you want this pattern on your own Plex, the short version:
- Rename the transcoder, put a bash shim in its place, keep the real binary as
.real, and make a container init script that reinstalls the shim at every start (and re-detects fresh binaries after image updates). - Pass through anything you don't recognize. A dead shim is a dead server.
- For text: no drawtext in this fork, write ASS files and inject a
subtitles=filter into the existing-filter_complexchain. Ship your own font plus a minimalfonts.conf. - For images:
movie=plusoverlay=in the same chain, labels preserved, and seteof_action=repeaton the overlay or a one-frame PNG ends your stream. - Drive windows with
enable='between(t,S,E)'in stream time (-copytsmakes output PTS start at the seek position). - Read a spool file at spawn for dynamic content, and requeue rather than consume, because throwaway spawns will eat your messages.
What I'd tell someone else
- Inject during the initial encode. Re-encoding existing segments with perfectly matched parameters still produces different codec metadata. A 5-byte difference in avcC is all it takes to fail verification.
- Run the pristine baseline before blaming an injection. A plain
-targument plus the DASH muxer crashes this fork with a divide error, and it produced seven consecutive false "my filter crashes the transcoder" conclusions before I tested an unmodified command. It crashed too. - Rename, don't overwrite, running binaries. Overwrite gives you Text file busy; rename never does.
- File-system watches lag. Directories appear after the transcoder spawns; API state appears before it.
- Cache everything you can. A 1-2 second API call loses every race; a pre-cached SQLite lookup in milliseconds wins all of them.
- Race conditions are real. The wrapper reads the queue within milliseconds of spawn. Any delay in detection or lookup and you've missed the window.
- Leverage what already works. I spent hours on a segment post-processor before admitting the wrapper queue was the right tool all along.
Bottom line
What started as "show movie trivia at the start of playback" turned into a deep dive on Plex's transcoding pipeline, command-shape parsing, ASS rendering, filter graphs, and race conditions. I now have a probe, a logo, a message queue, and a trivia channel, all burned into streams Plex told me I couldn't touch.
Worth it? Absolutely.
Seeing "Made $1.7B on a $200M budget (8.5x return)" when Inside Out 2 starts is deeply satisfying.
Now back to actually watching movies instead of engineering their metadata overlays.
Test environment: Plex (linuxserver.io container) on a Mac mini running Ubuntu, October 2026. Fork checked for drawtext and libass availability; all timings and tiers measured against real Xbox DASH sessions. The red logo is literally a placeholder PNG named plex-logo.png; it's a test image, not branding. No tokens or session identifiers in anything shown above.
