Skip to content

Plan 184: Videorc Cloud (in progress, DO NOT MERGE) - #727

Draft
TheOrcDev wants to merge 43 commits into
mainfrom
plan-184-videorc-cloud
Draft

TheOrcDev wants to merge 43 commits into
mainfrom
plan-184-videorc-cloud

Conversation

@TheOrcDev

@TheOrcDev TheOrcDev commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Draft, do not merge. The owner asked to prepare this and keep working on it for a while (2026-10-16).

Plan 184 builds Videorc Cloud:

  • Your show runs on Videorc's servers, so it stays on air when the computer dies.
  • Guests join talk shows from a browser.
  • The phone can be the camera and the remote.

What's here (Phase 1, the solo show)

  • plans/184-videorc-cloud.md is the plan: Phase 0 spikes, four phases, the Phase 1 wire contract and a Progress table.

  • Backend (C1 to C3). cloud_show.rs holds:

    • the web client;
    • the control socket, with a ping every 5 s and reconnect with backoff;
    • the state machine;
    • the RPCs cloud.status, cloud.settings.set, cloud.allowance.refresh, cloud.show.resume and cloud.show.end, and the event cloud.state.

    The recording.rs seam swaps the session's platform targets for one RTMPS feed per orientation. It hands the platform keys to the runner backend to runner, never through the renderer.

  • Go Live (C4).

    • A Go Live that expected the cloud and can't get it streams directly and says why once.
    • Basic users, and Premium users while the rollout switch is off, see nothing new.
    • The primary button reads "Connecting to Videorc Cloud…" while a cloud start runs.
  • Resume (C5). If a show is still live on our servers and this computer isn't feeding it, Studio says "You're still live through Videorc Cloud." with the hold countdown.

    • Resume is Go Live, bound to that show.
    • End livestream asks first.
    • The ingest comes back over the authenticated socket (ingest.get), because the web never stores ingest keys.
  • Livestream → Setup (C6). A Videorc Cloud section: the switch, the month's hours, the Be right back image (the card or a takeover) and the hold limit. It's locked with one reason for Basic (the Premium sentence) and for a paid account the cloud isn't on for yet.

  • Stream Manager (C7). Stream health reads "Cloud · 6 Mbps", "Be right back · 28 min left" or "Cloud resuming", with one Videorc Cloud row on hover above the runner's per-platform rows.

  • Plan 185 T4.

    • The account menu reads Max for a Max account.
    • Upgrade messages recognise "Videorc Max".
    • The plan and cloud blocks parse with a shared fixture in both languages.

Cross-repo wire review (2026-10-17)

A read-only, field-by-field check of desktop, web and node found five mismatches. They're fixed in all three PRs.

  • Desktop side of the fixes:
    • It waits 20 s for welcome (was 5 s), because a fresh runner opens its socket only after its engine starts.
    • The create call gets 20 s.
    • A Go Live that meets a show its node already ended closes it and starts a new one, instead of failing.
  • Not yet proven: only a run proves RTMPS from the real desktop against the node's certificate, and start-up timing. That's C8.

smoke:cloud-solo (C8): the app, the node and the runner together

pnpm smoke:cloud-solo runs the real dev app against MediaMTX, the node agent and the runner from a sibling videorc-cloud checkout (VIDEORC_CLOUD_DIR). It uses a fake control plane that mirrors the web's routes, node signatures and vcs1 tokens, and two local RTMP receivers. Without the checkout it fails; it never skips.

Feed cut (network pulled 12 s) The computer dies (SIGKILL, relaunch, Resume)
To Be right back 1.53 s 1.65 s
Back on air 4.8 s after the network returns 5.7 s after Resume; resumable 2.8 s after relaunch
Largest receiver gap 34 ms 34 ms
  • Both receivers kept one connection for the whole run, and only one show was created.
  • Stop took 92 ms and ended the show on the web and the node. The recording finalized.
  • No key or token reached the renderer or the log.

Fixes after C8

  • C5b: Resume no longer redoes platform setup. Resume is its own start, cloud.show.resume. It skips the dialog, the preflight and YouTube prepare, sends no destinations.set, and the feeds come from the show's ingest, so a live YouTube broadcast keeps its key.
    • Go Live on a live show still prepares fresh destinations.
    • After a relaunch, Stop relies on YouTube's auto-stop, and YouTube chat isn't attached (no server-side YouTube calls, D13/D15).
  • C1c: the feed checks the node's certificate. Every connect and reconnect of the RTMPS feed checks the chain and the host name (tls_verify=1).
    • The roots are the Mozilla store the app ships, the same one the control socket trusts. Windows uses its own store.
    • A release build refuses an ingest given by IP. Dev builds can trust one extra CA (VIDEORC_CLOUD_FEED_CA_FILE).
    • Platform legs are unchanged.
    • The smoke checks that the right roots pass and that a wrong CA or a wrong name is rejected.
  • client.version is now the app's version (it was the backend crate's 0.9.0).

Known gaps (in the plan)

  • C6b. "Test cloud" isn't built yet.
  • The default Be right back card is drawn in a blocky pixel font. A designed card is next.
  • Not exercised yet: a takeover as the hold image, the vertical leg, and wss with a real certificate.
  • By eye. The owner's check in both themes is owed.

Gates (last run, 76ec6426)

  • Static checks:
    • typecheck and format:check pass. Lint has 0 errors; its one warning is on main.
    • Desktop vitest: 476 files, 4903 passed.
  • Rust: cargo fmt --check is clean, clippy -D warnings passes, and the targeted cloud and FFmpeg-argument tests pass (41). The Windows cross-check compiles, with no new warnings.
  • Smokes:
    • smoke:cloud-solo passes, with the certificate check on.
    • smoke:multistream, smoke:stream-leg-eof, smoke:streaming-secrets and smoke:youtube-quota pass.
    • smoke:recording-matrix: 18 of 18 pass.
    • Earlier, on e9d22bf0: smoke:record-latency passes, with warm click to recording at 71 ms (median).

Companion PRs (also drafts, never merge)

  • TheOrcDev/videorc-web#85, plan-185-max-and-cloud: plan 185 (Videorc Max at $99, cloud hours, billing), the cloud control plane (W1 to W7), and T5's pricing, /max and account pages behind switches.
  • TheOrcDev/videorc-cloud#1, phase-1-runner: the per-show runner, the node agent and the wire protocol, with S0a measured on macOS.

Blocked on the owner

  • A Hetzner account for the media nodes (I1, S0d).
  • A LiveKit Cloud project (Phase 2).
  • DNS for *.live.videorc.com (I5).
  • An update to the YouTube compliance answer before cloud shows with YouTube reach anyone else (D15).

🤖 Generated with Claude Code

The owner asked for a cloud version: streams that survive the computer,
browser guests for talk shows, and the phone as camera and remote. The
plan has Phase 0 (spikes) and four phases: the cloud holds solo streams,
cloud-composed talk shows on LiveKit, the phone, and Max extras. It
includes the Phase 1 wire contract for the desktop, the web and the
private runner repo.

The owner chose every recommended answer on 2026-10-16 and asked to
start executing in draft PRs that are not merged. Plan 185 (the Max
tier) lives in the private videorc-web repo.

Co-authored-by: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

TheOrcDev and others added 28 commits October 16, 2026 23:24
…deorc Max (plan 185 T4)

The capabilities answer gains two optional blocks, `plan` and `cloud`, read
with `#[serde(default)]` and `skip_serializing_if` on every Option so an
older web deploy and a newer one both load (the serde-null contract trap).
Max stays `tier = premium` everywhere; the desktop snapshot carries the plan
as an optional field beside the tier, never as a tier value. The signed token's
optional `plan` claim is read too, with the capabilities' plan as the fallback.

The upgrade-toast matcher also recognises "Videorc Max", with the prefix only:
a bare "max" is a limit ("max 5 destinations"). Shared fixtures carry the Max
capabilities answer, the Premium cloud block and both snapshots; Rust and the
strict TypeScript schemas validate the same JSON.

Co-authored-by: Claude <noreply@anthropic.com>
C2: cloud_show.rs owns the show. That covers the web client (create,
current, end, allowance), the control socket (hello with the vcs1
token, intents with acks, a ping every 5 s, reconnect with backoff) and
the state machine off/connecting/live/hold/resuming/ending/failed. The
RPCs are cloud.status (the only one a remote role may read),
cloud.settings.set, cloud.allowance.refresh, cloud.show.resume and
cloud.show.end. The event is cloud.state, a whole secret-free status.
Direct legs reopen only after the web confirms the show is gone,
because two pushes to one key make a platform drop both.

C3: in cloud mode the session's platform targets become one RTMPS feed
per orientation, {serverUrl}/{streamKey} at each orientation's encode,
lowest bitrate when targets differ. The platform targets go to the
runner in destinations.set, backend to runner, never through the
renderer. They are also listed after the feed rows in the session's
leg state, so FFmpeg leg positions stay exact. apply_cloud_leg_states
moves those rows with the runner's per-platform state.

C4/C5: Go Live asks the cloud first and streams directly, saying why
once, only when the account expected the cloud. A Basic user, or a
Premium user while the rollout switch is off, sees nothing new. Resume
after a relaunch fetches the ingest over the authenticated socket with
the new ingest.get intent (Wire shape, 2026-10-17): the web never
stores ingest keys and this computer never persists them.

Gates: cargo fmt, clippy -D warnings, targeted cloud_show and seam
tests (16), desktop vitest (4882 passed), typecheck, lint (0 errors),
format:check and smoke:streaming-secrets all pass.

Co-authored-by: Claude <noreply@anthropic.com>
…notice and the Go Live cloud step (plan 184 C4-C7)

Contracts: cloud.status, cloud.settings.set, cloud.allowance.refresh,
cloud.show.resume and cloud.show.end in the RPC map, the cloud.state
event, and strict schemas for CloudStatus and CloudSettingsPatch. A
shared fixture block (protocol-fixtures/high-risk-contracts.json, cloud)
round-trips through both languages, so a renamed field or a null where
the schema says absent fails in a unit test first.

C6: Livestream -> Setup gains a Videorc Cloud section beside Output
quality: "Stream through Videorc Cloud" with the month's hours, the
image viewers see if this computer drops (the Be right back card or a
takeover), and when the stream ends (5 min, 15 min, 30 min, 1 hour).
Locked means disabled with one reason. A Basic account gets the Premium
sentence with View Premium over switched-off controls. A paid account
the cloud isn't on for yet says so with no Premium prompt. The page's
session lock now names Videorc Cloud.

C5: a show still live on our servers while this computer isn't feeding
it puts "You're still live through Videorc Cloud." above Session, with
the hold countdown. Resume is Go Live, which the backend binds to that
show. End livestream asks first, like every in-app stop.

C4: the Go Live primary reads "Connecting to Videorc Cloud..." while a
cloud start runs. The provider holds the cloud status in its own
context, so status ticks during a show re-render only what reads them.
It toasts a direct fallback once per Go Live, and a lost show once.
Errors go through toastUserError.

Gates: typecheck, lint (0 errors; the one warning is on main), format:
check, desktop vitest (476 files, 4900 passed), renderer asset budget
(pass), smoke:start-labels, smoke:streaming-secrets, cargo fmt, clippy
-D warnings, targeted cloud tests (25).
…e left (plan 184 C7)

The Stream Manager's dashboard carries the Videorc Cloud show for its own
session: cloud.state folds into LiveDashboardState.cloud (phase, and the
hold's seconds left), cleared by another session's status or the cloud
going off, and validated before main caches it.

Stream health stays one stat in the 32 px bar. Healthy through the cloud
it reads "Cloud · 6 Mbps". While the cloud holds the show it reads "Be
right back · 28 min left", which outranks the upload's own reconnecting
row, and while resuming "Cloud resuming". On hover the upload rows fold
into one Videorc Cloud row with the show's state ("On air", "Be right
back" with "Ends the stream in 28 min"), above the runner's per-platform
rows. A direct session reads exactly as before.

Gates: typecheck, lint (0 errors), format:check, desktop vitest (476
files, 4902 passed).
Max is Premium plus a plan (plan 185 D2): the tier stays premium and the entitlements snapshot's plan names it. The shell context carries the plan to the account menu's badge, which reads Max for a Max account and Premium otherwise; a stray plan never relabels Basic.

Gates: typecheck, lint (0 errors), format:check, desktop vitest (476 files, 4902 passed).
… (plan 184)

- The first welcome may take 20 s (was 5 s). A runner the create just
  spawned opens its socket only after its engine starts, and the node waits
  up to 15 s for it behind the hello. Before, a slow start fell back to
  direct as an unreachable node.
- Creating a show gets 20 s (was 10 s). The web waits up to 8 s for the node,
  plus its own work.
- A Go Live that meets a show its node already ended closes it on the web
  and starts a new one, once. Before, the web's stale row answered "show
  live", the desktop adopted it, the node said no such show, and Go Live
  failed until the reaper ran. A resumable show that ended while this
  computer was away now starts a new show on Go Live, instead of an error.

Gates: cargo fmt --check, clippy -D warnings, cloud tests (27, two new).
…e runner together (plan 184 C8)

The first run of the real dev app against a node: MediaMTX and the agent
from the sibling videorc-cloud checkout on free ports, a fake control plane
that mirrors the web's routes, node signatures and vcs1 tokens, and two
local RTMP receivers. The feed cut is a TCP proxy that drops the RTMPS path
(the plan's kill-the-encoder would end the recording too, and a MediaMTX
kick is undone by the fifo's 2 s retry); the computer dies with SIGKILL of
the whole app and a relaunch resumes the same show. ffprobe and ffmpeg
check every receiver: streams, duration, the largest packet gap, the hold
card during each drop and motion after each resume.
… setup (plan 184 C5b)

The Studio's "You're still live through Videorc Cloud." notice ran the full
Go Live: the dialog, the preflight, the metadata update and every platform
prepare, so a YouTube sign-in destination got a new broadcast and key on each
Resume, the runner moved its leg there, and the live broadcast and its VOD
split.

Resume is now its own start, `cloud.show.resume`, bound to the show by the
backend. The renderer sends the saved destinations as rows only (no dialog,
no preflight, no account check, no broadcast prepared or activated). The
backend connects to the show, takes the ingest it kept or asks the node for
it (`ingest.get`), feeds every orientation the show has that the session
composes, sends the hold image only if the user picked one, and sends no
`destinations.set`: the runner keeps every leg and key it holds. OAuth
destinations without a key are rows the runner's leg states move, never a
reason to refuse the start. A start that fails after binding the show makes
it resumable again instead of leaving it bound to a session that never ran.
A failed Resume says why under "Couldn't resume the stream".

Go Live while a show is resumable still hands its freshly prepared
destinations over: that is a new broadcast by choice.

Tests: the backend's resume sends no destinations.set, asks only for the
ingest without a hold image, works with no ready destination and feeds per
ingest orientation; the renderer's Resume calls cloud.show.resume and never
streamTargets.youtube.prepare, the preflight or the dialog; smoke:cloud-solo
counts the runner's destinations.set after the Resume click and fails on any.
…me (plan 184 C1c)

FFmpeg's `tls_verify` defaults to 0, so the feed to the node published to any
certificate: whoever answered for the node's name (a spoofed DNS answer on
hostile Wi-Fi) got the show's ingest key and could push their own video into
the show. The control socket already verified; the feed did not.

Every cloud feed leg now carries a TLS check in every output shape the legs
take: the bridge's per-leg fifo (`-format_opts tls_verify=1:ca_file=…`,
re-applied by the fifo on every reconnect), a tee slave's bracketed options,
and the plain `-f flv` output's CLI options. With FFmpeg 8.1's OpenSSL
backend (the macOS and Linux builds) `tls_verify=1` verifies the chain
against `ca_file` and binds the URL's host name (`SSL_set1_host`); the
Windows build's Schannel verifies both against the Windows store and ignores
`ca_file`. OpenSSL's compiled-in default paths point nowhere on a user's
machine, so the backend writes the roots it trusts beside the database
(`cloud-feed-roots.pem`): Mozilla's store from webpki-root-certs, the same
store the control socket and the API client trust. Dev builds may add one
extra CA through `VIDEORC_CLOUD_FEED_CA_FILE`, read like `VIDEORC_API_BASE_URL`
and never in a packaged build. A release build refuses an ingest address by
IP, because FFmpeg skips the name check for a numeric host; a dev build warns.
A roots bundle that cannot be written fails the start before anything reaches
the web. Direct platform legs are unchanged.

Measured with the local FFmpeg 8.1.1 through the fifo muxer: the right roots
pass the handshake, a CA that did not sign the certificate fails it, and a
certificate for another name fails it. smoke:cloud-solo now announces the
node as `localhost` (a DNS name, so the name is checked), trusts the run's
self-signed cert through the dev override, and runs those three FFmpeg
publishes as a gate; it passed with checking on (feed cut held 1.53 s, back
4.81 s after the network; resume 5.74 s; receivers one connection, 34 ms gap).
…e's 0.9.0 (plan 184)

`POST /api/cloud/shows` and the control socket's `hello` reported the backend
crate's version. The web and the node read `client.version`, so they saw
0.9.0 for every desktop. It now comes from `desktop_app_version()`, which
reads the `VIDEORC_APP_VERSION` Electron sets when it spawns the backend and
falls back to the crate's version without it. The node agent logged
`client="desktop 0.9.143"` for the smoke's dev app.
…art changes (plan 184)

The runner now embeds its default hold card's PNGs with include_bytes!
(videorc-cloud b632892f), so new art alone left the smoke on a stale
binary: the staleness check only read .rs and Cargo.toml. It counts .png
under crates/ too.

The hold card check is unchanged. The new card, "Be right back" in Noto
Sans on the dark base, reads 98.8% dark and 0.6% bright on the sampled
720p frame (the bitmap card read 99.2% and 0.4%). smoke:cloud-solo passed:
the feed cut held in 1.53 s and came back 4.81 s after the network; the
computer's death held in 1.65 s and was live 5.76 s after Resume; the card
covered 99.6% and 100% of the hold windows on both receivers; largest gap
34 ms.
…ntory (plan 184 C5)

The execution-policy inventory pins which RPCs own their session lifecycle
instead of the generic mutation deadline. cloud.show.resume has taken that
path since C5 (it is session.start bound to the live cloud show), but the
pinned list never named it, so the test failed in CI on every push since.

Full macOS backend suite: 3524 passed, 0 failed. clippy -D warnings clean.

Co-Authored-By: Claude <noreply@anthropic.com>
…lan 184 C6b)

`cloud.selftest.run` starts a test and answers at once; its progress and
result ride `cloud.state` as `CloudStatus.test`. (A `.test.` segment would
make the RPC admin-only and smoke-only in `backend_authority`, so it is
`selftest`.) The test creates a `kind: "test"` show on the web, connects the
control socket, and pushes 10 s of noisy `testsrc2` and a sine with the app's
own FFmpeg at the stream quality's size, fps and bitrate, over RTMPS with the
node's certificate checked (C1c). It reads the runner's feed kbps (the median
of its 2 s readings) and live state, ends the show on the socket and the web,
and kills its FFmpeg through the child it spawned. The verdict names one
reason: a refused certificate, a push that never ran (FFmpeg missing: this
computer's failure, never the network's), a feed that never arrived, a show
the runner ended, an upload under 85% of video plus audio, or a push that
failed. An upload the node never measured is unmeasured, never slow.

The push lifts VideoToolbox's data-rate ceiling to 140% of the bitrate. At a
stream leg's `-maxrate` equal to `-b:v` the noisy card settles near 70% of the
bitrate (2,951 of 4,160 kbps at 720p30 on an M4), so every test would read as
too slow; at 140% each 2 s window carried 94 to 102% at 720p30, 1080p60,
1440p60 and 1080x1920. FFmpeg's lines are kept redacted: the key replaced,
whole or its secret part, then every URL token dropped, as a session's
stderr tail is.

The test never touches the Studio's session or the slot's show. It refuses
while a session runs or finalizes and while a show is live or resumable. Any
session start stops a running test without waiting; a cloud Go Live and a
Resume wait for its show to end, because the web allows one open show per
account. A 409 that names a test (one a crashed desktop left open; its `kind`
is now parsed) is ended and the create runs once more, for Go Live and for a
new test, never adopted like a live show. `CloudApiError::ShowLive` boxes its
record, which keeps every `Result` it rides in small.

The RPC is in the execution-policy inventory (default mutation) and round-trips
the shared high-risk fixture in both languages (`cloud.statusTest`,
`cloud.testRunParams`).

Full macOS backend suite: 3534 passed, 0 failed (15 ignored). clippy -D
warnings clean.

Co-Authored-By: Claude <noreply@anthropic.com>
A "Test Videorc Cloud" row in the open Videorc Cloud section, a SettingsRow
in its grouped FieldGroup. Test is an outline button that reads "Testing…"
with a silent spinner while the test runs. The row's description says what
the test does ("Sends 10 seconds of test video. Nothing goes public."), the
step under way, then the result: "Videorc Cloud works from here. Live in
2.3 s. Upload held 4.1 of 4 Mbps." A failure the streamer acts on (a slow
upload, an unreachable cloud, a refused certificate, a blocked feed) is the
row's FieldError instead, with one fix each; a refusal (the hour's tests, the
cloud off or full) stays a plain line. The result is never a toast: only a
click the backend refuses reaches one, through toastUserError, as a warning.

Test locks with the section (its LockAlert says why), and is disabled with
one DisabledReason while a recording or livestream runs or a show is live or
resumable. It pushes the shape a show would carry (`cloudTestOutputs`): the
enabled horizontal destinations' stream quality at their lowest bitrate, or
the default stream profile before the first destination, plus the vertical
leg when it is armed.

Co-Authored-By: Claude <noreply@anthropic.com>
…st to its 30 s cap (plan 184 C6b)

Before Go Live, `cloud.selftest.run` pushes 10 s to the same runner with no
platforms, and must pass on loopback: live within 20 s and the upload at
least 85% of the stream quality's bitrate. The fake web learns the test kind
as the web has it (videorc-web 62d4f8ea): a solo show capped at 30 s to the
node, never current, never metered, six an hour with 429 cloud-test-limit,
and its call log now records what a create asked the node for, never the
key. The gates: the test passed, the node was asked for a solo show capped
at 30 s, the web saw the create and the end, current stayed empty while the
push ran, the runner applied no destinations.set, the receivers took
nothing, the desktop's own show never changed, and the node's usage report
for the test was accepted and never metered.

A new step holds the cap end to end: a test show nobody ends (the desktop
died mid-test), fed by the smoke's own FFmpeg with the certificate check,
must end `max-show` on the runner 30 s after its first feed frame, and the
web must close it from the node's heartbeat.

The leak scan now reads every launch's renderer events (it read only the
last launch's) and every show's ingest key, the tests' too. Go Live's create
and the shows created after the death are counted from Go Live on, so the
test shows before it are not counted as this show's. A reused
VIDEORC_SMOKE_OUTPUT_DIR no longer fails the receivers: their files are
removed before they start, where an older run's file used to read as bytes
from the first second.

VIDEORC_CLOUD_SOLO_SHOTS_DIR=<dir> captures the Test row idle, running, with
the result and after a test that failed (FFmpeg missing), each in dark and
in light: onboarding is marked done with one reload before the test, and the
sidebar's theme toggle flips the theme live, so the running state stays on
screen.

Pass on the final code (M4): the test was live 4.55 s after the click with
the upload at 4,130 of 4,000 kbps (plus 160 audio, 99% of the total), the
whole test 10.3 s; the unended test show ended max-show 29.999 s after it
went live and the web closed it; the blocked test read no-feed; the feed cut
held in 1.58 s and came back 3.10 s after the network; the death held in
1.60 s and was live 5.76 s after Resume, 1 show, 0 destinations.set after
it; the hold card covered 100% of both hold windows on both receivers,
largest gap 34 ms; Stop 94 ms; no secret in any launch's events or the app
log.

Co-Authored-By: Claude <noreply@anthropic.com>
C6b is done: the Test row, `cloud.selftest.run`, the web's test kind and
the smoke's Test and cap steps, with the measured numbers. The Wire shape
gains `kind: "test"`, 429 cloud-test-limit, and the 409 body's `kind`, which
the desktop now reads.

Co-Authored-By: Claude <noreply@anthropic.com>
…(plan 184)

The spike's dated note, as Phase 0 asks: the macOS configure change (libsrt
1.5.7, static, one flag), the size delta (+0.66 MB ffmpeg, +0.68 MB ffprobe),
the license check (FFmpeg stays LGPL v3 or later; the bundle adds the MPL-2.0
text and the UDT BSD notice), the Windows and Linux pins (libsrt present but
1.5.5, before the KMREQ CVE fix: re-pin to autobuild 2026-09-30), and the
measurements: an SRT feed ready 0.4 s after the publisher starts against
RTMPS's 1.6 s, 7.9% upload overhead against 4.7%, drops under 5 s kept, and
1% and 5% loss lost nothing at 1000 ms latency. SRT's stream id travels in the
clear, so C1b keys an SRT feed by a passphrase per feed on a MediaMTX path
per feed, never by the ingest key. Plan 184's Progress gains the S0c row.

Co-Authored-By: Claude <noreply@anthropic.com>
…r ingest pair (plan 184)

From the S0c note: SRT sends its stream id in the clear, so no secret may sit
in a feed's path. Ingest on the node becomes one MediaMTX path per feed,
`live/<showId>-<h|v>`, for both protocols: RTMPS carries the ingest key in
the stream key's query, and SRT is keyed by a passphrase per show that the
agent sets on the path through MediaMTX's API. The web mints the passphrase
(never stored) and sets SRT's latency; each ingest pair, in the create
response and in `ingest.get`, gains an optional `srt` block. C1b's slice now
lists the build, leg, probe, redaction, health and smoke work the spike
settled.

Co-Authored-By: Claude <noreply@anthropic.com>
…per show and no catch-all path (plan 184)

The smoke failed at the C1b node and web (runner a7a501c, web 7d8312b9),
in the harness only:

- The node requires srtPassphrase on POST /v1/shows (runner d636ba2,
  docs/wire-notes.md). The fake web never sent it, so every create got a
  400 and the fake web answered cloud-node-unreachable: Test cloud read
  cant-reach. The fake web now does what web 5cbcbce2 does: one 32 base62
  passphrase per show (a test's too) and srtLatencyMs, and the node's srt
  blocks pass through to the desktop with the web's checks (a malformed
  block is dropped by field name and fails the run).
- The template has no catch-all path any more (runner d5eb542), so
  MediaMTX refuses the C1c probe's path right after the RTMP connect and
  FFmpeg says only "Input/output error": the right-roots probe read
  unknown. The probe runs at debug level and classifyFeedHandshake reads
  the RTMP lines, which FFmpeg prints only after the TLS handshake and its
  certificate check passed.
- The template's SRT listener is :8890 on every interface; the run moves it
  to a free loopback port like the other listeners and tells the agent.
- Every show's SRT passphrase joins the leak scan: the desktop gets it in
  the create answer and in ingest.get's ack, even while it feeds RTMPS.

Pass on b6eba69 plus this change: Test live 4.61 s at 4,483 of 4,000
kbps; feed cut held 1.55 s, back 3.26 s; death held 1.66 s, Resume live
5.79 s, 1 show, 0 destinations.set; one connection for 62.1 s, gap 34 ms,
hold card 99.9%; Stop 139 ms. No ingest key or passphrase in the app log,
the renderer events or any node log. pnpm test:scripts 2445 pass, 0 fail.

Co-Authored-By: Claude <noreply@anthropic.com>
…root-certs 1.0.10 (RUSTSEC-2026-0383, plan 184 C1c)

C1c writes webpki-root-certs as cloud-feed-roots.pem, the roots the RTMPS
feed checks a node's certificate against. 1.0.9 still bundles emSign ECC
Root CA - G3; certificate paths through its distrusted intermediates, whose
private keys are now public, would pass that check and hand over the
ingest key. 1.0.10 removes the root (RUSTSEC-2026-0383). Cargo.lock only;
origin/main does not depend on the crate.

pnpm audit:rust now reports what origin/main's lockfile reports and nothing
more: RUSTSEC-2026-0370/0371/0372 (zbus, unmaintained), 0381 (webpki-roots
1.0.7, the roots of the control socket and the API client) and 0382
(rustls-webpki 0.103.15).

Gates: cargo clippy -p videorc-backend -- -D warnings clean; cargo test -p
videorc-backend 3616 passed, 0 failed, 15 ignored (the_roots_bundle_is_
mozillas_store_plus_the_dev_ca included).

Co-Authored-By: Claude <noreply@anthropic.com>
…ream key and srt blocks (plan 184 C1b)

Since C1b (runner 2b10405, web 5cbcbce2) the node's stream key is
<showId>-<h|v>?key=<ingestKey> and every ingest pair, in the create answer
and in ingest.get's ack, may carry an srt block. The tests still used the
old <showId>-h.<ingestKey> and no srt block, so nothing proved this desktop,
which feeds RTMPS only, reads what a C1b node and web send.

- A create answer in the Wire shape's C1b form parses, keeps the stream key
  whole for {serverUrl}/{streamKey}, and its Debug shows neither the ingest
  key, the SRT passphrase nor the token.
- The resume test's ingest.get ack carries an srt block per pair, and the
  handover keeps both RTMPS stream keys whole.
- The cloud seam's fixtures use the keyed stream key, so the feed URL, its
  certificate options and its redaction are checked with the key in the
  query.

cargo test -p videorc-backend: 3616 passed, 0 failed, 15 ignored.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…ost is told (plan 184)

Cloud test lane 2 B1: after a runner crash the agent restarts it in hold
without destinations (they live in memory only), and the desktop never
re-sent them, so every platform stayed dark while the rows still read live.

- The control loop reads the runner's new `destinationsSet` and `restarts`
  (optional: an older runner shows a restart only as a state without legs).
  After its own hand-over was acked, a runner without destinations gets the
  same `destinations.set` again, once per socket; the runner keeps legs whose
  id and URL are unchanged, so this never restarts a live leg.
- The session's rows go to Reconnecting while the legs are gone, instead of
  keeping the old live rows. A show resumed after a relaunch holds no keys,
  so its rows say to stop and go live again, unless the node gives the
  runner its destinations back itself.
- `cloud.state` gains `restart: { at, destinationsResent }`; the renderer
  says "Videorc Cloud restarted your show" once per restart, and once more
  if the platforms could not be reconnected (a refused or silent re-send).
- The reconnect welcome now goes through the same state handler, so its
  legs reach the session too.

Tests: the restart decision table, a restart over the real control loop
(re-send, rows, once per socket, legs back), a refused re-send, a node that
re-sent by itself, and the renderer toast. Existing tests now send the legs
a real runner keeps through a hold and a reconnect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…4 Wire shape)

Cloud test lane 5 S5: the control socket carries the show token and every
platform key in `destinations.set`, and the desktop accepted `ws://` control
URLs and plain `rtmp://` ingest in release builds too.

- `control_url_problem`: a release build connects only to `wss://`; the
  connector refuses anything else before dialing. Dev builds keep `ws://`
  for the local agent the smokes run, with a warning.
- `ingest_url_problem`: a release build feeds only `rtmps://` to a DNS name
  (the old IP-literal refusal moves here); dev builds allow plain RTMP and
  IPs with a warning. The feed builder in `recording.rs` uses it.
- The ingest is now checked right after create, before the control socket
  opens: a node this computer can't feed securely never receives a key, the
  show ends on the web, and the Go Live streams directly with
  `cloud-node-unreachable` (D11).

Both checks take the build kind as an argument, so the release branch is
tested too (lane 5 noted only the dev branch ever ran).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TheOrcDev and others added 14 commits October 21, 2026 11:25
…back (plan 184)

Cloud test lane 3 R1: with `VIDEORC_CLOUD_DISABLED` on, the account's
`cloud` block says not enabled, and the desktop only asked for a live show
at launch when entitled. So a desktop relaunched during a rollback never
learned which show to resume or end, and the runner held Be right back
until its hold limit.

The launch check now asks `GET /api/cloud/shows/current` whenever the
account could own a show: entitled, or on a paid plan whose block says off.
The web answers `current` during a rollback (videorc-web, C-W5).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…gnatures v2 first (plan 184)

`smoke:cloud-solo` signs to the real node agent and checks its heartbeats,
so the fake web must speak what the web and the agent now agree on
(cloud test lanes 3 and 5):

- Node signatures v2: HMAC-SHA256 over `v2\n<direction>\n<METHOD>\n<path>\n
  <timestamp>\n<raw body>`, `X-Videorc-Signature: v2=<hex>`. The fake signs
  `web-to-node` and verifies only `node-to-web` against the route it served;
  v1 is refused. The four shared vectors are pinned.
- The web's replay guard (`node-request-replayed`) and its rule that a
  heartbeat signed no later than the newest applied one is
  `409 node-heartbeat-stale`; the forgotten-show rule reads the signed time.
- Odd frame sizes are a 400 before any node is asked; a node that answered
  but refused is `502 cloud-node-rejected`, apart from an unreachable one.
- A non-loopback agent must hand out `wss://` and `rtmps://`.
- `setDisabled()` is the rollback switch: create is `503 cloud-disabled`,
  `current` still answers, the `cloud` block says off.

The desktop's own mapping needs no change: `502 cloud-node-rejected` is a
5xx and streams directly, and `400 invalid-cloud-show` stays an error; the
classification test now says so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
D18 promises a warning at 11 hours before the runner ends any show at 12;
cloud test lane 5 S8 found none. The cap is the runner's on-air clock,
which `cloud.state` already carries as `liveSeconds`, so the renderer says
once per show: "Your livestream ends in 60 min. A Videorc Cloud show runs
12 hours at most." A relaunch that meets a show already past 11 hours says
it once with the time that is left.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… node by its driver

Cloud test lane 1 E1 and E2, on ogre:

- `cargo test -p videorc-backend` ran the real VAAPI encode probe on the
  first render node; on that boot `renderD128` was the AMD card, whose
  encoder hung and wedged the GPU until a reboot. Test builds now never
  probe a render node unless `VIDEORC_TEST_HARDWARE_PROBE=1` is set; they
  take the software fallback with a reason that says so.
- `docs/linux-dev-loop.md` derived the pin from a hard-coded `renderD128`.
  The numbering changes between boots, so the snippet now finds the i915
  node by its driver and refuses to pin without one.
- A cold `videorc-backend` build after `cargo test` took 20 min 05 s on
  ogre and hit the smokes' 20-minute prebuild limit. The Linux default is
  45 minutes; other platforms keep 20.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… (plan 184 D11)

Cloud test lane 5 S8: the web now refuses an account's 13th show in an hour
with `429 cloud-show-limit` and `resetAt` (failed creates included, after
the one-open-show check). Classified as `Other`, it would have failed the
Go Live instead of streaming directly.

- `CloudApiError::ShowLimit` and the fallback reason `cloud-show-limit`:
  the Go Live streams directly, and the toast says "You've started this
  hour's Videorc Cloud shows. You're streaming directly from this computer
  for now." The shared schema knows the new reason.
- The fake web mirrors the limit (tests keep their own six an hour).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… 184)

Re-running `smoke:cloud-solo` on Linux (ogre, after the cloud test's GPU
incident) failed after the feed cut: the runner held, but the feed never
came back. The raw path (Linux, weak Windows) records and streams through
one `tee`, whose RTMP slaves had no recovery (plan 184 says so: "no
auto-recovery"). FFmpeg logged "Slave muxer #1 failed: Broken pipe,
continuing with 1/2 slaves" and the session went on without its feed, so the
show would hold until its limit. The encoded bridge (macOS) already wraps
each leg in the fifo muxer, which is why the M4 runs came back.

A cloud feed's tee slave now runs inside the fifo muxer (`use_fifo`) with
the per-leg recovery options, shared as `FIFO_RECOVERY_OPTIONS` with the
fifo output. `fifo_options` is a dictionary inside the slave's dictionary,
so its separators are escaped twice. Proven with the bundled FFmpeg 8.1.2
on ogre against MediaMTX: a recording plus a feed through a cuttable proxy
came back 0.04 s after an 8 s cut with the MKV intact, and over RTMPS a
wrong CA is still refused on every reopen while the right one publishes.
Direct platform legs are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…atform has (plan 184)

The capped test show (C6b) publishes with the smoke's FFmpeg, hard-coded to
`h264_videotoolbox`. On Linux that encoder doesn't exist, the publish never
pushed a frame, and the test show ended `no-feed` instead of at its cap,
failing the run. macOS keeps VideoToolbox; elsewhere it's libx264, which
the node's runner already needs from the same FFmpeg.

With this and the raw path's reconnecting feed, `smoke:cloud-solo` passes on
Linux for the first time (ogre, i7-8750H, openh264 desktop encode, libx264
runner, the node at videorc-cloud 8c3d6a7): Test live 4.23 s at 4,251 of
4,000 kbps; the unended test ended max-show 30.04 s after it went live;
feed cut held 1.64 s and back 2.92 s after the network; the death held
1.57 s, resumable 11.8 s after the relaunch, Resume live 6.36 s, 1 show,
0 destinations.set; Stop 1.34 s; both receivers one connection, largest
gap 67 ms; no secret in the app log or the renderer events.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… plan 184)

Cloud test round 2 on ogre: on the raw path (Linux, weak Windows) only the
cloud feed's tee slave had recovery (6c10dc8). Direct platform legs still
sat in the tee's queue-only fifo, so a platform that dropped the connection
stayed gone for the rest of the session, and a lone stream with no
recording used a plain `-f flv` output that ended for good on the first
broken pipe (a cloud feed with recording off included).

- Every RTMP tee slave now sets its own `fifo_options` with the per-leg
  recovery the encoded bridge uses (`FIFO_RECOVERY_OPTIONS`). The MKV slave
  keeps the tee's queue-only fifo, so a failing recording still aborts and
  is never reopened.
- A lone stream uses the same fifo output as a bridge leg. The raw path's
  encode already carries `+global_header`, which the fifo needs: without it
  every reopen published a stream MediaMTX dropped again (seen on ogre).
- The fifo's `Error opening <url>` / `Recovery successful` lines already
  drive the per-leg Reconnecting / Resumed rows (plan 161's monitor reads
  every session's stderr and attributes by URL).
- `smoke:stream-leg-eof` records at the stream's own quality off macOS: the
  raw path's one-encode rule refused its 720p recording beside a 1080p
  stream, so it never ran on Linux.

Proven with the bundled FFmpeg 8.1.2 against MediaMTX through a cuttable
proxy: a lone fifo output came back once, cleanly, after a 6 s cut.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… 184)

Cloud test round 2: a runner that restarts draws its default card until it
gets the custom hold image again. The node now hands back the last one it
relayed (videorc-cloud be842ef, `state.holdImageSet`, C-C11), but its memory
goes with an agent restart.

The show keeps the hold image it handed over, in memory only. On a detected
runner restart (unless the runner already says `holdImageSet: true`), or
whenever the runner says `holdImageSet: false`, the desktop sends it again
after the destinations, at most once per socket. The hand-over socket sends
it itself, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… counts as the web does (plan 184 D11)

- `prepare_session` against the web's `429 cloud-show-limit`: the Go Live
  streams directly with the reason `cloud-show-limit`, nothing connects,
  nothing is ended, and the next Go Live asks the web again.
- The fake web no longer counts a create the node refused or never
  answered toward the 12 shows (or the 6 tests) an hour, as the web does
  since 52038614 (C-W6 update).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n FFmpeg can't count one (plan 184)

Cloud test round 2: on the raw path (Linux, weak Windows) every output is a
tee or fifo, FFmpeg's progress says `bitrate=N/A`, and Stream health read
"Not reported yet" for the whole cloud show. The runner measures the feed
as it arrives (`cloud.state.feeds[].kbps`). The dashboard's cloud block
carries the connected feeds' sum as `feedKbps`, and the health stat uses it
when FFmpeg reports no bitrate: "Cloud · 4.2 Mbps". FFmpeg's own count wins
when there is one; a direct session reads as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With every raw-path leg recovering by itself (332e13f), `smoke:stream-leg-eof`
showed the leg's bytes arriving again while its row stayed Reconnecting. The
fifo's "Recovery successful" is an INFO line, and the raw path's session
FFmpeg ran at `-loglevel warning`, so plan 161's monitor never saw it. A
streaming raw-path session now logs at `level+info`, like the encoded bridge,
through the same stderr relay (INFO stays out of the warning paths, the
startup dump's URLs stay out of the logs). Recording-only sessions keep
`warning`.

`smoke:stream-leg-eof` on ogre: YouTube reported reconnecting 769 ms after
its ingest closed, resumed 1.76 s after it came back, the new stream starts
on a keyframe, Twitch stayed live, Stop 1.24 s mid-retry, the recording
finalized.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…or smoke names a dark display

On ogre the preview asks the desktop portal for a screen at launch; with no
picker the request ends by itself within seconds, and a `session.start`
inside that window is refused with the share-dialog copy (the app's right
answer). `smoke:multistream` won that race by chance and
`smoke:stream-leg-eof` lost it. `startSessionWhenScreenSettles` retries the
start for up to 30 s while that is the only refusal, authorizing the
output directory anew each time (a refused start spends the single-use
capability).

`smoke:freeform-editor` times every gesture to animation frames; a window the
compositor never presents (ogre blanks its panel when the lid closes) got no
frames and failed with a bare "CDP Runtime.evaluate timeout". It now says the
display is dark.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant