Skip to content

Latest commit

 

History

History
379 lines (254 loc) · 66.8 KB

File metadata and controls

379 lines (254 loc) · 66.8 KB

PATCHNOTES

Investigated — HEVC/AV1: MediaMTX v1.20.0 accepts them in, can't serve them back out via RTMP

Re-investigated after the user correctly pushed back on this project's own earlier "RTMP can't do HEVC" finding as too broad (real-world OBS→YouTube AV1 streaming does exist, via the 2023 "Enhanced RTMP" spec that Twitch/YouTube/OBS all support, which ffmpeg's flv muxer has implemented since v6.1).

Confirmed via a real synthetic test (hevc_nvenc → this project's actual bundled MediaMTX v1.20.0): the original ingest-side bug (MediaMTX's AMF0 fourCcList parser choking on HEVC, mediamtx#3188) was fixed upstream in v1.7.0 and no longer reproduces — MediaMTX correctly logs "stream is available and online, 1 track (H265)" on publish. But a second, different, currently-real MediaMTX limitation was found in the same test: a reader pulling that same stream back out via RTMP is rejected — "the stream doesn't contain any supported codec, which are currently AV1, VP9, H265, H264, ..." — despite H265 being in that exact list and having just been accepted as input. MediaMTX's RTMP ingest supports Enhanced RTMP; its RTMP serving side does not, as of v1.20.0.

Since every rendition in this pipeline gets read back out of MediaMTX via RTMP (-c copy, both destination legs and local-file legs), this is a real blocker for HEVC/AV1 anywhere in the current architecture — specific to MediaMTX's current output-side codec support, not a fundamental RTMP protocol limitation. Also confirmed AV1 hardware encoding isn't viable on either the dev machine (RTX 2080 Ti) or the streaming PC (RTX 2070) regardless — both are Turing-generation, no AV1 NVENC (needs RTX 40-series/Ada or newer). HEVC (hevc_nvenc) is available on both. CLAUDE.md/AGENTS.md's task #24 entry corrected to reflect this — no code changes from this investigation, config never touched HEVC/AV1.

v0.18.0 — swap bundled ffmpeg to an OpenSSL/SChannel-TLS build (Kick RTMPS investigation)

The real 3-platform test found kick-main dying at a consistent ~2s every attempt ([tls] Error in the pull function / IO error: End of file), while Twitch/YouTube worked fine. The bundled ffmpeg (gyan.dev's essentials build) is compiled --enable-gnutls, and GnuTLS-based ffmpeg RTMPS builds have documented real-world compatibility gaps with some ingest servers that OpenSSL/native-TLS builds don't share. Swapped the bundled ffmpeg.exe (personal-package-only, see v0.13.0's ffmpeg-bundling note) to BtbN's win64-gpl build (github.com/BtbN/FFmpeg-Builds), which is --enable-schannel (Windows' own native TLS stack, not GnuTLS — corrected from an initial assumption it was OpenSSL) — same GPL license category, ffmpeg-LICENSE.txt updated accordingly. Also confirmed this build carries full HEVC (libx265, hevc_nvenc, hevc_amf) and AV1 (libaom, libsvtav1, av1_nvenc, av1_amf) encoder support. Not yet verified against the real Kick failure — needs a real retest on the streaming PC.

v0.17.0 — always write console output to a log file

Requested directly by the user after debugging a real Kick RTMPS failure needed the raw ffmpeg stderr tail — which only ever went to console.error, never to the structured JSONL log logger.ts already writes — so a run launched by double-clicking the exe (no terminal to scroll back through) left no record of it at all.

Added

  • src/logging/consoleMirror.ts (mirrorConsoleToFile(), called first thing in src/index.ts): wraps console.log/console.warn/console.error to also append every call to logs/oneencode-console-<date>.log, in addition to printing normally. Every line is redacted the same way logger.ts's own writes are (CLAUDE.md §2A) — defense in depth in case a future call site ever passes something unredacted straight to console.error.
  • Test added (tests/logging/consoleMirror.test.ts) covering both the pass-through behavior and the redaction-of-embedded-secret case.

v0.16.0 — auto-login: the auto-launched dashboard tab is now zero-manual-step

User feedback after v0.15.0: opening the browser automatically was good, but you still had to manually copy the token out of state/ui-token.txt and paste it into the login screen every time. Fixed with the user's explicit sign-off, since this touches CLAUDE.md's "the token is never displayed in a URL" rule.

Changed

  • src/index.ts's auto-launch URL now includes ?token=....
  • web/src/App.tsx reads it once on load (getUrlToken), persists it to localStorage the same as a manual paste would, and immediately strips it from the address bar via history.replaceState so it doesn't linger visibly in the URL/browser history any longer than that one initial load.
  • CLAUDE.md/AGENTS.md's "never in a URL" rule updated to document this as a second narrow, deliberate exception (alongside the pre-existing WebSocket one) — explicitly scoped to the orchestrator's own auto-launched tab only, not any other link/redirect, and explicitly noted as a tradeoff accepted for the single-user dedicated-streaming-PC target rather than a shared machine.

v0.15.0 — auto-open the dashboard in the default browser on startup

Requested directly by the user ("I hate the localhost approach, UI should be internal") — clarified down to: keep the existing server+browser architecture, just stop requiring a manual URL visit. src/index.ts now spawns cmd /c start "" <dashboard-url> right after the UI server comes up (same point in startup as before — still gated on the pipeline actually being ready, same known behavior as always). Best-effort: wrapped in try/catch, a failure to launch a browser logs a warning and never crashes the orchestrator.

v0.14.0 — fix: bundled ffmpeg wasn't found by the packaged exe; a spawn failure crashed the whole orchestrator

Found while actually bundling ffmpeg.exe alongside oneencode.exe for a personal streaming-PC transfer (not a public-repo redistribution — see CLAUDE.md §6/§2A additions).

Fixed

  • spawn ffmpeg ENOENT even with ffmpeg.exe sitting right next to oneencode.exe. child_process.spawn on Windows resolves a bare command name ("ffmpeg") only via the PATH env var — unlike Windows' own CreateProcess, it does not also fall back to checking the launching process's own directory. src/index.ts now prepends path.dirname(process.execPath) to PATH on Windows at startup, which is a no-op in normal dev (process.execPath is just node.exe's own location there) but correctly resolves a co-located ffmpeg.exe when running the packaged exe (process.execPath is the exe itself in that case).
  • A spawn failure crashed the entire orchestrator, not just the one legspawnLegProcess (src/legs/legProcess.ts) had no "error" handler on the child process, so an ENOENT (or any spawn-time failure) was an unhandled exception that killed every other leg along with it, directly undermining the per-leg failure isolation CLAUDE.md §5 exists to guarantee. Now treated as a normal (if distinct — spawnError field added to the leg_exit log event) exit, so the existing restart/backoff loop in health/monitor.ts handles it like any other failure. Regression test added (tests/legs/legProcess.test.ts) simulating the ENOENT case via a mocked child_process.spawn.

Verified

Rebuilt oneencode.exe, re-ran the standalone smoke test with chocolatey's ffmpeg deliberately removed from PATH (so only the bundled copy could satisfy it) — confirmed it now spawns the bundled ffmpeg.exe and reaches the expected "no OBS live" ffmpeg error, instead of ENOENT. Full test suite (123/123) and typecheck clean.

v0.13.0 — standalone Windows exe for the streaming PC (no Node.js install required)

Requested directly by the user to deploy OneEncode on the dedicated streaming PC (CLAUDE.md's primary dual-PC target) without installing Node.js there.

Added

  • scripts/package-win.ps1 (npm run package:win): bundles the ESM backend to a single CJS file (esbuild, since this project is "type": "module" and Node's Single Executable Application feature wants one entry file), generates a SEA blob, copies the locally-installed node.exe, strips its Authenticode signature (required before injection — auto-locates signtool.exe under the Windows SDK path), and injects the blob via postject to produce oneencode.exe.
  • oneencode.exe resolves config/, tools/mediamtx/, and web/dist/ relative to its working directory, same as the normal tsx src/index.ts entrypoint (nothing in the codebase used __dirname/import.meta.url-relative paths, which is what made this straightforward) — so it can be dropped anywhere alongside those folders and run standalone. ffmpeg remains a separate required install on the target machine (never bundled, per §6's licensing/redistribution stance) — tools/mediamtx/ and web/dist/ are copied alongside it since those are this project's own build output, not a third-party redistribution concern.
  • New dev dependencies: esbuild, postject.

Verified

Smoke-tested the packaged exe standalone (no node_modules, no Node install in the test directory) twice: (1) in a directory with no config/ present — correctly hit the same config_validation_error path as the normal dev entrypoint and exited cleanly; (2) in a directory staged with the real config/, tools/mediamtx/, and web/dist/ — correctly started MediaMTX, loaded all 3 real renditions, exercised the NVENC session-limit fallback (correctly fell back kick-1080p60 to h264_amf under a conservative default ceiling), and retried the ingest connection with proper backoff (no OBS was live during this test, which is the expected/correct behavior — no destination legs run without frames). Confirmed clean shutdown with no orphaned mediamtx.exe/ffmpeg.exe processes.

v0.12.0 — fix: real secret leaked into a log file (ffmpeg stderr not redacted mid-string)

Found during the first real 3-platform test. Kick's RTMPS leg failed (see the TLS connectivity note logged separately) and its ffmpeg process's own stderr output — "Error opening output rtmps://host/KEY: I/O error" — was logged to disk with the real Kick stream key in plaintext, directly violating CLAUDE.md §2A's mandatory redaction rule ("any destination URL that might contain a stream key must be passed through a redaction helper before it is ever written to a log line").

Root cause

src/logging/redact.ts's URL-matching regex was anchored to the start of the string (^rtmps?:\/\//). That correctly redacts a bare URL value (an argv array element, e.g.) but misses a URL embedded mid-sentence inside a longer string — exactly the shape of ffmpeg's own error messages, which is not a case the original test suite exercised (its coverage was whole-string/array-element values only).

Fixed

redactString now does an unanchored, global search-and-replace for any rtmps?:// occurrence anywhere in a string, not just when the whole string is one. Added a regression test using the actual leaked-line shape. All existing redaction tests still pass unchanged.

Cleanup

The scratch log file this session's test run wrote to (oneencode-run.log, gitignored, never staged) contained the leaked key 10 times and was deleted outright rather than redacted in place. Confirmed via search that no other log file (logs/*.jsonl, the Bash tool's own background-task capture files) picked it up.

v0.11.0 — broadcast arm switch: manual gate before any rtmp-push leg starts

Ahead of the first real 3-platform test (Kick, Twitch, YouTube configured with real credentials), added a deliberate safety gate so a config with enabled: true platform legs can never silently start broadcasting the moment the orchestrator boots.

Added

  • src/health/broadcastArm.ts: an in-memory-only armed/disarmed switch. Always starts disarmed on every orchestrator restart — no persisted "armed" state can survive a crash and let a leg start unattended.
  • rtmp-push legs now start staged, not running, at startPipeline regardless of enabled: true — see src/pipeline.ts. local-file legs are unaffected (no external side effect, still auto-start as before).
  • restartManaged (the same function backing both "Restart" and the new "Go Live" dashboard action) now throws if the target leg is rtmp-push and the broadcast switch is disarmed — one enforcement point, not two.
  • RunningPipeline gained isArmed(), arm(), and disarm(). disarm() is a real kill switch, not just a future-start block — it immediately stops every currently-running rtmp-push leg, returning the ids it stopped.
  • New endpoints (src/ui/api.ts): GET /api/broadcast/armed, POST /api/broadcast/arm, POST /api/broadcast/disarm. GET /api/status now also reports broadcastArmed.
  • Dashboard (web/src/App.tsx): a persistent arm/disarm banner (red when armed, since that's the state where real data can leave the machine). Per-leg controls show "Go Live" instead of "Restart" for a staged rtmp-push leg, disabled with a tooltip when disarmed.

Verified

Typecheck clean (backend + web), full existing test suite still green (60/60 — no unit test coverage added for this yet, it's an integration-level gate; live dashboard verification pending the actual 3-platform run).

v0.10.0 — dashboard: clearer platform-destination setup, no more raw env-var field

Follows real-world feedback after the OBS validation above: the dashboard could add an RTMP-push leg, but the flow was confusing — it asked for a "destination env var name" (an internal config-loader detail no streamer should need to understand) and a single "Stream URL / key" field the user had to manually assemble, even though platforms like Twitch and YouTube give you the server and stream key as two separate fields in their own dashboard.

Changed

  • src/ui/configApi.ts: destinationUrlEnv is now derived server-side from the leg's own id (ONEENCODE_<ID>_URL) instead of being a UI-facing field. Editing a leg preserves its existing env-var name rather than re-deriving one (re-deriving would orphan the secret already stored under the old name).
  • New GET /api/config/platform-profiles endpoint serves config/platformProfiles.yaml (Twitch/YouTube/Kick/Facebook Live recommended settings, architecture decision #10 — built 2026-08-13, never wired into anything until now) to the dashboard.
  • web/src/ConfigManager.tsx: the rendition form gained an optional "Prefill from a platform" dropdown that fills in resolution/fps/bitrate from that platform's published recommendations — suggestion only, never overrides an already-set value, matches decision #10's hard rule exactly. The leg form's "Type" field is now "Send this to" with plain-language options, and the old single "Stream URL / key" input is now two fields — "RTMP server" and "Stream key" — joined client-side into the URL the backend expects, matching how platforms actually present the information instead of asking the user to concatenate it themselves.
  • 4 new backend tests (tests/ui/configApi.test.ts): destinationUrlEnv auto-derivation on create, preservation on edit, and the new platform-profiles endpoint (empty-list fallback + real data).

Verified live (real browser)

Logged into the dashboard, confirmed the "Encode pipeline" single-control card (from the combined-process fix above) renders correctly with live stats. Opened the platform-prefill dropdown on a new rendition, selected YouTube Live, confirmed video bitrate changed 6000→9000 and audio 160→128 exactly matching platformProfiles.yaml. Created an rtmp-push leg with separate server (rtmp://live.twitch.tv/app/, deliberately with a trailing slash) and key fields, confirmed the on-disk secret joined them correctly with no double slash (rtmp://live.twitch.tv/app/live_FAKE_KEY_ABC123) and the env-var name was auto-derived (ONEENCODE_TWITCH_TEST2_URL). Cleaned up both test legs through the UI, confirmed config and secrets files returned to their original clean state.


Task #24 CLOSED — combined-process fix shipped, root mechanism fully characterized, validated against a real OBS feed (2026-08-13)

Closing entry for the rendition-dedup jitter investigation. Summary of the full chain, newest findings first (earlier entries below have the detailed evidence for each step).

Shipped fix: src/legs/argvBuilder.ts's buildCombinedRelayAndRenditionsArgv folds the relay's decode and every rendition's encode into one ffmpeg process (-filter_complex split), eliminating the relay→rendition RTMP roundtrip that the original design had. src/pipeline.ts/src/ingest/decodeRelay.ts were restructured accordingly; src/rendition/renditionProcess.ts (the old per-rendition-process spawner) is gone. Destination legs are untouched — still fully independent processes, still -c copy from their rendition's RTMP path, still isolated from each other and from the encode side. The dashboard's per-rendition stop/restart controls were replaced with a single "Encode pipeline" control (/api/encode/stop|restart) since renditions no longer have independent processes to control — restarting any of them now honestly restarts the shared relay+all-renditions process, which is documented behavior, not a regression, since renditions already implicitly depended on the relay being alive.

Root mechanism, fully characterized (not just narrowed) via further isolation after the fix shipped:

  • A pure remux of an already-perfect file (zero encoding involved at all), republished through MediaMTX and read back by a separate -c copy process, still measured CoV≈0.06 versus the source's CoV=0.0000 — proving MediaMTX's own publish/relay/subscribe mechanism introduces jitter independent of encoding (scripts/jitterMediaMtxRelayTest.ts).
  • A plain OS-level stdio pipe between two processes (RTMP/MediaMTX removed entirely) measured CoV=0.09 — worse, not better — proving the mechanism isn't RTMP- or MediaMTX-specific either (scripts/jitterStdioPipeTest.ts, mislabeled during development as a "named pipe" test — Windows named pipes need a server created explicitly, which ffmpeg doesn't do automatically; the working test uses Node's own child-process stdio piping instead, a real OS pipe).
  • Conclusion: the jitter is inherent to crossing any process boundary at all, scaling with how many concurrent processes/branches are contending for the machine (scripts/jitterMultiBranchTest.ts: 1 branch=CoV 0.028 floor, 2 branches=~0.056 regardless of encoder mix, 2 NVENC branches specifically=~0.087). This is a direct consequence of this project's own foundational, deliberate architecture decision (process-per-destination failure isolation, locked before any code was written) — not a bug in any specific transport or library. Baseline's historical CoV=0.0000 was only possible because it has zero internal process hops; any design that keeps destination-level isolation (the boundary that actually matters for a live-streaming tool: a flaky platform shouldn't take down other destinations) pays some jitter cost for it. There is no further transport swap that escapes this while keeping that isolation.

A custom pacing/normalization relay was built, found working, then found buggy, then fixed, then judged unnecessary: scripts/jitterSmoothingRelayStressTest.ts's FanOutPacingRelay buffers a priming window and releases bytes at a steady, measured rate, decoupling output pacing from input arrival jitter. Single-writer short tests hit CoV=0.0000 (reproduced twice). A proper multi-leg, 3-minute stress test (explicitly requested rather than trusting the short test) caught a real bug the short test couldn't show: the buffer grew unbounded (0→48MB) because setInterval(fn, 10) doesn't reliably fire every 10ms under the real 7+-process load — measured firing at only ~55-64% of nominal rate by two independent forked debugging sessions (one auditing the relay's own byte accounting, one checking Node stream backpressure; both ruled out their assigned hypothesis and independently converged on the same timer-precision root cause). Fixed by computing release volume from actual measured elapsed time instead of the assumed fixed interval — buffer now stays correctly bounded. But the underlying jitter didn't improve after the fix (CoV stayed ~0.053, tested at 10ms/50ms/200ms tick granularity with no difference) — confirming the residual jitter comes from downstream process scheduling under contention, not from this relay's own timing precision, consistent with everything above. Not integrated into production — real-world testing (next section) showed it isn't needed.

Real OBS validation — the gap flagged since day one, finally closed: every test above used a synthetic lavfi source. Live-tested with real OBS output for the first time. Two real, separate bugs surfaced and were fixed:

  • HEVC over RTMP silently drops the video track. Not a OneEncode bug — standard RTMP (the FLV-based protocol this pipeline's ingest is built on) has no HEVC support; MediaMTX/ffmpeg only saw an audio track when OBS was configured for HEVC. OBS's video encoder must be H.264 for this pipeline's ingest.
  • Live RTMP sources can need more probe time than ffmpeg's defaults (analyzeduration=5s, probesize=5MB) before -filter_complex can bind streams — [0:v] matches no streams at filtergraph-binding time. Fixed by setting both to 10s/10MB on the combined process's input. (This turned out not to be the actual cause of the specific failure hit during testing — that was the HEVC issue above — but it's a real, independent gap the synthetic source never exercised, and is worth keeping regardless.)

With those fixed and a real H.264 OBS feed at 1080p60, the shipped combined-process pipeline (2 renditions, 3 legs, real dedup in effect) measured CoV=0.0283 across all three legs, max single-frame delta 17ms, drop=0/dup=0 — the cleanest multi-leg result of this entire investigation, well below the ~0.05-0.09 seen in synthetic multi-process stress tests, and comfortably under the ~33-50ms judder reference point used earlier to reason about perceptibility. The recording was visually reviewed (not just measured) and confirmed smooth/performant. Real-world conditions are meaningfully cleaner than the worst-case synthetic stress tests, most likely because a real deployment doesn't carry this investigation's own test-harness overhead.

Status: closed. The architecture fix is real, shipped, and validated against actual live usage. The deeper cross-process-contention mechanism is now fully understood as an inherent property of the failure-isolation design this project chose deliberately on day one — not a defect, and not something further tunable without giving up that isolation. Real-world numbers are good. If a future real platform destination (Phase 2) or a much busier multi-rendition config ever shows visibly-bad results, the diagnostic tools built during this investigation (npm run investigate:jitter-*, verify:jitter-fix) are the starting point, and the pacing relay is a proven, working (if currently unneeded) fallback.


Root cause CONFIRMED — jitter regression (task #24): the relay->rendition RTMP roundtrip itself is the mechanism (2026-08-13)

Direct, decisive confirmation, via scripts/jitterNoRtmpHopTest.ts (new, npm run verify:jitter-fix): one ffmpeg process decodes the ingest exactly once and splits the decoded frames (-filter_complex split) into two encode branches — branch A is the relay's normal ull encode, published to the real relay RTMP path exactly as today; branch B is a rendition-style 1080p6M encode written directly to a local file, no RTMP publish, no MediaMTX, no second process. Same total GPU/encode workload as the real pipeline (both branches run concurrently, same settings as every prior test), with exactly one variable changed: no RTMP demux/remux roundtrip between the decode and the rendition-style encode.

Result: CoV = 0.0000 — perfectly uniform, matching baseline exactly.

This is decisive: the jitter is not caused by encoding twice, not NVENC, not libx264, not GPU/session contention (all already ruled out) — it's specifically the RTMP publish/subscribe roundtrip through MediaMTX between the relay and the rendition encode. The moment that roundtrip is removed (same encode work, same process boundary count otherwise unchanged for branch A), the rendition-style branch is indistinguishable from baseline.

The real tension this surfaces: MediaMTX/RTMP was deliberately chosen for the relay->rendition and rendition->leg boundaries specifically to decouple readers for failure isolation (see architecture decision on rejecting a single monolithic -filter_complex process — CLAUDE.md §1). This experiment proves that exact decoupling mechanism is what introduces the jitter. There is a real tradeoff to make here, not a free fix: collapsing the relay->rendition boundary into one process (mirroring this test) would fix the jitter for renditions but reintroduce the coupling risk a rendition-stage crash was designed to avoid — though notably, legs/destinations (the boundary that actually matters most for isolation, since a destination failing shouldn't affect encoding) can likely stay on separate processes/RTMP reads unaffected, since this test didn't touch that boundary. Task #24's root cause is now CONFIRMED, not just narrowed — the next step is an architecture decision about which process boundaries can safely absorb this fix and which must keep isolation, not further diagnostic work.

Caveat, not yet addressed: every experiment in this investigation, including this one, uses the same synthetic lavfi test source, not real OBS output. OBS's own capture/encode pipeline has its own overhead and pacing characteristics that could differ from the synthetic source — this investigation isolates the relay->rendition boundary specifically, using an identical ingest across every comparison, but real-OBS validation of the eventual fix is still a separate, open follow-up before calling this production-proven.


Investigated — jitter regression (task #24): root cause narrowed to live re-encode of an already-relayed stream; a third fix (-fps_mode cfr) tried and rejected (2026-08-13)

Continuing from the entry directly below (two candidate fixes already rejected: nobuffer/low_delay, writeQueueSize both directions). Built three new isolation tools (scripts/jitterHopIsolation.ts, scripts/jitterFpsModeTest.ts, scripts/jitterEncoderTest.ts, all now permanent — npm run investigate:jitter[-fpsmode|-encoder]) to stop guessing at the architecture level and directly measure which hop introduces the jitter.

1. Hop isolation, via zero-encode -c copy taps at each stage (ingest / post-relay / post-rendition) simultaneously. In this run all three taps read back essentially the same CoV (≈0.028), including the raw ingest tap itself — meaning a pure remux of the live ingest is not perfectly uniform either, and neither encode hop measurably added jitter on top of what a plain copy already showed. This didn't match the earlier 0.0000-vs-0.0740 finding, which was the first sign the effect isn't simply "more hops = more jitter" in a way a copy tap can see — copy-based taps don't re-time anything, so they can only reveal jitter already baked into arrival timing, not jitter an encoder itself introduces.

2. Reproduced the official 0.0740 finding in a minimal, single-rendition-off-the-live-relay setup (previously only ever measured via the full multi-leg pipeline) — confirms the earlier finding is real and not an artifact of the specific benchmark script. In the same run, a second concurrent rendition encoder with -fps_mode cfr explicitly forced scored worse, not better (0.0771 vs 0.0740), and introduced actual frame duplication (dup=56 throughout, a duplicated frame roughly every second) — the forced-CFR frames still weren't evenly spaced (max delta 0.033s, a full doubled frame gap). -fps_mode cfr is ruled out as a fix — third candidate rejected.

3. Tested whether this is NVENC-specific by re-running the same single-rendition-off-the-live-relay setup with libx264 (CPU) instead. Still elevated — 0.0598 CoV, roughly 8x the copy-tap floor — meaning this is not an NVENC real-time session-pacing quirk; a completely different encoder backend shows the same qualitative effect, just at a somewhat lower magnitude.

4. Ruled out raw GPU/NVENC session-count contention as the mechanism. The original baseline benchmark runs 2 concurrent NVENC sessions (local-archive-1 + local-archive-2, both independently decoding the original ingest, no relay) and still measures ground-truth CoV≈0.0000 — near-perfect. A single rendition-stage encode session (regardless of encoder) reading from the relay already shows the full jitter. So contention/session-count isn't the differentiator — hop distance from the true origin is.

Sharper characterization of the mechanism, still not a full explanation: a live, real-time re-encode of a stream that itself came from another live ffmpeg process (the relay) measurably amplifies frame-pacing jitter in its own output, across at least two different encoder backends — while a live re-encode of the original true-origin ingest does not, even under comparable or higher concurrent session load. The remaining open question is why ffmpeg's live encode path treats these two input sources differently at the timestamp-generation level — that would need either instrumenting ffmpeg itself or testing further input-side variables (e.g. does normalizing the relay's own output timestamps before republishing help?), neither attempted yet. Task #24 stays open — three candidate fixes now rejected with real data (nobuffer/low_delay, writeQueueSize, -fps_mode cfr), but the root cause is meaningfully narrower than before: it's an encoder-agnostic effect of re-encoding a relayed live stream specifically, not a tunable buffer/pacing flag, not NVENC-specific, and not raw contention.


Investigated — jitter regression (task #24): writeQueueSize ruled out both directions; ground-truth PTS cross-check makes the finding starker, not weaker (2026-08-13)

Continuing from the earlier "Investigated" entries below (nobuffer/low_delay tried and rejected — made things dramatically worse). Two more real steps taken:

1. MediaMTX's writeQueueSize tried in both directions — neither helped. Tested at 128 (below the 512 default) with the same bench:oneencode methodology used throughout: CoV 0.089-0.090, statistically indistinguishable from the untouched-default run's 0.090-0.092. (A larger value had reportedly been tried and rejected in an earlier pass of this same investigation, before default was reverted to for this final comparison — not independently re-verified with saved data, but consistent with this result.) Reverted to the default — no override left in config/mediamtx.yml, just a comment recording what was tried and that neither direction measurably helped.

2. Completed the ground-truth cross-check flagged as still-open in the earlier entry: extracted real per-frame PTS values via ffprobe from actual recorded output files (not the periodic -stats samples) and computed jitter directly (scripts/ptsJitter.py, new permanent tool). This produced a starker result than the -stats-based CoV metric showed:

design ground-truth PTS-delta CoV
baseline (naive, one process per destination) 0.0000 — frame deltas ranged 0.016666-0.016667s, essentially perfectly uniform
this design (rendition-dedup) 0.0740 — frame deltas ranged 0.015-0.018s, real ±10% swing around the 60fps target

The -stats-based metric (CoV ≈0.05-0.07 baseline vs ≈0.09 this design) was directionally correct but understated how clean the baseline's actual frame delivery is — real ground truth shows the baseline isn't just "steadier," it's essentially perfectly uniform, while this design has genuine, measurable per-frame timing jitter. This is likely the actual mechanism behind the kind of visible stutter this project exists to fix, now captured with precise data rather than a periodic proxy.

Root cause still not isolated after two real hypotheses tested and rejected with data (nobuffer/low_delay, writeQueueSize both directions). Both were plausible, both were tested rigorously, neither explained it. Remaining candidates for a future session, not yet tried: something more fundamental to the extra-hop architecture itself (more concurrent OS processes competing for CPU/GPU scheduling time, inherent to any 3-hop chain vs. baseline's 1-hop) rather than a tunable buffer parameter — which would mean this needs an architectural answer, not a config tweak, if it's going to be fixed. Task #24 stays open. Per CLAUDE.md §8: this project's architecture remains CPU/GPU-efficiency-correct and failure-isolation-correct, but confirmed — now with sharper evidence than before — NOT to reduce the frame-pacing symptom it exists to fix.


Docs — project licensed, source-available (2026-08-13)

Added a real LICENSE file at the repo root, mirroring the structure of this author's sibling MW3 controller project's own license (read directly from that project's real LICENSE file, not guessed/templated from memory): source is fully open/viewable/forkable, but the software and any fork/derivative must always remain free to end users — no charging for it by anyone other than the copyright holder. This is a deliberate restriction, so the project is accurately described as source-available, not OSI open-source, going forward in any doc/README. Adapted the MW3 license's game/Activision-specific clause into an equivalent for OneEncode's actual context (no rights granted to any third-party streaming platform's trademarks/APIs/services). CLAUDE.md §6 updated with the real per-dependency licensing detail (FFmpeg — external, never bundled; MediaMTX — MIT, confirmed by reading its actual downloaded LICENSE file directly).


v0.9.0 — add/edit/remove legs through the dashboard, live-verified

Closes the one documented gap from v0.7.0's Phase 6 dashboard: config changes required hand-editing YAML + a restart. That restart requirement is unchanged (no hot-reload), but the hand-editing is no longer required.

Added

  • src/ui/configApi.ts — full CRUD REST API for renditions and legs, mounted at /api/config behind the same token gate as the rest of the dashboard. Every write is validated through the exact same rootConfigSchema/legSchema/renditionSchema (src/config/schema.ts) the orchestrator itself loads with, so a config the API accepts is guaranteed to be one the orchestrator will actually start with. Deleting a rendition still referenced by a leg is refused with a clear error naming the dependent leg(s), not a silent orphan reference.
  • Secrets are write-only through the whole stack: a leg write accepts an optional secretValue, written straight to config/secrets.local.yaml, never returned by any GET response — the API only ever reports secretSet: true/false. Deleting an rtmp-push leg deletes its associated secret too.
  • web/src/ConfigManager.tsx + web/src/configApi.ts — a "Configure" tab alongside the existing "Monitor" tab, with add/edit/delete forms for both renditions and legs. Secret fields are password-style inputs, never pre-filled with a real value on edit ("leave blank to keep as-is").
  • Every successful config write shows a persistent "Config saved — restart required, no hot-reload yet" notice banner in the dashboard, so the restart requirement is visible, not a silent gap.
  • encoderName/renditionSchema widened from module-private to exported in src/config/schema.ts, needed by the new config API for validation and to populate the frontend's encoder-selection options.
  • 9 new backend unit tests (tests/ui/configApi.test.ts) — notably, node:fs is fully mocked so these tests never touch the real config/*.yaml on disk (this project's dev config is shared with whatever else might be running against it).

Verified live (real browser, real file I/O this time — not mocked)

  • Logged into the dashboard, opened the new Configure tab: both existing renditions and legs rendered correctly in tables.
  • Created a new rendition (test-540p, 960x540) through the form: it appeared in the table immediately, the "restart required" notice appeared, and config/legs.local.yaml on disk was confirmed to contain the exact new entry, correctly formatted YAML.
  • Deleted it through the UI: table and on-disk file both returned to their original state.
  • Zero console errors throughout. This also incidentally confirmed the Express route-mounting order (/api then /api/config) works correctly — Express's router fall-through behavior does the right thing here, worth knowing given this project already found one real Express 5 routing surprise in v0.7.0.

v0.8.0 — rtmp-push code path validated end-to-end; real relay-encoder bug found and fixed

Context: three parallel background agents were set to work on the open items from v0.7.0 (jitter root cause, add/edit/remove-leg UI, de-risking Phase 2 without real credentials). All three hit the session's account-level API rate limit mid-task and were cut off before finishing. This entry covers picking up and completing the Phase 2 de-risking work; the other two are covered separately below once reviewed and completed.

Fixed — real bug, found via live testing, not assumed

  • buildRelayArgv unconditionally emitted NVENC-only flags (-tune ull, -rc cbr) regardless of the configured relay.encoder. EncoderName is a schema-level union that explicitly permits libx264/libx265/AMF variants for relay.encoder, but passing NVENC-only flags to any of those makes ffmpeg reject the command outright at launch. This went unnoticed through this entire project so far because every prior test used the schema default (h264_nvenc) — it surfaced the moment a non-NVENC relay encoder was actually tried. Fixed by splitting relay encode-arg construction into per-encoder-family branches (relayEncodeArgs, mirroring the existing pattern in videoEncodeArgs): NVENC keeps -tune ull/-rc cbr; AMF uses -quality speed (ignoring the NVENC-shaped preset value); libx264/265 uses the real -tune zerolatency and no -rc. 2 new unit tests lock in the fix (one per non-NVENC family, asserting the NVENC-only flags are actually absent).

Verified live — the rtmp-push code path, never exercised before in this project

Every leg tested anywhere in this project up to this point was type: "local-file" — the rtmp-push variant (resolveRtmpDestination, buildCopyArgv with an rtmp output sink, secrets resolution from config/secrets.local.yaml) had real code and real unit tests for its pieces, but had never actually been run end-to-end. Built a self-contained validation (spawned its own MediaMTX on port 1965, its own synthetic source, the real buildRelayArgv/buildEncodeArgv/resolveRtmpDestination/buildCopyArgv functions, pushed to a local stand-in "destination" path instead of a real platform, then acted as that platform — recorded 8s from the stand-in path and ffprobe'd it) — confirmed a real, valid, playable H.264/AAC stream was received (1280x720, correct codecs and duration). RESULT: PASS. This directly de-risks Phase 2 (adding the first real platform leg) — the plumbing between config, secrets resolution, and the actual RTMP push is now proven correct independent of having real platform credentials.


v0.7.0 — Phase 6 dashboard frontend, live-verified in a real browser

Added

  • web/ — Vite + React SPA per CLAUDE.md architecture decision #8. Token login gate (paste from state/ui-token.txt, cached in localStorage), a card per rendition (resolution/fps/bitrate/encoder-preference chain, live stats, Stop/Restart), a table of that rendition's dependent legs with color-coded state badges and their own Stop/Restart controls. Live data via WebSocket push with a 5s REST poll fallback.
  • Root package.json: web:install/web:build/web:dev convenience scripts. web:dev proxies /api and /ws to the real backend (port 4771) for hot-reload iteration; production always serves the built static bundle directly from src/ui/server.ts.
  • Removed unused default Vite scaffold assets (hero.png, react.svg, vite.svg, icons.svg) that nothing referenced.

Fixed

  • Real startup-crashing bug, found via live testing: Express 5's router (path-to-regexp v7+) rejects a bare "*" wildcard route — src/ui/server.ts's SPA-fallback route used that syntax (app.get("*", ...)) and crashed the entire orchestrator at startup with PathError: Missing parameter name at index 1: * the moment web/dist actually existed. This had been silently unreachable in all of v0.6.0's "verified live" backend testing, because that testing happened before the frontend was built, so web/dist didn't exist yet and the server took the other branch. Fixed to the named-wildcard form Express 5 requires ("/*splat") and re-verified.

Verified live (real browser, not just curl)

  • Navigated to http://127.0.0.1:4771/ in an actual Chrome tab: login gate rendered correctly, entering the real token from state/ui-token.txt authenticated successfully.
  • Dashboard rendered real live data: both renditions with correct resolution/fps/bitrate/encoder chains, all three legs correctly grouped under their rendition, live-updating stats matching what the orchestrator's own log showed.
  • Clicked "Stop" on local-720p-archive's row: its badge updated to STOPPED within seconds, no page reload, no console errors (read_console_messages confirmed clean).
  • Sibling legs/renditions (which had independently hit their restart cap after the test source ended, showing FAILED) were correctly unaffected by the stop click — the UI accurately reflects true backend state per leg, not a shared/aggregated status.

Still open (per plan Phase 6)

  • Add/edit/remove destination legs through the UI is not built — see v0.6.0 below and CLAUDE.md architecture decision #8 for the honest scope note. Config changes still require hand-editing YAML plus a restart.

v0.6.0 — Phase 6 dashboard backend

Added

  • src/ui/server.ts — Express + WebSocket server, bound to 127.0.0.1 only, serving the REST API and (once built) the static frontend from web/dist.
  • src/ui/auth.ts — local token auth, generated on first run to gitignored state/ui-token.txt, Authorization: Bearer for REST and a ?token= query param for the WebSocket handshake (browser WS clients can't set custom headers).
  • src/ui/liveState.ts — tails today's structured log continuously (byte-offset based, no re-reading) into an in-memory legId -> latest stats map, broadcasting updates over WebSocket. Decoupled from the core supervisor, same pattern as the benchmark scripts' jitter report.
  • src/ui/api.tsGET /api/status (full leg/rendition list with live state + stats, secrets never included since LegConfig only ever stores a destination env-var name, not a resolved value), POST /api/legs/:id/stop|restart, POST /api/renditions/:id/stop|restart.
  • src/pipeline.ts: stopManaged/restartManaged — a manual restart respawns a fresh supervised process reusing the original argv-building closure and (for renditions) the originally-selected encoder, without re-running NVENC session selection (would double-reserve tracker slots otherwise).
  • src/rendition/renditionProcess.ts: extracted buildRenditionEncodeArgv as a pure function, separated from startRenditionEncode's side-effecting spawn — needed so a manual restart can rebuild the exact argv without spawning a throwaway process just to get it.

Fixed

  • Caught and fixed mid-build: an early version of pipeline.ts's restart-descriptor plumbing accidentally called startRenditionEncode (which spawns a real process) just to probe a value, then discarded it — a real bug that would have spawned duplicate rendition-encode processes as a side effect of building an argv string. Caught before it ever ran, via the buildRenditionEncodeArgv extraction above.
  • Real logging accuracy bug, found and fixed: leg_exit's wasExpected field was hardcoded to false unconditionally — a deliberate stop (via the dashboard, orchestrator shutdown, or a watchdog-triggered restart) was logged identically to a genuine crash. Added LegProcessHandle.markExpectedExit(), called by stopLegProcess before it ever writes q/kills the process, so the log now correctly distinguishes "we stopped this on purpose" from "this died on its own."

Verified live

  • Auth: confirmed /api/status returns 401 with no/wrong token, and full data with the correct one.
  • /api/status returns real live stats (fps/bitrate/drop/dup, updating in near-real-time) for every leg and rendition.
  • Stop: stopped local-720p-archive via the API well before the test source ended (to avoid confounding with source-loss crash-looping seen in an earlier, noisier test); confirmed it stayed state: "stopped" with no restart, while local-archive-1/local-archive-2 and both renditions kept running unaffected — failure isolation holds through the dashboard's own control path too, not just process kills.
  • Restart: restarted the stopped leg via the API; confirmed it came back to state: "running" with fresh drop=0 stats within seconds.

Known limitation (documented, not silently omitted)

  • Add/edit/remove destination legs through the UI is not built. Config changes still require hand-editing config/legs.local.yaml/secrets.local.yaml and an orchestrator restart, same as before this dashboard existed. The original locked scope (CLAUDE.md architecture decision #8) named this as first-version scope; it's deliberately deferred rather than rushed, and documented here as a real gap, not claimed as done.
  • Frontend not built yet — the API has been verified via curl only. web/ (Vite + React SPA) is the next piece.

Investigated — comparative benchmark re-run WITH the jitter metric (2026-08-12)

Ran bench:baseline and bench:oneencode back to back, same synthetic 2560x1440@60 source, same ~60s duration, same rendition definitions, using the newly time-windowed printJitterReport. This closes the "still open" item from v0.5.0 below — and the result is not the clean win it might sound like from the architecture alone, reported plainly rather than spun:

leg (end-output stage only) design mean fps CoV (lower = steadier)
archive-1 baseline 62.4 0.049
archive-2 baseline 62.4 0.049
720p-archive baseline 63.1 0.067
archive-1 oneencode 63.8 0.090
archive-2 oneencode 63.8 0.090
720p-archive oneencode 63.8 0.090

At the actual delivered-output stage, this design showed HIGHER jitter (CoV ~0.090) than the naive baseline (CoV ~0.049-0.067) in this run — roughly 60% worse by this metric, not better. Interestingly, the rendition-encode stage itself (rendition-shared-1080p60: CoV 0.045, rendition-local-720p: CoV 0.064) was comparable to or slightly better than baseline's direct-encode legs — so the redundant-encode-elimination isn't the problem. The likely cause is the extra RTMP hop the rendition-dedup design introduces: baseline is ingest → leg's own decode+encode → output (one hop); this design is ingest → decode/relay → rendition encode → rendition-relay publish → leg's stream-copy → output (more hops, more places for scheduling/buffering jitter to accumulate), even though total redundant CPU/GPU work is genuinely lower.

Update, same day: repeated both runs once more — the gap is confirmed real, not noise. Ran bench:oneencode a second time: CoV 0.092/0.092/0.091 for the three archive legs (vs. 0.090/0.090/0.090 first run), 0.045/0.063/0.064 for relay+renditions (vs. 0.045/0.064/0.064 first run). Ran bench:baseline a second time: CoV 0.046/0.046/0.065 (vs. 0.049/0.049/0.067 first run). Both designs are individually highly repeatable — each run lands within ~0.002-0.003 CoV of its own prior run — which means the ~0.025-0.045 gap between the two designs is a genuine, systematic, repeatable effect, not a fluke from a single noisy run. The "no repeat-run noise floor" caveat from the original entry above is resolved: there is a real noise floor (very small, ~±0.003), and the between-design gap is roughly 10x larger than it.

Root cause not yet isolated. Until it's found and addressed, the rendition-dedup and single-decode architecture is CPU/GPU-efficiency-correct and failure-isolation-correct, but confirmed NOT to reduce the frame-pacing/burstiness symptom this project exists to fix — it measurably makes that specific symptom worse in this test, not better, even while using genuinely less redundant compute. This is now a confirmed finding, not a hypothesis, and is flagged prominently in CLAUDE.md.

First root-cause hypothesis tested and REJECTED, same day: FFmpeg's own default RTMP input buffering on the copy leg. Tried adding -fflags nobuffer -flags low_delay to buildCopyArgv's input side (a real, targeted attempt to eliminate one candidate, not left as a TODO). Result was dramatically worse, not better: CoV jumped to 0.153-0.156 and actual playback stalls appeared (min fps dropped to 8-9, mean fps dropped to ~53 against a 60fps target — visible real degradation, not just a worse number). Reverted immediately. Makes sense in hindsight: nobuffer/low_delay trade buffer depth for latency — exactly backwards from this project's own locked priority order (consistency over minimum latency, see PROJECT OVERVIEW's "Success criteria" section). This specific lever is ruled out — the remaining candidates (MediaMTX's own internal buffering/queueing between publish and subscribe, or something else entirely in the extra hop) are still open for a future investigation session.


v0.5.0 — frame-pacing/jitter measurement, pipeline refactor

Added

  • src/legs/statsAnalysis.ts: computeJitterStats — mean/stddev/coefficient-of-variation over a leg's fps sample series, closing the real methodology gap found via recovered planning context (drop=/dup= counters can't see frame-pacing burstiness, which was the original reported symptom). 5 unit tests, including one proving a bursty series (30,90,30,90) is distinguishable from a steady one (60,60,60,60) at the same mean.
  • src/logging/readLog.ts: readFpsSamplesForLegs — reads today's structured JSONL log back and extracts fps samples per legId, decoupling benchmark analysis from the core supervisor (no live-callback threading needed).
  • scripts/reportUtil.ts: printJitterReport — prints a drop/dup + jitter summary table at benchmark shutdown; wired into both scripts/benchBaseline.ts and the newly-created scripts/benchOneEncode.ts.
  • scripts/benchOneEncode.ts created — this was referenced in package.json's bench:oneencode script since Phase 1 but never actually existed; npm start was used as a stand-in. Now runs the real pipeline via the shared src/pipeline.ts module and prints the same jitter report as the baseline script for a direct comparison.
  • Refactor: extracted src/index.ts's pipeline-building logic (relay, decode/relay, rendition grouping, NVENC tracking, leg supervision) into src/pipeline.ts: startPipeline, shared by both index.ts and benchOneEncode.ts — the benchmark now exercises the exact same code path as production instead of a parallel reimplementation.

Verified live

  • Ran bench:oneencode against the synthetic source; confirmed the refactored pipeline behaves identically to before (rendition dedup, restart/backoff, NVENC tracking all unaffected by the extraction).
  • An extended run correctly hit the rolling-hour restart cap (maxRestartsPerHour: 5) once the source went away and stayed away, marking legs leg_failed_permanent rather than looping forever — unplanned but welcome real-world validation of that Phase 4 logic under a longer-than-usual outage.
  • Verified the jitter report directly against real collected log data: local-archive-1/local-archive-2 (the rendition-dedup pair from Phase 3.5) again reported byte-for-byte identical jitter stats (76 samples, mean 67.9fps, stddev 9.03, CoV 0.133) — an independent reconfirmation of the dedup correctness from a different angle than the earlier file-size check.

Still open

  • The tooling to measure frame-pacing jitter now exists, but the actual comparative baseline-vs-design benchmark re-run using it — to properly re-evaluate Phase 1's "more nuanced than a clean win" finding — has not been performed yet.

v0.4.0 — NVENC probe, platform profiles, recovered planning context

Added

  • src/nvenc/sessionTracker.tsNvencSessionTracker + selectEncoder walk a rendition's encoderPreference list, skipping NVENC entries once the probed ceiling is reached (falls back to a conservative default of 3 with a loud warning if the probe hasn't been run yet), logging an encoder_fallback event on any actual fallback. Wired into src/index.ts — encoder choice is made once per rendition at orchestrator startup (a scoped simplification: not re-evaluated on every crash-restart). 7 new unit tests. Live smoke-tested: pipeline runs unaffected with the tracker in place (ceiling of 16, only 2 NVENC sessions in use, no fallback triggered, as expected).
  • src/nvenc/probe.ts (npm run probe:nvenc) — empirically probes this machine's real concurrent NVENC session ceiling by holding synthetic h264_nvenc encodes open one at a time until a launch fails, writing the result to gitignored state/nvenc-ceiling.json. Run against this real machine (RTX 2080 Ti, current driver): no failure was hit up to 16 concurrent sessions — this driver has no observable NVENC session limit at that count, well above any realistic leg count for this project. ceilingIsExact: false in the state file reflects that the true ceiling (if any) is higher than 16, not that 16 is confirmed as the hard limit.
  • config/platformProfiles.yaml — committed reference table of Twitch/YouTube Live/Kick/Facebook Live's published recommended encode settings, per CLAUDE.md architecture decision #10. Not yet wired into the config loader or a UI (lands with Phase 2/6). Each entry carries a confidence rating and sourcedDate — compiled from general knowledge of each platform's public docs, not a live fetch; flagged explicitly in the file's own header as needing periodic re-verification.

Docs — recovered planning context folded into CLAUDE.md/AGENTS.md

A prior ChatGPT planning conversation (read in full via a forked sub-agent, 2026-08-13) turned up decisions and diagnostic history not yet captured anywhere in this repo:

  • Dual-PC deployment is the primary target, not single-PC (gaming PC runs only game/capture; a separate streaming PC runs OBS + OneEncode, keeping all transcode load off the machine actually running the game). Single-PC, which is what this project's own development/testing has used so far, is a supported fallback — flagged so Phase 1's test results aren't assumed to transfer directly without saying so.
  • Explicit success-criteria priority order locked: no dropped frames > game/OBS unaffected > every platform gets its required format > stable/consistent output (a steady 2s beats 2s→2s→6s→2s even at similar average) > low latency > minimum latency. ~3s end-to-end latency is an accepted cost of consistency. The actual bar: the viewer must never notice.
  • Twitch designated as the first real platform for Phase 2 — described as the fussiest of the majors, treated as the reference case rather than whichever is easiest to wire up first.
  • Real diagnostic history surfaced a gap in Phase 1's own benchmark methodology: the original symptom (before this project existed) was frame-pacing/burstiness — OBS's average FPS didn't match visibly smooth playback, and capping framerate (not resolution) was what actually helped. drop=/dup= counters, Phase 1's primary metric, measure counts, not timing consistency, and can show drop=0 on a run that still stutters. Documented in CLAUDE.md §8 as a known, not-yet-closed gap — a frame-interval-variance/jitter metric is needed before the fix can be called fully proven.
  • A future architecture direction was captured (not built, not yet scheduled to a phase): a shared upscale stage before the rendition split (only upscale when source < target, shared across renditions that need it — same "decode once, branch many" family as rendition dedup), plus workload staggering and small deliberate buffering as a delivery "shock absorber" in service of the consistency-over-latency priority above.

v0.3.0 — Phase 3.5: rendition-level dedup

Added

  • Config schema split: renditions (what to encode — resolution/fps/bitrate/codec) are now separate, named, reusable objects; legs (where it goes — a platform push or local file) reference a rendition by renditionId instead of inlining their own encode spec. Schema-level validation rejects a leg referencing an unknown rendition, and duplicate rendition/leg ids, before anything starts.
  • src/rendition/group.tsgroupLegsByRendition (pure, unit tested) groups enabled legs by shared rendition, buildRenditionUrl derives each rendition's MediaMTX path from the existing relay URL's host/port.
  • src/rendition/renditionProcess.ts — one supervised encode process per unique rendition actually referenced by an enabled leg, publishing to rtmp://<host>/rendition/<id>.
  • src/legs/argvBuilder.ts: buildEncodeArgv (generic decode+scale+encode, replaces the old leg-specific buildLegArgv) and buildCopyArgv (cheap -c copy stream-copy, used by every destination leg now that encoding happens once per rendition instead of once per leg).
  • src/index.ts rewritten around the two-stage pipeline: group legs by rendition, spawn one supervised rendition-encode per unique profile, spawn one supervised stream-copy leg per destination.
  • config/legs.example.yaml and config/legs.local.yaml updated to the new schema, both including a deliberate dedup case (two legs sharing one rendition) so the mechanism is demonstrated, not just described.
  • 4 new unit tests for rendition grouping/URL derivation; argv-builder tests updated for the new function signatures. 22 tests total.

Verified live

  • Configured two local-file legs (local-archive-1, local-archive-2) against the same rendition id. Log confirmed exactly one rendition-shared-1080p60 encode process ran while both legs' stream-copy processes reported byte-for-byte identical bitrate at every sample. Output files confirmed byte-identical via ffprobe/file size (23,817,338 bytes, 32.021167s duration, both files) — not just "close," the exact same encoded bytes remuxed twice.
  • A third leg on a different rendition (local-720p) correctly got its own independent encode process, confirming dedup only collapses genuinely identical profiles, not everything.
  • When the upstream source stopped (test script's configured duration elapsed), the whole chain (rendition encodes → dependent legs) correctly entered its restart/backoff loop rather than hanging or crashing the orchestrator — no special-case coordination code needed, since a rendition-encode outage is just "connection failed" from a dependent leg's point of view, handled by the same retry logic every leg already has.

v0.2.0 — Phase 4: health/restart supervision

Added

  • src/health/monitor.ts: superviseLeg — full leg lifecycle supervisor superseding legProcess.ts's narrower spawnLegWithRetry. Owns spawn, initial-connection-race retry, ongoing crash restart with exponential backoff (computeBackoffMs), a watchdog restart if no stats sample arrives for 20s while the process is still alive (some ffmpeg failure modes hang rather than exit), and a rolling-hour restart cap (isOverRestartCap) past which a leg is marked failed and surfaced loudly instead of looping forever. Each leg's supervisor state (attempt count, backoff, restart history) is fully independent of every other leg's.
  • src/index.ts, src/ingest/decodeRelay.ts, and scripts/benchBaseline.ts all now use superviseLeg in place of the old retry-only wrapper. Local-file legs now regenerate their timestamped output filename on every restart (via a buildArgv closure re-invoked per attempt) rather than reusing a precomputed path, so a restart can't reopen/overwrite a prior partial file.
  • 7 new unit tests for the two pure decision functions (computeBackoffMs, isOverRestartCap) — 18 tests total now passing.

Verified live

  • Killed one running leg's ffmpeg process directly (local-720p, mid-stream) while the relay and two other legs kept running. Confirmed via the structured log: only local-720p recorded a leg_exit (exitCode: 1, uptime matching the kill) — relay, local-1080p, and local-source-res recorded zero additional exits in that window and kept producing drop=0 stats samples throughout. The killed leg logged leg_restart with backoff and resumed normal stats output afterward. This is the concrete failure-isolation proof the project's own rules require (CLAUDE.md §5/§8) — not just assumed from the architecture.

v0.1.0 — Phase 1: single-decode/multi-leg mechanism

Added

  • Node.js/TypeScript orchestrator skeleton (src/index.ts) — loads config, starts the local MediaMTX relay, starts the single decode/relay FFmpeg process, starts one FFmpeg process per enabled destination leg, logs structured JSON-Lines events, shuts down cleanly on SIGINT/SIGTERM.
  • Config schema + loader (src/config/) — zod-validated YAML leg configuration, gitignored real config (config/legs.local.yaml, config/secrets.local.yaml) with committed placeholder templates (config/legs.example.yaml, config/secrets.local.example.yaml).
  • FFmpeg argv builder (src/legs/argvBuilder.ts) — builds per-leg and relay FFmpeg command lines as argument arrays (never shell strings), covering NVENC/AMF/libx264 rate-control variants.
  • FFmpeg -stats parser (src/legs/statsParser.ts) and structured logger (src/logging/logger.ts) with mandatory secret redaction (src/logging/redact.ts) applied to every log write.
  • Retry-until-ready process wrapper (originally src/legs/legProcess.ts: spawnLegWithRetry, since superseded by the full supervisor in v0.2.0 below) — used by both the decode/relay process and every destination leg, to tolerate real startup ordering (source, relay, and legs may become available in any order).
  • MediaMTX (v1.20.0) vendored as the local relay server, with a minimal RTMP-only config (config/mediamtx.yml).
  • Synthetic 2560x1440@60 test source generator (scripts/genTestSource.ts) and a naive-baseline benchmark script (scripts/benchBaseline.ts) for before/after comparison.
  • 11 unit tests covering argv construction, stats parsing, and secret redaction.

Fixed (found via live testing, not assumed)

  • Real secret-leak bug: redactObject() originally only redacted strings sitting directly under secret-sounding object keys, so a raw destination URL (with stream key) passed as a positional FFmpeg argv element — as leg_start events do — was never redacted. Fixed by scanning every string value for an rtmp(s):// pattern regardless of position, with the key-name check kept only as a secondary fallback for non-URL secrets. Caught by a unit test before this ever reached a real log file.
  • MediaMTX rejects a reader immediately ("no stream is available") when nothing is publishing yet, rather than blocking until a publisher appears — confirmed live. Both the decode/relay process and every destination leg now retry with backoff until their upstream becomes available, instead of treating "not live yet" as a fatal error.
  • Orphaned MediaMTX process observed when the orchestrator's outer process was terminated abruptly (not via its own SIGINT handler) — stopRelayServer now does a Windows tree-kill (taskkill /T /F) by PID rather than a bare child.kill().
  • Missing paths: section in the initial mediamtx.yml caused every RTMP path (including the intended dynamic /ingest/live and /relay/live) to be rejected outright; added the all_others catch-all.

Verified live

  • Sustained ~50s run: decode/relay process + 3 destination legs (1080p/NVENC, 720p/AMF, source-resolution/NVENC) all running concurrently, drop=0 dup=0 throughout, speed consistently >1.0x (real-time-plus).
  • 3 concurrent NVENC sessions + 1 concurrent AMF session ran simultaneously on this machine (RTX 2080 Ti + AMD integrated graphics) without hitting any session-limit error.
  • Graceful shutdown via a real SIGINT correctly sent FFmpeg's q quit key to every process; all legs exited with code 0, and all three output .mp4 files were confirmed valid (correct resolution/framerate/duration, both video and audio streams present) via ffprobe.

Investigated — Phase 1 benchmark result is more nuanced than a clean win

Ran the naive baseline (3 independent FFmpeg processes, each pulling and decoding the same synthetic 2560x1440@60 source directly) against the new single-decode design, same leg definitions, same source, ~45-50s each.

  • Both approaches sustained drop=0 dup=0 at 3 legs on this machine (Ryzen 5 9600X, RTX 2080 Ti). This specific test, on this specific hardware, did not reproduce the dropped-frame symptom the project exists to fix — the machine is simply powerful enough not to show it yet at this leg count with a local synthetic source.
  • GPU decode-engine utilization was NOT meaningfully lower in the new design (~59-61% vs. baseline's ~50-57%) — not the reduction expected going in. Root cause: every leg still has to decode its own input before it can scale/re-encode, whether that input is the original ingest (baseline) or the local relay's mezzanine stream (new design) — RTMP always carries compressed video, so a receiving FFmpeg process cannot skip decoding just because another process already decoded the same content once. The architecture's actual saved cost is specifically N redundant decodes of the original external ingest collapsing to one; in this test, both the original ingest and the relay were on localhost loopback with an already-fast synthetic source, so that specific saved cost was cheap either way and didn't show up as a visible difference. This nuance was actually anticipated in the implementation plan itself (C:\Users\kyesa\.claude\plans\modular-brewing-dragon.md, NVENC/mezzanine section) but is being called out here because the live numbers confirm it rather than just theorize it.
  • Implication for next steps: this benchmark needs to be re-run under conditions that actually stress the thing being fixed — a real capture-card/OBS source (not local synthetic), more simultaneous legs, and/or this pipeline running on weaker hardware than this development machine — before the dropped-frame fix can be called proven. Worth separately considering explicit hardware-accelerated decode (-hwaccel cuda) on each leg's own relay-read step, which wouldn't eliminate the "each leg decodes its own input" reality but would make each of those decodes cheaper than the current CPU/software decode path every leg is on today.

Docs

  • CLAUDE.md/AGENTS.md updated throughout with the locked architecture decisions and the local dashboard addition (see git history for the specific dated entries).