This is the full reference for OneEncode's config system. If you haven't gone
through the basic setup yet, start with GETTING_STARTED.md
first — this document assumes you already have a working config/legs.local.yaml
and want to understand every field and how the pieces fit together.
| File | Committed? | Purpose |
|---|---|---|
config/legs.example.yaml |
Yes | Documentation template — showcases every field (multiple renditions, rendition dedup, an rtmp-push leg shape) but isn't meant to run as-is (its rtmp-push legs reference secrets that don't exist). Copy this to legs.local.yaml as a starting point for real setup. |
config/legs.default.yaml |
Yes | The actually-runnable first-run default — one local-file leg, no real credentials needed. Auto-copied to legs.local.yaml if that file doesn't exist yet (see below); you won't normally edit this directly. |
config/legs.local.yaml |
No (gitignored) | Your real renditions, legs, ingest/relay settings, restart policy. |
config/secrets.local.example.yaml |
Yes | Placeholder template for secrets. |
config/secrets.local.yaml |
No (gitignored) | A flat map of ENV_VAR_NAME: real-value — real destination URLs and stream keys. Never referenced by literal value anywhere else in config; legs point at a secret by name only. |
config/platformProfiles.yaml |
Yes | Reference table of known platforms' published recommended encode settings — not a secret, just documentation data. Used to prefill new renditions in the dashboard. |
config/mediamtx.yml |
Yes | Config for the bundled local RTMP relay server (MediaMTX). You generally won't need to touch this. |
legs.local.yaml and secrets.local.yaml are both loaded and validated at
startup by src/config/load.ts. If legs.local.yaml doesn't exist at all,
it's auto-generated from legs.default.yaml so the orchestrator can start on
a completely fresh install — a startup log line and the dashboard's
usingDefaultConfig banner both make this visible, it's never silent. This
only fires when the file is missing; if it exists but fails schema
validation, or a leg references a secret env-var name that isn't resolvable
(missing from secrets.local.yaml and not set as a real process
environment variable), the orchestrator still refuses to start with a clear
error — a real, if broken, config is never silently replaced or
partial-started with some legs missing their credentials.
There is currently no hot-reload. Every config change (through the dashboard or by hand-editing YAML) requires restarting the orchestrator to take effect. The dashboard shows a "restart required" notice after any config write for exactly this reason.
A rendition describes an encode profile — resolution, framerate, bitrate, codec — completely independent of where it's going. Example:
renditions:
- id: 1080p60-6M
resolution: { width: 1920, height: 1080 }
fps: 60
videoBitrateKbps: 6000
audioBitrateKbps: 160
keyframeIntervalSec: 2
encoderPreference: [h264_nvenc, h264_amf, libx264]Fields (from src/config/schema.ts's renditionSchema):
-
id(required) — must match^[a-zA-Z0-9_-]+$(letters, digits,-,_only) since it becomes part of an internal URL path (rtmp://<relay-host>/rendition/<id>). Must be unique across all renditions. -
resolution(required) — either an explicit{ width, height }object, or the literal string"source"to skip scaling entirely and encode at whatever resolution the incoming source actually is. No-vf scalefilter is applied for"source". -
fps— defaults to60if omitted. Note: this value is not used to force the output frame rate (no-r/fps=filter is applied anywhere) — it's used purely to compute the GOP/keyframe-interval size (fps * keyframeIntervalSec, rounded). The actual output frame rate is always whatever the source genuinely delivers. -
videoBitrateKbpsorvideoQuality— exactly one style of rate control, not both:videoBitrateKbps: 6000— a fixed CBR-style bitrate target (-b:v,-maxrate,-bufsizeat 2x the bitrate).videoQuality: { mode: cq, value: 19 }(ormode: cbr/vbr) — quality-based encoding (-cq/-crfdepending on encoder family) instead of a fixed bitrate. Lowervalue= higher quality/bigger files.- If neither is set, config validation fails with a clear error naming the rendition.
-
audioBitrateKbps— defaults to160. Audio is always AAC, 48kHz. -
keyframeIntervalSec— defaults to2. Used withfpsto compute the GOP size passed to the encoder. -
encoderPreference(required, non-empty array) — an ordered fallback list fromh264_nvenc | hevc_nvenc | av1_nvenc | h264_amf | hevc_amf | av1_amf | libx264 | libx265. The orchestrator walks this list at startup, skipping NVENC entries once the probed session ceiling is reached (see "Encoder selection" below).Only H.264 encoders are currently usable end-to-end (
h264_nvenc,h264_amf,libx264) — see TROUBLESHOOTING.md for why HEVC/AV1 don't work with the bundled relay yet, even though the schema itself permits configuring them.
Two or more legs that reference the same renditionId share exactly one
encode branch inside the single combined decode process — the second (and
third, etc.) leg is a cheap -c copy stream-copy, not a redundant
decode+scale+encode. This is the whole point of separating renditions from
legs.
Concrete example: if you're pushing 1080p60 to both Twitch and Kick, give
both legs the same renditionId (e.g. shared-1080p60) instead of two
separate renditions with identical settings — one encode branch runs, both
legs remux from it. Only give two legs different rendition ids when they
actually need different resolutions/bitrates/codecs (e.g. a 1440p rendition
for YouTube alongside a 1080p rendition for everything else).
A leg references a rendition by id and describes a destination. Two types:
legs:
- id: twitch-main
enabled: true
type: rtmp-push
renditionId: shared-1080p60
destinationUrlEnv: ONEENCODE_TWITCH_MAIN_URL
priority: 20
- id: local-archive-1
enabled: true
type: local-file
renditionId: shared-1080p60
outputDir: recordings
filenamePattern: "archive_{timestamp}.mp4"
priority: 10Common fields (every leg type):
id(required, unique).enabled— defaults totrue. A disabled leg is skipped entirely — its rendition branch isn't even built if no other enabled leg needs it.renditionId(required) — must reference an existing rendition; the config fails validation otherwise (with the specific leg named in the error).priority— defaults to0. Used for display/ordering; doesn't affect encode behavior.
type: rtmp-push fields:
destinationUrlEnv(required) — the name of an environment variable/secrets-file key holding the real destination URL (e.g.rtmp://live.twitch.tv/app/<stream-key>). Never the URL itself — that lives insecrets.local.yaml, resolved at load time. In the dashboard, this is derived automatically from the leg's own id (ONEENCODE_<ID>_URL) — you never see or edit the raw env-var name there, just separate "RTMP server" and "Stream key" fields that get joined client-side.- rtmp-push legs never auto-start. See "Broadcast arm" below — they
start staged and need an explicit arm + Go Live action, even with
enabled: true.
type: local-file fields:
outputDir(required) — relative to the working directory (typicallyrecordings/).filenamePattern— defaults to"archive_{timestamp}.mp4".{timestamp}is substituted at record-start time. Regenerated on every restart, so a crash-restart never reopens/overwrites a prior partial file.- Local-file legs auto-start — no arm/Go Live gate, since nothing leaves the machine.
Worth calling out here since it's config, not code: some platforms' own
dashboards give you a destination URL that's just a bare host with the
stream key as the only path segment (e.g. rtmps://example-host/KEY). If
your platform does this, add an explicit /app/ (or whatever segment your
platform's docs show) before the key. See
TROUBLESHOOTING.md for why this matters — ffmpeg's RTMP
handler will otherwise silently send an empty stream name and the platform
will reject the connection in a way that looks exactly like a TLS failure.
ingest:
listenUrl: "rtmp://127.0.0.1:1935/ingest/live"
relay:
url: "rtmp://127.0.0.1:1935/relay/live"
encoder: h264_nvenc
preset: p1
tuneLowLatency: true
bitrateKbps: 40000
encoderPriority:
- h264_nvenc
- h264_amf
- libx264
restartPolicy:
maxRestartsPerHour: 5
backoffInitialMs: 2000
backoffMaxMs: 60000ingest.listenUrl— where your local encoder (OBS) publishes the source. You normally never need to change this from the default.relay.url— its host:port is the base every rendition's real internal URL is derived from (rtmp://<relay-host>/rendition/<id>). The relay no longer runs a separate mezzanine encode+publish of its own (that branch was removed as dead weight — see PATCHNOTES.md) — only its host/port are actually used now.relay.encoder/preset/tuneLowLatency/bitrateKbpsonly matter for the degenerate zero-rendition fallback case and can be left at their defaults.encoderPriority— the default fallback order used if a rendition doesn't set its ownencoderPreferenceexplicitly. (In practice, always setencoderPreferenceper-rendition — the dashboard always does.)restartPolicy— applies uniformly to every leg and to the combined encode process.maxRestartsPerHouris a rolling-hour cap past which a leg is marked permanently failed and surfaced loudly rather than looping forever; backoff is exponential betweenbackoffInitialMsandbackoffMaxMs.
encoderPreference is walked in order at orchestrator startup. NVENC-family
encoders (h264_nvenc, hevc_nvenc, av1_nvenc) are tracked against a
concurrent-session ceiling — once reached, the tracker skips remaining NVENC
entries in the list and falls through to the next non-NVENC option, logging
an encoder_fallback event.
That ceiling is never hardcoded. Consumer NVIDIA cards have historically capped concurrent NVENC sessions in the driver, and the real number varies by GPU/driver. Run:
npm run probe:nvenc
This empirically launches synthetic h264_nvenc sessions one at a time
against a test source until one fails, and writes the real ceiling to
state/nvenc-ceiling.json (gitignored — this is per-machine). If you've
never run the probe, the orchestrator uses a conservative fallback ceiling
of 3, with a loud warning telling you to run the probe. Re-run it after any
NVIDIA driver update.
Encoder choice is made once per rendition at orchestrator startup, not re-evaluated on every crash-restart — a reasonable simplification since renditions are stable for a run's duration. If NVENC genuinely can't be acquired at runtime despite the tracker's count, it surfaces as a normal ffmpeg launch failure and the existing restart/backoff loop takes over.
rtmp-push legs (anything that sends real data to a real external
platform) start staged, not running, when the orchestrator boots — even
with enabled: true. Two gates, both reset to off on every restart (no
persisted armed state can survive a crash and let a leg start unattended):
- A global arm switch — the dashboard's persistent arm/disarm banner,
or
POST /api/broadcast/arm/POST /api/broadcast/disarm. - A per-leg "Go Live" action — refused unless the global switch is armed.
Disarming is a real kill switch, not just a block on future starts — it
immediately stops every currently-running rtmp-push leg.
local-file legs are completely unaffected by any of this — no external
side effect, they auto-start as always.
The dashboard (http://127.0.0.1:4771 by default) talks to the same
config/legs.local.yaml/secrets.local.yaml files directly — there's no
separate database to fall out of sync.
Configurable through the dashboard's "Configure" tab (src/ui/configApi.ts,
mounted at /api/config):
- Full create/edit/delete for renditions and legs.
- Every write is validated through the exact same schema the orchestrator loads with — the dashboard cannot produce a config the orchestrator would then reject at startup.
- Deleting a rendition still referenced by a leg is refused with an error naming the dependent leg(s).
- A leg's stream key/URL is write-only end to end — the API only ever
reports
secretSet: true/false, never the real value, even to an already-authenticated client. - A rendition can be prefilled from
config/platformProfiles.yaml's recommendations (see below) via a dropdown — suggestion only, never overwrites an already-set field.
Not configurable through the dashboard — requires hand-editing YAML + restart:
ingest.listenUrlrelay.*settingsencoderPriorityrestartPolicy
A committed, non-secret reference table of well-known platforms' published recommended encode settings, used only to prefill a brand-new rendition's resolution/fps/bitrate in the dashboard. Hard rule: it never overrides a value you've already set — it only ever seeds an unset field on a new rendition, or shows as a visible suggestion.
Platforms currently in the table: Twitch, YouTube Live, Kick, Facebook
Live, TikTok LIVE (the TikTok entry is ingest-spec documentation only —
TikTok isn't wired up as an actual leg type yet, see
TROUBLESHOOTING.md). Each entry carries a
confidence rating and a sourcedDate — these are compiled from each
platform's public documentation, not fetched live, and should be
re-verified against the platform's current docs before you rely on them for
a real broadcast, especially if the date looks old.
Two log outputs, both under logs/ (gitignored):
- Structured JSONL (
logs/oneencode-<date>.jsonl) — one line per event (leg starts/exits/restarts, stats samples, encoder fallbacks, config errors). This is what the dashboard's live stats and the benchmark scripts' jitter reports read back. - Console mirror (
logs/oneencode-console-<date>.log) — everyconsole.log/warn/errorcall, mirrored to a plain-text file in addition to printing normally. Exists so a run launched by double-clicking the packaged exe (no visible terminal) still leaves a debuggable record.
Both are redacted before every write — any URL that looks like it might carry a stream key is scrubbed, matching mid-string occurrences (e.g. embedded in an ffmpeg error message), not just clean structured values.
Both are also size-bounded, added ahead of the first public release so a
long-running, unattended install can't fill a disk: each dated file rolls to
a new numbered file (.2, .3, ...) once it would exceed 10MB, and the
oldest files across the whole logs/ directory are pruned once total size
exceeds 200MB. Both the dashboard's live tailing and the benchmark scripts'
log-reading correctly follow rotation, so this is transparent during normal
use.