Status: image watermarks and text SHIPPED. A still per rendition and a line of burnt-in text, both with percentage geometry, nine anchors and opacity. Operator page: ../RENDITIONS.md.
Text arrived after this note first said it had not, along with the two things the design correctly identified as its prerequisites: the embedded font (two weights of Inter, because
drawtexttakes a font path and a container image has neither fontconfig nor a font file) and thedrawtextfilter probe (the filter is optional in FFmpeg; a build without it drops the text and keeps the picture up rather than failing the rendition).Not built, and still designed below: the clock, externally-fed data, multiple overlays per rendition, and the one-frame preview endpoint.
Three things building it changed.
scale2refis deprecated in FFmpeg 8.1.2 and warns on every start. Its documented replacement -- a two-inputscale=rw:rh-- works, but the reference input has to come from asplitof the main chain, costing a frame copy per frame. So the image width is computed in Go instead, which means a watermarked rendition needs an explicit width and height. That is a real restriction the design did not anticipate, and it is enforced at validation time rather than discovered as a stream with no logo.- Columns, not tables. The design argues for
overlays+rendition_overlaysbecause the full feature has several overlays per rendition and reuses rows across renditions. v0.5 has neither, and a join table for a strictly 1:1 relationship is structure with nothing in it. Growing to the table later is a six-column data migration.r.Deinterlacewas missing fromrenditionSig-- found while adding the overlay to it. Changing a rendition's deinterlace mode was stored, shown in the UI, and never reached the running encoder. Dates from the item-0 work; fixed here.The hardware-encoder finding held exactly as written: the overlay is an ordinary software stage appended before VAAPI's one-way
format=nv12,hwuploadtail, and nothing changed for the other four encoder families.
Status of the original design below: superseded by the note above. It was written on 2026-07-28 and deferred; watermarks shipped that week and text followed. The body is kept because its reasoning about the filter chain, the geometry model and the hardware-encoder interaction is what the implementation was built from, and two of its three predictions held exactly.
Evidence: the most-repeated unmet request on the competitor's tracker — asked five separate times (6+5+4+2+1 reactions across distinct issues), every one closed unimplemented. Effort: ~16 days full, or a credible 6-day v0.5.
An overlay forces a video re-encode. polyemesis's central promise is that video is copied, never touched.
So overlays belong on renditions, where re-encoding is already the contract —
not on destinations, which do -c:v copy and have no mechanism by which a copied
bitstream acquires a logo.
The novel angle nobody else ships is per-destination branding: a different sponsor card for Twitch than for YouTube, a clean feed with no branding for the archive, a vertical-safe lower-third that only applies to the 9:16 rendition. Overlays elsewhere are a property of the stream; here they can be a property of the destination.
The filter chain is one function. videoFilter
(internal/ffmpeg/rendition.go:351) comma-joins
a []string and hands it to -vf. Stages are already deliberately ordered:
deinterlace → aspect → VAAPI tail.
A labelled multi-chain graph is already proven. blurredPadFilter
(rendition.go:462) emits split=2[bgsrc][fgsrc];…;[bg][fg]overlay=… inside
-vf, so -vf accepting link labels is established. What has never been done is
adding a second input.
Image handling has a precedent, and a rule. SlateSettings.ImagePath is
stored relative to DataDir, validated by SlateImageProblem() (length cap,
control characters, backslash normalisation, .., leading /, drive letters),
and joined to an absolute path only at process-build time. And synth.go:345
states the rule an overlay must obey:
A plain path, never a
movie=filter argument: paths routinely contain the characters a filtergraph would treat as separators.
That one comment forecloses the movie= design. Image overlays must be real
-i inputs.
The acknowledgement vocabulary already exists. Destination.ExpertAckReencode
"records the operator agreeing, in as many words, that an argument here
overrides something the product otherwise guarantees", and checkExpertArgs
builds a promise string and forces acknowledgement. Overlays should borrow this
wholesale rather than invent a new warning idiom.
This is the most useful thing the research turned up, because it removes a problem everyone assumes is there.
prof.vaapi is true for exactly one encoder (rendition.go:188). There is no
-hwaccel on the input anywhere in RenditionArgs, so NVENC, QSV, VideoToolbox
and AMF all decode and filter in system memory and upload internally.
The hwupload/hwdownload sandwich problem does not exist in this codebase
except for VAAPI — and for VAAPI it is not a sandwich, it is a one-way tail
(format=nv12,hwupload). An overlay is simply another software stage appended
before that tail. No download, no round trip, and no change at all for the
other four encoder families.
Columns on renditions cannot express "logo and channel name", and an
overlay must be reusable across renditions (the same card on 16:9 and 9:16):
overlays(id, source_id, name, kind, text, image_path, font_file, font_size_pct,
font_color, box, box_color, opacity, anchor, margin_x_pct,
margin_y_pct, width_pct, source_url, refresh_seconds, …)
rendition_overlays(rendition_id, overlay_id, z, PRIMARY KEY(rendition_id, overlay_id))
Geometry is percentage-based, and this is non-negotiable. The entire
per-destination angle is the same overlay attached to a 1920×1080 and a 1080×1920
rendition; pixel geometry lands off-canvas on the second. Percentages compile to
expressions against main_w/main_h, so one row is correct at every size.
A clean feed is the absence of a rendition_overlays row — deliberately
mirroring "passthrough is the absence of a rendition_id".
With ≥1 image overlay, RenditionArgs switches from -vf to -filter_complex:
-vaapi_device … # unchanged, still before every -i
-i udp://… # input 0, the relay — unchanged
-i /data/overlays/logo.png # input 1
-filter_complex "[0:v:0]bwdif=…,scale=…[bs];
[1:v]format=rgba,scale=iw*0.12:-2[o1];
[bs][o1]overlay=x=…:y=…:eof_action=repeat[v1];
[v1]drawtext=textfile=…[v2];
[v2]format=yuv420p[vout]"
-map "[vout]" -map 0:a -c:a copy
Four load-bearing details:
- Image inputs go after the relay input, so the relay stays
0:and-map 0:a -c:a copyis untouched. Audio still arrives bit-identical; the invariant survives verbatim. - No
-loop 1, no-shortest. A single-frame PNG plusoverlay=…:eof_action=repeatholds the logo forever, and the process still ends when the relay ends.-loop 1without-shortestwould keep the encoder alive after the ingest died. - An empty overlay set must produce byte-identical argv to today.
-vfstays-vf. The 1,117-linerendition_test.gois the regression net. format=yuv420pis pinned after the last overlay, or an RGBA logo over a limited-range source shifts colour depending on which conversionoverlayauto-inserts.
| kind | mechanism | cost | changes without restart? |
|---|---|---|---|
| image | second -i, overlay |
~1–2% CPU | no |
| static text | drawtext=textfile=…:expansion=none |
~2–5% at 1080p | no |
| clock | drawtext with %{localtime…} |
same | self-updating |
| feed (viewer count) | textfile=…:reload=1 + a Go writer |
same + a file read per frame | yes |
drawtext's text= option is the worst escaping surface in FFmpeg — and the
existing lavfiEscaper does not escape %, which drawtext expands. A
channel name containing an apostrophe becomes a stream that will not start.
textfile= eliminates the entire class.
Feed text is rewritten by a goroutine using write-temp-then-os.Rename in the
same directory. A half-written file read by reload=1 renders garbage on air.
The rule that protects the restart model: the overlay's shape (id, kind,
anchor, percentages, font, image path plus the image file's size and mtime,
static text) goes into renditionSig. The feed file's contents do not.
Otherwise every viewer-count tick restarts the encode and every destination on
it.
It requires --enable-libfreetype. detect.go currently parses -encoders only
— nothing knows whether this FFmpeg has drawtext. It must gain -filters
parsing, and a text overlay on a build without it must fail at validation time
with a clear message, not at process start.
Fonts: embed one open font (~200 KB) via go:embed and materialise it into
<DataDir>/overlays/ on first run. Minimal containers have neither fonts nor
fontconfig, and "channel name" has to work out of the box.
It costs exactly one encode per distinct branding.
- Twitch with a sponsor card + YouTube with a different card + a clean archive = two encodes and one passthrough, not one encode and three overlays.
- Adding the first overlay to an existing rendition costs no new process — a few percent CPU on an encode that was already running. Present that case as cheap, because it is.
- The expensive case is differentiating. Cloning a 1080p60 rendition so two
platforms can carry different cards costs a second full encode: roughly 1.5–3
cores on x264
veryfast, or ~zero CPU on NVENC — but one of the 3–8 concurrent NVENC sessions a consumer card allows. That ceiling is a hard FFmpeg start failure, and the existing encoder probe will not catch it because it probes once, not per session.
Keeping it visible. Reuse the product's existing vocabulary:
DestinationCardrenders"passthrough · copy"today. An overlaid destination renders"1080p60 · overlaid"with a distinct chip — the copy/encode distinction stays the first thing on the card.- Attaching an overlay to a rendition shared by K destinations says so, with the list, before saving. Shared branding must never be a surprise.
- "Give this destination its own branding" is an explicit Branch this
rendition button, never a side effect of editing an overlay. The confirm
prices it with the Mpix/s estimator
RenditionsPagealready computes. - That confirm carries an acknowledgement modelled on
ExpertAckReencode— stored, not one-shot, for the same reason the expert flag is.
The template already exists: TestAspectFiltersPlaceThePictureCorrectly
(rendition_test.go:900) renders one frame to -f rawvideo -pix_fmt rgb24 pipe:1 and counts pixels.
- Position as a bounding box. Black source, solid-magenta PNG. Dump one
frame, compute min/max x and y of magenta, assert the rect for each of 9
anchors × 2 canvas shapes × 2 margins within ±1 px. Catches a swapped
W/main_w, a margin on the wrong axis, or rounding drift — none of which a string comparison catches. - Scale invariance. The same overlay row at 1920×1080 and 1080×1920: assert
measured width / canvas width equals
width_pctin both. This is the single test that proves the per-destination story works. - Text without OCR.
drawtextwithbox=1:boxcolor=whiteon black: measure the white bbox and assert its height scales linearly withfont_size_pctacross three sizes. A zero-area result catches a missing font. - Reload without restart. Run 3 s with
reload=1, atomically replace the file mid-run with text of a different width, sample frame ~10 and ~80, assert the bbox changed and the process never restarted. - framemd5 on the raw chain (not through H.264, which is not bit-reproducible): the same chain twice is identical; overlay-present differs from overlay-absent; two renditions sharing an overlay produce identical overlay regions. This makes "the clean feed is actually clean" a measurement.
- The passthrough guarantee as a golden test.
RenditionArgswith no overlays must be byte-identical to today, and every destination kind must still emit-c:v copyand never a video-filter_complex. - VAAPI ordering:
hwuploadlast, every overlay before it,-vaapi_devicebefore every-i, and-icount equals 1 + images. - Path confinement, mirroring the slate's table test.
- Hostile text containing
: , ' \ % [ ] ; "— build it, run it, assert exit 0 and a non-empty glyph bbox. Withtextfile=this passes trivially, which is the point of choosing it.
-vf→-filter_complexis surgery on the most safety-critical arg builder, guarded by ~40 KB of golden expectations. Mitigated only by the byte-identical no-overlay test.- Stream renumbering. Adding
-imakes the logo input 1.api/expert.gorefuses a second-ion destinations precisely because it renumbers[0:a:N]. Renditions have no routing graph so it is safe — write that down in the code, or someone will later "fix" it wrongly. drawtextabsent from the build. A rendition that started yesterday must not stop starting today.reload=1reads the file every frame — 60 opens/second inside the encode loop. Document a 1 Hz refresh ceiling.- Editing an overlay restarts the encode, and therefore every destination on that tier. Nudging a logo 10 px while live drops them for a second or two. Only feed content is hot; geometry is not. The UI must say so.
- NVENC session limits turn per-destination branding into a hard start failure on consumer GPUs, invisible to the one-shot probe.
- Scope creep toward a graphics engine. The v1 line: static image, static text, clock, one externally-fed text file. No animation, no browser sources, no alert graphics.
| days | |
|---|---|
overlay.go + rendition.go restructure + string tests |
3 |
detect.go filter probe + embedded font |
1 |
| DB model, migrations, validation, path confinement | 2 |
| Engine wiring, signature, feed-text writer, lifecycle | 2 |
| API + one-frame preview endpoint | 1.5 |
| UI: editor, preview, cost/ack framing | 3.5 |
| Measurement tests | 2 |
| Docs | 1 |
| Total | ≈16 |
The 6-day v0.5 worth considering: image watermark only, one overlay per
rendition, percentage geometry, no text, no dynamic data, no preview. It closes
the most-requested item and it exercises the whole -filter_complex
restructure — which is the part that must be right before anything else is built
on it.
- ROADMAP
- ../RENDITIONS.md — where overlays attach
- UNREACHABLE-FEATURES.md — the UI drift this work would otherwise extend