diff --git a/.github/ISSUE_TEMPLATE/integrations.yml b/.github/ISSUE_TEMPLATE/integrations.yml
new file mode 100644
index 0000000..9adc3df
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/integrations.yml
@@ -0,0 +1,58 @@
+name: Integrations (experimental)
+description: A bug, a rough edge or an idea for the Integrations tab.
+title: "[integrations] "
+labels: ["integrations"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Integrations are new and experimental, so every report helps shape them. Thanks for testing.
+
+ - type: input
+ id: version
+ attributes:
+ label: InstantClone version
+ description: Top-right of the dashboard, or the `v0.1.15` in the .exe name.
+ placeholder: "v0.1.15"
+ validations:
+ required: true
+
+ - type: dropdown
+ id: kind
+ attributes:
+ label: What is it?
+ options:
+ - Something doesn't work
+ - Something is confusing
+ - An idea or a missing feature
+ validations:
+ required: true
+
+ - type: input
+ id: integration
+ attributes:
+ label: Which integration
+ description: Its name on the card, like "Smart platform alerts" or one you built.
+ placeholder: "Highlight button"
+
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened, or what you'd like
+ description: In your words. Screenshots help a lot.
+ validations:
+ required: true
+
+ - type: textarea
+ id: run
+ attributes:
+ label: The run log (if something failed)
+ description: Open the integration and look at its last runs. Paste the steps and what each one answered. Check it first for links, keys or tokens and remove them.
+ render: text
+
+ - type: textarea
+ id: recipe
+ attributes:
+ label: The integration as a recipe (optional)
+ description: On the card, use Share and paste it here. Recipes never contain your webhooks, keys or tokens.
+ render: text
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bb04c69..38a587e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -111,9 +111,10 @@ jobs:
- uses: Swatinem/rust-cache@v2
# native-tls links system OpenSSL on Linux (schannel on Windows), so the
- # headers must be present for the build to link.
+ # headers must be present for the build to link. ffmpeg lets the
+ # reconnect screen's lossless round-trip test run instead of skipping.
- name: install build deps
- run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev
+ run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev ffmpeg
- name: clippy
run: cargo clippy --all-targets -- -D warnings
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 39d6ce3..fca2c82 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,6 +11,12 @@ on:
permissions:
contents: write # needed to create releases
+# The Twitch app id the Integrations tab logs in with (Device Code flow, a
+# public client: the id is not a secret). Baked in at compile time through
+# option_env!; set it as a repository variable named TWITCH_CLIENT_ID.
+env:
+ INSTANTCLONE_TWITCH_CLIENT_ID: ${{ vars.TWITCH_CLIENT_ID }}
+
jobs:
# Gate 1: tests must pass on the exact tagged commit before we ship.
# This guards against the "tag a commit that was never on main" case
@@ -48,8 +54,10 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
save-if: false
+ # ffmpeg: the reconnect screen's decode tests fail on Linux CI without it
+ # (same deps as ci.yml's test-linux).
- name: install build deps
- run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev
+ run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev ffmpeg
- name: clippy
run: cargo clippy --all-targets -- -D warnings
- name: test
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e4342a2..89272f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,792 @@ All notable changes will land here. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow
[SemVer](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [0.1.15] - Crash protection, Integrations (experimental), vertical without Twitch, and Enhanced Broadcasting on your GPU
+
+### Crash protection: OBS crashes, the stream stays up
+
+When OBS crashed, froze or lost its connection, every destination went
+offline with it, and viewers were gone by the time OBS was back. Turn on
+**Settings -> System -> Crash protection** and they stay live on a
+reconnect screen instead, for up to the time you pick (30 s to 5 min).
+
+- **A reconnect screen, not a black frame.** Five styles: **Whisper**
+ (pulsing dots), **Beacon** (radar rings), **Orbit** (a spinner),
+ **Studio** (a lower third, lifted clear of TikTok's buttons on vertical
+ canvases) and **Arcade** (pixel art). Pick your own accent, background,
+ headline and subline. Settings shows every style as a card with your
+ colours and text on it, plus an animated preview in 16:9 and 9:16,
+ drawn by the same code that streams it. Every style animates in small
+ steps over a small area, so the loop stays light (under 1 Mbps at
+ 1080p). It plays at the resolution each destination receives, with
+ silent audio, after any buffered delay has aired, and timestamps carry
+ on from the last real frame, so platforms see one unbroken stream.
+- **OBS coming back resumes on the same connection.** No reconnect on the
+ platform side: the stream picks up at OBS's first keyframe. With a delay
+ armed, the screen stays up until the delay has rebuilt, so viewers never
+ see live.
+- **Frozen OBS counts too.** OBS still connected but sending no video for
+ 3 seconds opens the same screen, and its next frame closes it.
+- **Enhanced Broadcasting is covered.** Every track of a Twitch multitrack
+ stream gets its own screen at its own resolution, the VOD audio track
+ gets its own silence, and OBS reconnecting is handed the same Twitch
+ session, so the destination carries on instead of restarting. Twitch
+ VOD-audio sessions are kept the same way.
+- **HEVC and AV1 hold the last frame.** The screen is H.264, so those
+ tracks re-send their last keyframe once a second instead.
+- **Stopping in OBS never opens the screen,** even while it is up for a
+ frozen OBS: the stream ends once your delay has aired. Only a drop
+ without a goodbye opens it. **Protect every disconnect** (under
+ Advanced) covers encoders that fail cleanly, like an NVENC error.
+- **End it early from anywhere.** A banner on the dashboard and a strip in
+ the OBS dock show the countdown with **End now** (click twice), the tray
+ menu has **End crash protection** while it is on air, and it can be
+ bound to a global hotkey or a MIDI pad. `POST /crash-protection/end`
+ does the same for a Stream Deck.
+- **You hear about it.** The destination card reads **Reconnect screen**,
+ the tray status shows the time left, and the "Stream alerts" integration
+ posts on Discord when the screen goes on air, when OBS is back, and when
+ it ends.
+- **Good to know.** After a crash, OBS asks whether to run in Safe Mode:
+ pick **Run in Normal Mode**, or your scripts and plugins (the VOD track
+ script too) stay off. The banner says so while it waits. The hold time
+ counts from the crash, so a delay still airing uses part of it. A delay
+ as long as the hold time or longer airs in full, and then the stream
+ ends.
+
+### Integrations (experimental): InstantClone talks to Discord, Twitch chat, your phone and your tools
+
+A new **Integrations** tab. Each card previews its Discord message, chat
+reply, phone push or VOD marker before you switch it on.
+
+Integrations are **experimental**: they work, but how they look and how
+you set them up (mostly the screens) may still change between versions.
+Please try them and tell us what breaks or confuses you: the tab's
+**Report it** link opens an integrations issue form, and a recipe from
+**Share** shows what you built without any of your keys.
+
+- **A catalog of ready-made integrations**, one click to add: a crash
+ alert on Discord, a `!delay` command that always answers with your
+ current delay, "tell chat" when OBS drops (only after 20 s, so short
+ blips never reach chat), VOD markers at every crash and cut, a phone
+ push, a destination-down alert, mod controls (`!cut`, `!setdelay 30`),
+ a webhook for n8n or Home Assistant, a crash counter an OBS text source
+ can show, and more. Starter packs add a few at once.
+- **An editor that is the preview.** Pick a moment (OBS crashes, OBS is
+ back, the hold runs out), write on the message itself, and click a
+ variable to insert it. Every option (where it posts, when it sends,
+ who can use a command, cooldowns) is a chip that opens its own panel.
+- **Build your own, with no limits.** A step editor: any trigger (an
+ InstantClone event, a chat command or message, a timer, or a secret web
+ address a Stream Deck or script can call), checks with then/otherwise,
+ waits, web requests whose answer later steps can use, delay actions,
+ clips, programs, files and counters. Plus the one only InstantClone can
+ do: **wait until the moment has aired for your viewers**, so a `!clip`
+ captures what they actually saw.
+- **Test runs** send the real messages marked `[TEST]`, skip the waits
+ and never touch the stream, then show every step and what it answered.
+ A **dry run** sends nothing at all and shows what each step would have
+ sent, and you can test with your own values ("a Kick drop", "a mod
+ typing !chapter Intro") instead of the samples. A test of a "Kick only"
+ alert reads Kick, not YouTube.
+- **Chat commands that call any API.** Add **Command from a website** and
+ paste the command a site gives you for Nightbot, StreamElements,
+ Fossabot or Streamlabs: the address, the reply and the command name fill
+ in, their variables translated (`$(user)`, `${1}`, `$(touser)`). Every
+ web request has **Try it**: send it for real, see the answer as a tree
+ and click the value you want in the reply. What viewers type is encoded into
+ its own spot of the request, so `!rank` can't be bent into calling
+ something else. Or start from a ready-made one: `!uptime`,
+ `!accountage`, `!followers` and `!game` work in one click, and the
+ preview shows the real answer right away.
+- **One Discord message per drop.** The crash alert posts "OBS dropped"
+ with a live countdown to the end of the hold, then edits that same
+ message to "back after 38 s" instead of adding another. Any Discord step
+ can "edit the last one".
+- **Start an integration from a hotkey or a MIDI pad**, even in game.
+ The new **Clip button** clips the moment (once it has aired, so the delay
+ never makes it clip the wrong seconds), drops the link in chat and says
+ so on stream, from one key.
+- **Show on stream.** Add InstantClone's alerts page to OBS once as a
+ browser source; "Show on stream" steps put a card on it, queued so two
+ never overlap. Tests never put anything on stream.
+- **Edit text** shapes an answer before it goes out: its first line, the
+ part between two texts, find and replace, round a number, group digits,
+ or pick one line at random (an 8-ball in two steps).
+- **React to every step of the delay**: you arm one, it's ready to go on
+ air, it goes on, changes, goes off, or you cancel it before it went on.
+ The new **Delay status** alert keeps one Discord message in step with
+ all of it, and a delay step can now also **disarm** the delay.
+- **Quiet hours** keep an integration silent overnight, `{uses}` counts
+ how often one ran, and every card shows its last runs as a row of dots.
+- **Everything can be taken back.** Every card has a menu (edit,
+ duplicate, share, delete), adding and deleting come with **Undo**, and
+ views opened from the catalog or the simple editor have a back button.
+- **Nothing private on screen by default.** Your phone topic, webhook
+ links, API headers and secret addresses are hidden until you click
+ Show, and addresses elsewhere only show their site.
+- **Twitch: log in once and forget it.** You approve InstantClone on
+ twitch.tv with a code; no password goes through the app. The login
+ refreshes itself, so as long as you open InstantClone at least once a
+ month you never log in again. An optional bot account can post instead
+ of you. Prefer your own Twitch app? Paste its Client ID in **System >
+ Twitch & OBS > Twitch login app**; the steps to make one are right there.
+- **Share integrations as recipes**: a line of text you can paste in
+ Discord. Recipes never include your connections, keys or secret links,
+ arrive switched off, and anything that runs a program or writes a file
+ needs your explicit yes.
+- **Nothing an integration does can touch the stream.** They run on their
+ own thread; a slow webhook or a busy chat never delays a frame, and a
+ failing integration shows up in the tab with the reason instead of
+ failing quietly.
+- **Discord cards.** Any Discord step can send a card: a colored edge, a
+ title, fields side by side, a footer with the time, images, and a line
+ above it where a clip link unfurls into a player. You write on the card
+ itself in the editor, pick its color from swatches, and add or resize
+ fields in place. The catalog's Discord alerts are cards now.
+- **Smart platform alerts.** Quiet on blips: one card when a platform
+ is really down (still down after 60 s), turning green when it's back;
+ one card when it keeps dropping (3 drops in 10 min), updating its count
+ live and turning green once it's steady again; and a health report
+ when the stream ends. Every threshold is yours to change.
+- **Stream timeline.** One Discord card per stream that writes itself:
+ crashes, delay changes, platform outages, highlights and chapters, each
+ with its place in the VOD (the delay is taken into account). Any
+ integration can add a line with the new **Timeline** step.
+- **Highlight button.** A hotkey, MIDI pad or Stream Deck button clips the
+ moment once it has aired, marks the VOD, adds it to the timeline and
+ posts it to Discord. When Twitch can't clip, the moment is still marked
+ and the stream shows "Marked, but Twitch couldn't clip it". A
+ `!highlight` for mods is one switch away, and answers the mod either way.
+ Presses during a long delay queue up instead of being turned away.
+- **Hype clip.** Chat decides when to clip. The new **Chat gets busy**
+ trigger takes rules you stack (busier than normal, how many people,
+ the words they use), all or any of them, over a window you pick.
+ "Busier than normal" is measured against your own chat, so one setting
+ fits a small and a huge channel. A live meter shows where chat is
+ against each rule while you tune it. It fires once per burst, then waits
+ for chat to calm down, so one hype moment is one clip.
+- **Chapters.** Mods type `!chapter Boss fight` to mark the VOD; when the
+ stream ends you get a YouTube chapter list ready to paste, with
+ YouTube's rules applied (the first at 0:00, each at least 10 s, three or
+ more). An optional "Technical difficulties" chapter for crashes goes
+ back to the chapter before once OBS is back.
+- **On-air light.** A light by your door: red when live, amber with the
+ delay on, flashing if OBS crashed, off at the end. Pick Home Assistant,
+ WLED or Philips Hue and paste its address. The light hears the current
+ state as soon as the app starts or the integration is switched on.
+- **Scene delay.** Your OBS scene runs the delay: on in game, back to live
+ in the lobby. It only acts once a scene has stayed on air (3 s by
+ default), so flicking through scenes does nothing, and never during the
+ reconnect screen. Scenes come straight from OBS's own WebSocket server,
+ whose settings InstantClone reads itself: switch the server on in OBS
+ and pick from your scene list. The "back to live" rule is "any other
+ scene", which never counts the game scene, and starting the stream
+ already in game puts the delay on too. Scene and button triggers can be
+ limited to while you stream.
+- **OBS steps.** Switch to a scene, show or hide a source, or change what
+ a text source says, from any integration. Scenes and sources are picked
+ from OBS's own lists, and chat can never choose them.
+- **A builder for real flows.** Steps move into and out of checks with
+ the arrow buttons, any step can be switched off without deleting it,
+ removing a step can be undone, and new blocks land right after the step
+ you picked. Each step only offers the values it can really use, grouped
+ by where they come from, and a step that misses something says what.
+ Saving keeps the builder open.
+- **Times read like times.** Every wait, cooldown and timer takes "10s",
+ "2m" or "1m 30s".
+- **Buttons work everywhere.** A hotkey or MIDI pad trigger is now a
+ **button**: it also gets its own address a Stream Deck, Bitfocus
+ Companion or any program can press. That works on any system, not just
+ Windows.
+- **New moments to react to:** a stream starts or ends (a crash OBS comes
+ back from is the same stream), the on-air state changes, a destination
+ comes back, stays down, keeps dropping or is steady again, and a line
+ is added to the timeline. New variables: `{uptime}`, `{vod_time}`,
+ `{onair}`, `{timeline}` and `{chapters}`. A Discord message can now also
+ be **finished**: edited one last time, and the next alert starts fresh.
+- **New packs:** Stream pro, Highlights and Studio. Every catalog entry and
+ pack shows everything it needs (Discord, Twitch, OBS…) and what's
+ already connected.
+- **Recipes say what they do before you add them:** who can start each
+ integration (anyone in chat, only mods…) and everything it reaches. A
+ program or file on your PC, or a delay anyone in chat can change, shows
+ in red; the delay and OBS in amber. **See every step** lists each step
+ in plain words, checks and their branches included, and **Raw settings**
+ shows exactly what gets added. A recipe can never choose a program, its
+ arguments, a file, a web address or an OBS scene: you pick those
+ yourself after adding it, and the steps say so. The delay action that
+ turns the delay off now says so ("Turn the delay off"); it used to read
+ as if it only cancelled one still getting ready.
+- **Shared Chat is your choice.** While you share chat with other
+ streamers, their viewers can't use your commands or count toward "chat
+ gets busy" unless you switch on "listen to partner channels" in
+ Connections › Twitch.
+- **Clearer editors.** Fields show what they will say (sample values
+ filled in, links and bold drawn like Discord) and turn back into what
+ you wrote while you edit them. Insert buttons say what they hold ("how
+ long it was down") instead of variable names, options read as plain
+ sentences ("Repeat at most: once every 10 s", "Each time: updates the
+ same message"), a card that misses something says what under its name,
+ and the editor's switch says On or Off (draft).
+
+Fixes in the new integrations, found before release:
+
+- A network blip with crash protection off no longer ends the stream:
+ OBS has a minute to come back before the health report, the chapters
+ and a new timeline go out.
+- Stopping on purpose with "hold on every disconnect" is no longer counted
+ as a crash, and doesn't make the on-air light flash.
+- A platform that keeps reconnecting no longer floods the timeline, and a
+ full timeline drops plain lines before chapters and highlights.
+- A "down" or "keeps dropping" card left open when a stream ended is no
+ longer edited from the next stream, far up the channel.
+- Timeline card updates arrive in order, wait out Discord's rate limit
+ instead of failing, and a highlight that finished after the stream
+ ended no longer adds a `0:00` line.
+- A card with only a title or fields can be switched on; the "Ping" option
+ shows when it can actually ping; each words rule counts its own words;
+ OBS's crash reasons are only offered where they can match, and filters
+ take lists and wildcards (`YouTube, Kick`, `*timed out*`).
+- Shared recipes keep the on-air light's states and give buttons a fresh
+ Stream Deck address, and a switched-off trigger or step no longer stops
+ the rest from being switched on.
+- Recipes can never pick an OBS scene or source, or keep a web request's
+ body by claiming to be a light; a card's link and images can't come
+ from chat; a wait filled in from chat lasts a minute at most.
+- An OBS request that timed out is dropped instead of being done late, and
+ a busy chat (a raid) is judged a few times a second instead of on every
+ message. Counters are saved with the rest every few seconds.
+- An OBS drop without crash protection shows in the health report and
+ lights the on-air light, and a moment's reconnect of OBS's WebSocket no
+ longer re-runs the scene delay.
+- Adding a pack before any Discord channel opens the Discord setup right
+ away, and the pack's integrations switch on once it's saved.
+- In the builder, Alt+Up and Alt+Down move the picked step, Ctrl+D copies
+ it and Delete removes it.
+- On a smaller window, the builder's steps keep their text readable: only
+ the picked step shows its move, copy and delete buttons, on a row of
+ their own. Chat-activity rules fit the side panel too.
+- A switched-on integration that pushes to your phone says "Needs your
+ phone" on its card until a phone is connected.
+- `!setdelay 0` turns the delay off. It used to set your default delay
+ instead, while chat said "Delay set to 0s". A "Set the delay" step left
+ blank still uses your default, and a new Mod controls answers
+ `!setdelay 9999` with the 600 s limit instead of claiming it.
+- `!setdelay` takes whole seconds only. `!setdelay 0.5` used to set 1 s
+ and `!setdelay 0.0004` your default delay, while chat said otherwise; a
+ new Mod controls now answers with how to use it, and checks have a new
+ "is a whole number".
+- Removing a Discord channel that couldn't be removed says so, instead of
+ "Channel removed".
+- A card's message box no longer shrinks and cuts its text when the
+ editor redraws while you type in it (most visible on a phone).
+
+Also fixed before release:
+
+- **InstantClone no longer crashes in the first hour after Windows
+ starts.** The Twitch login check worked out "an hour ago" from the
+ time since boot, and that is negative right after a restart. Launching
+ at startup hit it every time.
+- **The Twitch login can't get stuck.** A Twitch request that never
+ answered could stop token refreshes for the rest of the session. Twitch
+ requests now time out. Logging out during a refresh no longer logs you
+ back in. A clip or marker refused for a revoked login checks the login
+ right away. Chat reconnects with the current login instead of the one
+ it started with.
+- **Hotkeys and MIDI pads, sorted out:**
+ - A delay hotkey or pad can't silently take one an integration uses.
+ - An integration can't be switched on with a key the delay already
+ uses.
+ - A key another app holds shows "Key taken" on its card.
+ - A knob or fader fires once per turn, not dozens of times.
+ - An integration's pad works on any controller, not just the one
+ picked in Controls, and learning one can use any controller.
+ - Learning a delay pad in Controls refuses a pad an integration uses,
+ and says which one.
+- **A chat message Twitch refuses fails its step**, with Twitch's reason
+ (slow mode, a duplicate, a ban), instead of showing as sent.
+- **A destination that drops again during the alert cooldown still gets
+ its "dropped" alert** once the 5 minutes are up, so "live" is never the
+ last word on a dead one.
+- **Duplicate makes a copy that starts switched off**, with its own web
+ call link and no hotkey, so one press never runs both.
+- **Cooldowns only start when something ran.** A command skipped during
+ quiet hours, or while the integration was busy, no longer makes the
+ viewer wait. "Busy" runs no longer hide a failing integration, and a
+ flood of them is one line in the log.
+- **A Discord ping is kept when the message it edits was deleted** and
+ has to be posted again.
+- **`{uses}` survives a restart or quit**, and the integrations data file
+ is written safely: a file damaged by a power cut is kept aside as
+ `.bad` instead of being overwritten.
+- **Phones:** the editor's Save button and the builder's close button no
+ longer fall off the screen. The builder scrolls as one page, and the
+ dashboard scrolls normally instead of in a small box.
+- **Modals stay put.** The Connections tabs and opening a chip no longer
+ shift the window, and Esc closes an open chip before the whole editor.
+- **The builder is easier to follow:**
+ - Each trigger's on/off switch and Remove sit right under its name,
+ instead of at the end of the panel, where the confirm overflowed it.
+ - Two triggers alike are numbered, so their tabs never read the same.
+ - Settings for the whole integration (wait between runs, quiet hours)
+ are grouped apart from the trigger's own.
+ - Every block says what it does, and an empty trigger says how to
+ start.
+ - The catalog's tiles no longer cut off their Add button.
+ - Integrations with many moments show them as chips that wrap, each
+ with its switch, instead of a bar running off the edge.
+ - Choices that don't fit a phone's width wrap instead of sticking out.
+- **A delay changed by an integration says "Integration"** in its toast,
+ not "Hotkey".
+- **Smaller fixes:**
+ - A timer switched back on waits a full period.
+ - "Wait for the delay" can't crash on an unlucky tick.
+ - A chat reply starting with `/` stays within Twitch's 500 characters.
+ - Logins in web addresses (`user:pass@`) are hidden like the rest.
+ - Counters named after viewers stop at 5,000.
+- When the Twitch account you logged in with isn't the channel your
+ Twitch destination streams to, Connections says so, and markers and
+ clips show "Other channel" instead of failing with Twitch's "offline".
+
+Your existing Discord webhook moves over by itself: it becomes a Discord
+connection plus a "Stream alerts" integration that sends the same
+messages as before.
+
+Going back to 0.1.14 afterwards: 0.1.14 doesn't know integrations, so it
+stops posting to Discord and drops your integrations the first time it
+saves its settings. Copy `instantclone.config.json` first if you might go
+back.
+
+### Vertical (9:16) no longer needs a Twitch destination
+
+Sending a vertical feed to TikTok, YouTube Shorts or Kick mobile used to
+require a Twitch destination, because the only 9:16 canvas InstantClone knew
+about was Twitch's Dual Format one. Streamers who only go to TikTok (through
+Restream, for example) could not use it at all, and the Vertical option was
+greyed out for them (thanks **fashion** on Discord for the report and the
+logs that tracked it down).
+
+- **OBS's Additional canvas feeds vertical destinations.** With no Twitch
+ destination, InstantClone now builds OBS's Enhanced Broadcasting config
+ itself from what OBS reports, and asks for your Additional canvas (for
+ example Aitum Vertical) when you picked one. Set it once in OBS:
+ Settings -> Stream -> Enhanced Broadcasting, then Additional canvas.
+- **Vertical is always selectable.** The lock that disabled it without a
+ Twitch destination is gone.
+- **A waiting destination says why.** Its card shows **EB off** or
+ **No 9:16 canvas** with the fix written out underneath, the Destinations
+ header counts how many are waiting, and the log says it once per stream
+ instead of the destination sitting silent. With a Twitch destination
+ whose channel has no Dual Format, the log names the vertical
+ destinations that will get nothing.
+- **Twitch 2K channels feed vertical destinations too.** Twitch streams
+ Partners and Affiliates at 1440p in HEVC, vertical track included, even
+ with H.264 set in OBS. InstantClone could only find a vertical track in
+ H.264, so a vertical destination showed "No 9:16 canvas" and got nothing.
+ It now reads the vertical track from Twitch's config, in any codec, and
+ forwards it as Twitch sent it. Restream takes HEVC and converts it to
+ H.264 for TikTok and the rest.
+- **A horizontal destination besides Twitch gets an H.264 main track.**
+ While one is on, InstantClone asks Twitch for H.264, since Kick and most
+ ingests can't play HEVC. Twitch then streams at 1080p instead of 2K.
+
+### Enhanced Broadcasting without Twitch now uses your OBS settings
+
+With Enhanced Broadcasting on, OBS asks InstantClone which video tracks to
+encode. Where that answer comes from depends on your destinations:
+
+- **A Twitch destination is on when you start streaming:** nothing changes.
+ InstantClone asks Twitch, and Twitch's own Enhanced Broadcasting config is
+ used, exactly as when OBS streams straight to Twitch. Twitch picks the
+ codec, resolution and bitrate, and that's the best quality you can get
+ there.
+- **No Twitch destination is on:** there is no Twitch to ask, so
+ InstantClone answers itself. This is the fallback, and it is what changed
+ in this version.
+
+Twitch is only asked at the moment you press Start Streaming. Switching a
+Twitch destination on mid-stream keeps the fallback until you stop and start
+streaming in OBS again.
+
+**What the fallback does now.** It used to make up its own settings: first
+three x264 encodes at once (enough to overload a gaming PC's CPU), then a
+fixed 6000 kbps at your full canvas size plus a vertical track nobody asked
+for. That could look worse than streaming with Enhanced Broadcasting off.
+Now it copies you instead:
+
+- **Your stream settings, as with Enhanced Broadcasting off.** The main
+ track uses what you set in OBS under Settings > Output. In Advanced mode
+ that is your encoder with all its settings, your bitrate and your Rescale
+ Output. In Simple mode it is your bitrate. To change how the fallback
+ looks, change it in OBS as you always would.
+- **A vertical track only when you stream one.** OBS's Additional canvas is
+ encoded only while a destination is set to Vertical, so it costs no GPU
+ otherwise. It gets the same quality per pixel as your main track. It uses
+ your encoder too when every vertical destination plays its codec (HEVC to
+ YouTube or Restream, for example), and H.264 otherwise, which plays
+ everywhere.
+- **If your OBS settings can't be read**, the old defaults are used: 6000
+ kbps for the main track (or the Maximum Streaming Bandwidth you set for
+ Enhanced Broadcasting), on your GPU's H.264 encoder (NVENC, AMF or QSV)
+ when OBS lists it as available, and x264 otherwise.
+
+The event log says which one you got, for example `main track uses your OBS
+stream settings: h264_texture_amf, 6000 kbps`.
+
+### Security
+
+- **Websites can't reach the dashboard any more.** With no dashboard
+ password, a web page could reach InstantClone through DNS rebinding: a
+ domain that re-resolves to your own PC. It could then save an
+ integration that runs a program. The dashboard now answers only when
+ it's opened by its address (`127.0.0.1`, `localhost` or a LAN IP).
+ Opening it by a name needs a password, and a reverse proxy should
+ always have one. Overlays, the alerts page and web call links keep working from
+ anywhere.
+- **Your settings can't be read by other websites.** Every page used to
+ let any website read it. Now only the overlay, alerts and delay-status
+ pages can be read from another site. The config (your ingest key and webhooks),
+ destinations, integrations and logs can't.
+- **Programs and files need a password once the dashboard is on your
+ network.** With the dashboard open to your network and no password,
+ anyone on the network could add a step that runs a program and start it
+ from chat, directly or through an overlay page the streaming PC loads.
+ While the dashboard is open to the network, or reached through a
+ reverse proxy or a port forwarder, Run a program and Write a file can
+ only be added, changed or switched on once it has a password; they show
+ as locked and say why. A web request step aimed at the dashboard itself
+ counts as coming from outside, so it can't set the first password
+ either. With the dashboard only on this PC (the default), nothing
+ changes.
+- **Overlay pages run sandboxed.** An overlay is a page from the overlays
+ folder; it now runs with an origin of its own, so it still draws but
+ can't act as the dashboard. An overlay file opened in the Studio is
+ cleaned first, so one made to break out of its styles can't run code
+ in the dashboard either.
+- **Chat can't run commands through a program.** A value typed in chat
+ stays one argument, and keeps only plain letters, numbers, spaces and
+ `_ . , + = / -`: a step like `cmd /c echo {arg1}` can't be talked into
+ running something else, look-alike letters can't turn into quotes, and
+ no word of it can pass itself off as an option (`--config`, `/s`).
+- **Web request addresses are hidden** until you click Show, like webhook
+ links: they can hold keys (`?key=`, a Hue bridge username).
+- **A stream key under six characters is no longer logged in the clear**
+ when a destination drops. Platform keys are never that short, but a
+ custom RTMP destination takes whatever you paste.
+- **Recipes leave out more**: request bodies and program arguments too.
+ An import is now cleaned the same way, so a hand-made recipe can't
+ bring in a web address, a program or a hotkey.
+- **Web requests don't follow redirects.** A redirect could have pointed
+ a request at the dashboard itself and posted its answer to chat. The
+ step now says where the server moved instead.
+
+### Fixes
+
+- **Stopping in OBS no longer cuts the end of a delayed stream.** With a
+ delay on and no crash protection hold (protection off, or a stop it
+ lets through), the last seconds of the delay used to vanish: viewers
+ never saw your goodbye. Now the rest of the delay airs, then the stream
+ ends. A crash without protection airs what OBS sent before it, too. The
+ dashboard and dock show "Your delay is still airing" with a countdown,
+ and **End now** or **Cut delay** cuts it short. Starting OBS again before
+ it finishes reconnects the destinations at your delay, as before.
+- **Restarting OBS no longer leaves a destination offline for up to 30 s.**
+ After a platform had refused a few connections, the wait between
+ retries stayed long even once it worked, so a quick OBS restart had to
+ sit it out. A stream that ended because of OBS now resets that wait,
+ and one that ends the moment it starts backs off instead of redialling
+ the platform every second.
+- **Nothing airs twice after a freeze you ended.** When you ended the
+ reconnect screen of a frozen OBS (or it ran out) and OBS then recovered,
+ a destination starting afterwards could join on video from before the
+ freeze and replay it, sometimes over and over. It now joins OBS's new
+ video at your delay.
+- **A 9:16 destination stays on when OBS comes back from a crash.** It was
+ ended if its vertical canvas arrived a moment after the main one; it now
+ gets a few seconds' grace.
+- **End now when there's nothing left to end** says so, instead of "Stream
+ ended on every destination".
+- **Discord webhooks work.** The HTTPS client checked certificates
+ against a bundled list instead of Windows' own store, and refused
+ Discord's certificate chain, so every Discord alert failed silently. It
+ now uses the system store.
+- **The "Twitch · mobile risk" chip no longer flickers.** It read the
+ total bitrate one second at a time, so a stream at 8 Mbps bounced
+ across the line with every keyframe, and audio plus the VOD track
+ pushed a 7.8 Mbps video over it. It now reads the video alone,
+ averaged over 10 s, and clears only once it drops back under 7.7 Mbps.
+- **A warning when OBS ignores Enhanced Broadcasting.** OBS only applies
+ Stream settings while nothing is running: tick EB with the Replay
+ Buffer on and the next stream still goes out single-track, with no
+ vertical canvas. When OBS's saved settings have EB on but the stream
+ arrives single-track, the dashboard says so and how to fix it.
+- **Crash protection keeps a new Twitch session when the hold ends first.**
+ If OBS came back and asked for a fresh Enhanced Broadcasting session just
+ as the hold ran out (or you pressed End now), the session was thrown away
+ and Twitch restarted without it.
+- **An OBS that reconnects but never sends a picture** no longer keeps the
+ reconnect screen up forever: after 10 seconds it counts as a freeze and
+ gets the normal time limit.
+- **No backwards timestamps when the reconnect screen starts.** Its first
+ frames are stamped after the newest audio or video already sent, not the
+ last one, which can be a few milliseconds older.
+- **A second Twitch account on a 2K channel** gets an H.264 main track like
+ Kick does, since it streams outside the Enhanced Broadcasting session.
+- **The vertical track survives a stray config request** made with the wrong
+ InstantClone key, and the header's Vertical pill now also recognises an
+ HEVC vertical canvas.
+- **Arcade style draws accents and curly quotes.** "¡Volvé!" and "We’ll" show
+ as VOLVE and WE'LL instead of dropping the letters.
+- **Dashboard:** unsaved System edits survive saving a destination or the
+ password; saving a destination no longer replays the tab's animation;
+ arrow-key style changes show the save bar; Space on a focused button or
+ inside a dialog no longer arms or stops the delay; a log source filter
+ that empties resets to all sources; End now reports failures in the
+ dashboard and the dock; the panel refits when the crash-protection banner
+ appears.
+- **Twitch no longer loops on "connection aborted" after OBS reconnects
+ during crash protection.** When OBS dropped with no Enhanced Broadcasting
+ session to keep and then started a fresh one, the reconnect threw that new
+ session away as stale. Twitch then got the multi-track stream on its plain
+ ingest and refused it on every retry. The same happened when OBS took
+ back the kept session and the hold ran out, or was ended, before OBS
+ started publishing on it.
+- **A frozen OBS that recovers just after crash protection's deadline is
+ reported as ended, not as back in time.** Its first frame could beat the
+ deadline check and post "OBS is back, live again" for destinations that
+ had already ended.
+- **A delay always goes on air, and never shorter than you set.** A 1 s
+ delay, or a 2 s delay with a 4 s keyframe interval, counted as "close
+ enough" to live, so viewers kept watching live while the dashboard said
+ delayed. A delay now lands on the newest keyframe at least that old, so it
+ can run up to one keyframe interval longer than set, never shorter.
+- **Raising the delay on air jumps back once.** It could jump back twice
+ and show viewers the same stretch again.
+- **An OBS restart mid-delay never puts viewers on live.** With crash
+ protection off, destinations came back live and then jumped back once the
+ buffer refilled. They now wait for the buffer to reach the delay.
+- **A destination added mid-delay starts delayed** with long keyframe
+ intervals too, instead of airing live.
+- **A delay longer than the buffer holds no longer gets stuck.** Only the
+ dashboard checked a delay against the buffer size; a hotkey, a MIDI pad,
+ auto-arm or a bitrate that rose after arming could ask for more than it
+ holds, and then the delay never filled: destinations waited forever and
+ ones already on air stalled. A full buffer now counts as ready, the delay
+ runs at what it holds, and the log says so.
+- **A delay no longer replays the same moment over and over** when the
+ encoder's keyframes turn out further apart than first measured (OBS's
+ "auto" keyframe interval, right after a restart).
+- **Crash protection plays out what's airing.** When its time runs out while
+ the delay is still airing, the rest airs in full before the stream ends,
+ as promised. **End now** ends at once. A frozen OBS that outlasts the
+ hold ends the stream too; it used to replay the delay's last seconds in a
+ loop.
+- **Coming back from a freeze keeps the full delay.** The delay rebuilds from
+ OBS's first keyframe, not its first frame, so it no longer rejoins short
+ of the delay.
+- **A destination switched on during crash protection joins the reconnect
+ screen** instead of waiting for OBS.
+- **"Track 2" destinations get only the clean track.** With OBS sending both
+ tracks, a YouTube or Kick destination set to the VOD / clean track also
+ got the live one mixed in, copyrighted music included (since 0.1.13).
+- **The VOD unlocker script works without Enhanced Broadcasting.** With EB
+ off, OBS's own stream setup freed the script's VOD audio encoder right
+ before going live, so OBS sent one audio track and the Twitch VOD had no
+ separate audio. Re-download the script from **System -> Twitch & OBS ->
+ Twitch VOD audio** and replace the old one in OBS. It now also logs a
+ warning when the VOD track goes missing, without needing verbose logging
+ (thanks **adTexaz** on Discord for the report and the logs that tracked
+ it down).
+- **Editing a live destination restarts it cleanly.** A new URL, stream
+ format or audio track takes effect right away with a proper goodbye to
+ the platform. Switching to Vertical mid-stream used to corrupt the picture
+ until the next cut, and audio track changes waited for the next restart.
+- **A platform that accepts and then drops the stream** is retried with a
+ growing backoff instead of every second, and a platform that never answers
+ a connect is retried after 20 s instead of showing "connecting" forever.
+ The reconnect counter now counts only real drops.
+- **Restarting a hung OBS works.** A publisher that stopped sending video
+ hands over to a new one instead of locking it out until its connection
+ dies.
+- **A stopped destination no longer holds the buffer back**, which let the
+ buffer grow to its full size.
+- **Hardened ingest.** Oversized commands, half-sent messages and idle
+ connections that never publish are cut off before they can use lots of
+ memory, and refused publishes are logged at most every 10 s. The local
+ test sink now listens on this computer only.
+- **Keyframes are read more carefully:** an encoder's end-of-stream marker is
+ no longer taken for a keyframe, and streams with short NAL lengths get cut
+ points. Custom RTMP servers that reject part of the publish setup
+ InstantClone sends (some answer it with an error) no longer fail the
+ connection.
+- **No backward timestamp when OBS reconnects within half a second**, and a
+ new stream header mid-stream now goes out before the frame that needs it.
+- **Your settings survive a settings file that can't be read.** A file
+ saved from Notepad as ANSI with an accent in it, or one briefly locked by
+ antivirus or OneDrive, used to start InstantClone on defaults and save
+ them over every destination and stream key. Now a stray byte only costs
+ that character, a locked file is retried, and a file that still can't be
+ read is copied to `.unreadable` and never overwritten at startup.
+- **A deleted destination stays deleted.** Removing your last destination
+ brought back an enabled "Main" one on the next launch.
+- **A malformed video frame can no longer close InstantClone.** A frame
+ ending in an empty NAL unit crashed the app mid-stream. The RTMP ingest
+ also no longer reserves memory for data a client only announces.
+- **Enhanced Broadcasting destinations start on a clean picture.** A
+ destination joining a multitrack stream could start on another track's
+ keyframe and show blocky video until the next one.
+- **A broken control request no longer disarms the delay.** A `POST /arm`
+ with an unreadable body, or one cut off before its end, used to run as if
+ it had no arguments. It now gets an error and changes nothing.
+- **The OBS dock and Stream Deck token no longer see custom RTMP URLs**,
+ which often hold a stream key. The dashboard still shows them for editing.
+- **OBS's service list stays valid.** Registering InstantClone into an OBS
+ with no services wrote invalid JSON, a failed refresh could list
+ InstantClone twice, and every refresh added a blank line.
+- **The OBS VOD track setting sticks** when OBS's settings file has the same
+ section twice.
+- **Update checks see every newer version**, including `beta.10` after
+ `beta.9` and versions tagged with build info.
+- **Custom RTMP destinations accept IPv6 addresses** like
+ `rtmp://[::1]:1935/app/key`, and a URL with no stream key is reported
+ instead of publishing with an empty one.
+- **Servers that send AMF0 dates or long strings** in their replies no
+ longer drop the connection.
+- **No false "1920p" warning for Kick** when the main canvas is portrait.
+- **Ending a stream now reaches the platform as a clean stop.** InstantClone
+ sent the platform its goodbye and closed the connection straight away,
+ and the closing could throw that goodbye away before the platform read
+ it, so the platform saw a dropped stream instead. It now closes only
+ after the platform has read it (or after 1 second).
+- **The old "VOD + EB" desktop shortcut no longer edits OBS 32.2+.** On
+ those versions it can't turn on Enhanced Broadcasting for Custom RTMP, so
+ it now only opens OBS and the log points to the InstantClone service and
+ the VOD unlocker script.
+- **Editing a Twitch destination could reset its pinned ingest to Auto.**
+ The first time the editor opened after loading the dashboard, the region
+ list arrived after the saved value was applied, so saving wrote Auto.
+- **The Twitch mobile-risk pill never appeared.** It looked for a platform
+ field the live state doesn't carry.
+- **The VOD + EB setup button reset a destination's audio track** to Auto.
+- **The header's Vertical pill stayed on after OBS stopped.**
+- **Destination save errors are shown as plain text,** and a save that
+ gets no answer from the app says so instead of doing nothing.
+- **Hovering a blue button turned it grey** with near-invisible text, on
+ every primary button in the dashboard (Save, Arm, Add destination...).
+ Amber warning buttons lost their colour on hover the same way.
+- **Buttons hovered green while a delay was arming.** The hover colour
+ blended the accent with the stream state's colour, and cyan plus amber
+ came out green. Each state now hovers in one clean colour: amber while
+ arming, green while active, the accent otherwise.
+- **The bitrate stuck at its last value after OBS stopped** (the header and
+ graph kept showing, say, 17.40 Mbps with OBS offline). It now drops to 0
+ a couple of seconds after data stops, and a destination's rate does the
+ same.
+- **"… Applying" lingered for 2.5 s after Cut or Disarm** even when the app
+ answered at once. It now clears as soon as the new state arrives, and
+ reads "Applying" with the dots used elsewhere. The OBS dock no longer
+ flashes Arm for a moment after Cut delay (the delay stays armed, so it
+ goes straight to Activate).
+- **Idle destination cards squeezed their message** into a narrow column
+ beside the counters. The counters now drop to their own line.
+
+### Polish
+
+- **System is organized by topic.** General, Crash protection, Controls
+ (hotkeys and MIDI), Twitch & OBS, Access (ingest key, password, ports,
+ LAN), Alerts & logs, and About. Behavior used to hold six unrelated
+ things while three tabs held one each.
+- **Find a setting.** Type in System's search (or press **/**) and it
+ opens the right tab and highlights the section. It knows the words
+ people use, like "keybind", "brb" or "port".
+- **A save bar that follows you.** It stays on screen while there are
+ unsaved changes, with **Discard** to undo them, and the page asks before
+ you close it with changes unsaved.
+- Each tab's scroll area now ends inside the window instead of under it.
+- **Destination cards say what they're doing.** "Goes live when OBS does",
+ "Connecting…" or "Off. Switch it on to stream here." instead of a dash,
+ frames, cuts and reconnects in words once there is something to count,
+ and one pulse in the platform's colour when a destination goes live.
+- **Platform logos.** Twitch, YouTube and Kick show their own marks in their
+ colours on destination cards, the platform picker and the setup wizard;
+ Restream, Custom and the test sink get a clear symbol each.
+- **A roomier destination editor.** Two columns on wide screens (name beside
+ platform, server beside stream key, format beside audio), one on narrow
+ ones, and a full-screen sheet on phones. The header shows the platform's
+ logo and an Enabled switch, and Save stays in view while the rest scrolls.
+- **Livelier fields and buttons.** Inputs glow softly when focused and their
+ label lights up with them; buttons lift a touch on hover. Both pick up the
+ stream state's colour: cyan when idle, shifting toward amber or green.
+ Dropdowns open as a styled list that fades in, with a turning arrow and a
+ check on the current choice (older browsers keep the native list).
+- **The aurora, reworked.** Five soft pools of light circle slowly behind the
+ whole delay panel, each at its own size, speed and direction, so the
+ colours keep folding into each other. They take the stream state's colour
+ (grey offline, amber buffering, cyan armed, green delayed), and while
+ buffering the light grows as the buffer fills. The page top glows faintly
+ with it, the panel's top edge catches the light, and each state change
+ blooms from the number. Buttons and cards pick up a subtle lit edge too.
+ It holds still with the system's reduced-motion setting.
+- **Clearer at a glance.** Larger section titles, labels and stats numbers,
+ bigger tab icons with the open tab in the accent colour, and screens that
+ explain themselves when there's nothing yet (Stats before OBS streams, an
+ empty Profiles list).
+- **A log you can read.** Every event is a row with its time and source;
+ errors are red, warnings amber, good news green. Filter to Problems or
+ Errors (with counts), one source, or any text, and jump back to the latest
+ line after scrolling up.
+- **A friendly start.** With no destinations yet, the Destinations tab asks
+ where you stream and opens the form with that platform picked.
+- **Motion that guides the eye.** Cards and sections ease in one after
+ another when a tab opens, and buttons give a small press. All of it
+ switches off with the system's reduced-motion setting.
+- **A delay panel that answers back.**
+ - The main button eases between Arm, Activate, Adjust and Cut instead of
+ snapping, and when only the number changes, only the number flips.
+ - The delay field rolls its digits up or down as you step through values.
+ - − at 0, + at 600 or a typed 700 light up the range, and a typed value
+ past 600 settles on 600.
+ - A profile too big for the buffer says why when clicked, instead of
+ hiding it in a tooltip.
+ - "Waiting for OBS" visibly listens, and the button blooms once when OBS
+ connects.
+ - Clicking a profile sends a spark into the button it arms, and the armed
+ profile fills in.
+
+ All of it plays only on a real change, never on the dashboard's 4× a
+ second refresh, which now writes nothing when nothing changed. Reduced
+ motion keeps the colour cues and drops the movement.
+- **The status pill glides.** Its words change in step with its colour, and
+ it eases to its new width instead of jumping. The delay readout also stops
+ rewriting itself on every refresh when nothing changed.
+- **The OBS dock, reworked for mid-stream use.**
+ - While the buffer fills, the main button counts down ("Ready in 0:23")
+ and fills toward Activate, instead of a spinner that restarted four
+ times a second.
+ - During a crash hold the dock says **Holding** (not Active and Offline
+ at once), and the hold card shows the countdown, how many destinations
+ are on the reconnect screen, and **End now**. It takes the place of the
+ delay controls, which step aside until OBS is back. A dock without
+ them, like a destinations-only dock, shows the card on top.
+ - Destinations show when they are on but not streaming yet ("Connecting…"
+ in amber) and when they are on the reconnect screen, instead of the
+ same grey dot as off. They follow the live state instead of a 4 s
+ refresh, so a "Turn off?" no longer vanishes before you can confirm.
+ - The status chip glides between states, the number pops only on a change
+ you made (not while the buffer counts up), and the idle "0 s" sits
+ together as "0s" without moving the +/− buttons.
+ - Like the dashboard, the dock now writes nothing when a refresh changes
+ nothing.
+- **Add destination** is a tinted button with a real plus icon.
+- The log wraps long lines instead of cutting them off, and stops jumping
+ to the bottom while you are reading older lines.
+- Destination cards in a row line up, the frame, cut and reconnect counters
+ have a tooltip, and the "+ Add" tile keeps keyboard focus.
+- The destination editor shows the vertical setup as two steps, only when
+ Vertical is picked, and folds the audio track details away.
+- The OBS tab recommends a GPU encoder and notes that Custom RTMP can't use
+ Enhanced Broadcasting on OBS 32.2 and newer.
## [0.1.14] - Hotkeys and MIDI for the delay, a dashboard password, and Linux builds
@@ -1703,7 +2488,8 @@ See `0.1.0` below for the full feature list.
port pre-flight, and process / RSS sampler have Windows-specific paths.
- No automated release pipeline yet. Build from source per the README.
-[Unreleased]: https://github.com/Soulhackzlol/InstantClone/compare/v0.1.14...HEAD
+[Unreleased]: https://github.com/Soulhackzlol/InstantClone/compare/v0.1.15...HEAD
+[0.1.15]: https://github.com/Soulhackzlol/InstantClone/compare/v0.1.14...v0.1.15
[0.1.14]: https://github.com/Soulhackzlol/InstantClone/compare/v0.1.13...v0.1.14
[0.1.13]: https://github.com/Soulhackzlol/InstantClone/compare/v0.1.12...v0.1.13
[0.1.12]: https://github.com/Soulhackzlol/InstantClone/compare/v0.1.11...v0.1.12
diff --git a/Cargo.lock b/Cargo.lock
index add05c3..03ed2d8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -219,7 +219,7 @@ dependencies = [
[[package]]
name = "instantclone"
-version = "0.1.14"
+version = "0.1.15"
dependencies = [
"bytes",
"flate2",
diff --git a/Cargo.toml b/Cargo.toml
index c838b3e..e280172 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "instantclone"
-version = "0.1.14"
+version = "0.1.15"
edition = "2021"
authors = ["s1moscs"]
license = "GPL-3.0-only"
@@ -61,6 +61,7 @@ windows-sys = { version = "0.61", features = [
"Win32_System_LibraryLoader",
"Win32_Security_Cryptography",
"Win32_System_Threading",
+ "Win32_System_SystemInformation",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_DataExchange",
"Win32_System_Memory",
diff --git a/README.es.md b/README.es.md
index b6ea29d..b9b0fb1 100644
--- a/README.es.md
+++ b/README.es.md
@@ -149,7 +149,7 @@ Haz simulcast de una señal de OBS a Twitch, YouTube, Kick y RTMP personalizado
**📱 Vertical (9:16) gratis**
-Activa **Formato Dual** de Twitch (Enhanced Broadcasting) y pon el formato de cualquier destino no-Twitch en **Vertical**. InstantClone reutiliza el lienzo 9:16 que OBS ya crea para Twitch y lo envía a YouTube Shorts, Kick móvil o TikTok, sin codificación extra.
+En OBS, activa **Enhanced Broadcasting** y elige tu lienzo vertical (por ejemplo Aitum Vertical) en **Lienzo adicional**, y pon el formato de cualquier destino no-Twitch en **Vertical**. InstantClone envía ese lienzo 9:16 a TikTok, YouTube Shorts o Kick móvil, con o sin un destino de Twitch. Activa el destino Vertical antes de empezar a emitir: sin Twitch, la pista vertical solo se codifica mientras haya uno activo. ¿También en Twitch? El **Formato Dual** de Twitch aporta el mismo lienzo.
@@ -191,13 +191,29 @@ Rearma o ajusta el delay arriba/abajo sin desarmar primero, expuesto como un con
**⌨ Atajos de teclado globales**
-Asigna delay on/off, armar, activar, cortar y **cortar cuando esto salga** a una combinación que funciona con un juego en pantalla completa por delante. Toda combinación necesita un modificador para que nada se dispare a media partida, una que otra app ya ocupa se marca en su fila en vez de fallar en silencio, y una acción rechazada te llega como globo en la bandeja.
+Asigna delay on/off, armar, activar, cortar, **cortar cuando esto salga** y terminar la protección contra crasheos a una combinación que funciona con un juego en pantalla completa por delante. Toda combinación necesita un modificador para que nada se dispare a media partida, una que otra app ya ocupa se marca en su fila en vez de fallar en silencio, y una acción rechazada te llega como globo en la bandeja.
**🎹 Pads y controladoras MIDI**
-Mapea las mismas cinco acciones a un pad o un knob, aprendidos pulsando el control en vez de escribir un número de nota. Cada mapeo recuerda de qué dispositivo vino, así dos controladoras pueden mover acciones distintas aunque manden la misma nota, y puedes acotar a qué dispositivo escucha InstantClone.
+Mapea las mismas acciones a un pad o un knob, aprendidos pulsando el control en vez de escribir un número de nota. Cada mapeo recuerda de qué dispositivo vino, así dos controladoras pueden mover acciones distintas aunque manden la misma nota, y puedes acotar a qué dispositivo escucha InstantClone.
+
+
+
+
+
+
+**🛡 Protección contra crasheos**
+Si OBS se cuelga o se congela, todos los destinos siguen en directo con una pantalla de reconexión (cinco estilos, con tus colores y tu texto) y audio en silencio, hasta 5 minutos. Cuando OBS vuelve, retoma en la misma conexión desde su primer keyframe, Enhanced Broadcasting incluido: cada pista recibe su propia pantalla y OBS recibe la misma sesión de Twitch. Las pistas HEVC y AV1 mantienen su último fotograma. Termínala antes desde el panel, el dock de OBS, la bandeja, un atajo o un pad MIDI.
+
+
+
+
+
+
+**🧩 Integraciones (experimental)**
+InstantClone habla con Discord, el chat de Twitch, tu móvil y tus propias herramientas. Con un clic añades un aviso de crasheo, un comando `!delay`, controles para mods, un botón de highlight que clipea lo que los espectadores vieron de verdad, una tarjeta de Discord que escribe la línea de tiempo de tu stream, capítulos de YouTube, una luz de "en directo" y más, y un editor por pasos cubre el resto. Son nuevas y aún pueden cambiar: cuéntanos lo que falle o confunda con el enlace **Report it** de la pestaña.
@@ -216,7 +232,7 @@ Mapea las mismas cinco acciones a un pad o un knob, aprendidos pulsando el contr
-**Dos fases por diseño.** **Armas** un buffer (un tamaño objetivo en segundos). InstantClone lo prellena desde la señal de OBS sin tocar lo que sale. Cuando se llena pulsas **Activar**, y el cambio a diferido es instantáneo en pantalla: el reproductor solo salta del borde en directo a un punto N segundos atrás.
+**Dos fases por diseño.** **Armas** un buffer (un tamaño objetivo en segundos). InstantClone lo prellena desde la señal de OBS sin tocar lo que sale. Cuando se llena pulsas **Activar**, y el cambio a diferido es instantáneo en pantalla: el reproductor solo salta del borde en directo al fotograma clave de al menos N segundos atrás, así que el público nunca queda más cerca del directo de lo que pusiste.
@@ -249,6 +265,7 @@ Mapea las mismas cinco acciones a un pad o un knob, aprendidos pulsando el contr
| POST | `/cut-after` | | Marca el borde en directo; autocorta cuando sale en todos. |
| POST | `/cut-after/cancel` | | Descarta un corte programado pendiente. |
| POST | `/delay` | `ms=NNN` | De un tiro: arma y autoactiva en cuanto esté listo. |
+| POST | `/crash-protection/end` | | Termina ya la pantalla de reconexión: todos los destinos terminan. |
| GET | `/state` | | Instantánea JSON puntual. |
| GET | `/events` | | Flujo de estado JSON por server-sent events. Solo push. |
@@ -306,7 +323,7 @@ Armado de un botón. Añade `/activate` y `/stop` a otros dos botones para contr
- **Handshake RTMP con paridad total con OBS.** `connect` lleva la misma bolsa de capacidades de códec que envía librtmp (`audioCodecs=3191`, `videoCodecs=252`, `videoFunction=1`), el `fourCcList` de Enhanced-RTMP (AVC / HEVC / AV1 / VP9 / Opus / AC-3 / FLAC), `Set Chunk Size` antes de connect, `FCUnpublish → deleteStream` al cerrar, y RTMP Acknowledgement (BYTES_READ_REPORT) al umbral ventana/10 declarado por el par, en entrada y salida.
- **Passthrough de Enhanced Broadcasting a Twitch.** Cuando OBS pasa a multipista "Auto" hacemos de proxy de `GetClientConfiguration` de Twitch, ruteamos la salida al endpoint IVS asignado a la sesión y reenviamos cada SPS/PPS por pista fielmente para que se encienda la escalera de transcodificado sin importar el nivel de cuenta. Los destinos no-Twitch reciben la pista primaria horizontal por defecto; las etiquetas de escalera con `TrackId != 0` se descartan para evitar la tormenta de varios frames por PTS que hace caer el decodificador de YouTube. Los cortes de EB caen en el IDR de la pista primaria (no en el keyframe del peldaño que gane el `partition_point`) para que el decodificador del destino siempre tenga su ancla.
-- **Selección del lienzo vertical (9:16).** El lienzo vertical se identifica decodificando el SPS de cada pista por orientación (retrato, mayor área) en vez de confiar en el JSON privado de sesión de Twitch, y se autocorrige según se activa/desactiva Formato Dual.
+- **Selección del lienzo vertical (9:16).** El lienzo vertical se identifica decodificando el SPS de cada pista por orientación (retrato, mayor área) en vez de confiar en el JSON privado de sesión de Twitch, y se autocorrige según aparece o desaparece el lienzo vertical.
- **Audio de VOD de Twitch, desbloqueado en el servicio InstantClone.** OBS ata su pista de VOD al servicio llamado literalmente "Twitch" (`ServiceSupportsVodTrack == {"Twitch"}`), así que está bloqueada en el servicio InstantClone. Un pequeño script de OBS incluido (`optional-vod-unlocker.lua`, descargado desde el panel) engancha el mismo segundo codificador de audio que usaría la propia pista de VOD de OBS, sin la restricción. Su lector de formato coincide byte a byte con el `flv_packet_audio_ex` de OBS (`AudioPacketType` en el byte 0, `TrackId` en el byte 6). OBS 32.2+ necesita el script; OBS anterior puede usar la casilla de VOD Track integrada (escribimos `EnableCustomServerVodTrack` en el `user.ini` de OBS 32, con `global.ini` como respaldo).
- **Ruteo de audio por destino.** Las pistas no seleccionadas se descartan y la elegida se aplana a una etiqueta de una sola pista estándar (AAC reescrito al `0xAF` legado), espejando el `flatten_multitrack_video` del lado de vídeo. Si la pista elegida no se está enviando, cae a la pista en directo en vez de quedarse en silencio.
@@ -336,7 +353,7 @@ El HTML del panel se minifica + gzipea en tiempo de compilación con `build.rs`
**E/S de disco síncrona en la ruta caliente de escritura al anillo, por elección.** La escritura con buffer aterriza en la caché de páginas del SO en microsegundos y el kernel vacía en segundo plano, así que la caché de páginas ya es el buffer asíncrono; el índice y los bytes avanzan bajo un solo lock para que un lector nunca vea una etiqueta cuyos bytes aún no están en disco.
-**Tests.** `cargo test --release` cubre la máquina de estados (`arm → preparing → ready → active → cut`), detección de IDR de AVC + Enhanced-RTMP, AMF0 (incluido Strict Array + guardia de recursión), round-trip de settings, expulsión del buffer en anillo con protección de lecturas en vuelo, parseo HTTP, política CSRF, pre-flight de puerto, negociación de contenido, caché de cabeceras de secuencia por pista de Enhanced Broadcasting + selección de etiquetas por TrackId, audio multipista + ruteo por destino, parseo de orientación SPS para la selección vertical, el parcheador de `services.json`, el parser del check de actualizaciones, el SHA-256 hecho a mano (vectores NIST), el lector/escritor de chunk-stream RTMP, la máquina del corte programado, las tablas de atajos y de mapeos MIDI (incluido el dispositivo que distingue dos controladoras), y la descarga de autoactualización + verificación de checksum + intercambio del exe. **392 tests, todos en verde.**
+**Tests.** `cargo test --release` cubre la máquina de estados (`arm → preparing → ready → active → cut`), detección de IDR de AVC + Enhanced-RTMP, AMF0 (incluido Strict Array + guardia de recursión), round-trip de settings, expulsión del buffer en anillo con protección de lecturas en vuelo, parseo HTTP, política CSRF, pre-flight de puerto, negociación de contenido, caché de cabeceras de secuencia por pista de Enhanced Broadcasting + selección de etiquetas por TrackId, audio multipista + ruteo por destino, parseo de orientación SPS para la selección vertical, la configuración de Enhanced Broadcasting construida a partir de los lienzos y codificadores GPU de OBS, el parcheador de `services.json`, el parser del check de actualizaciones, el SHA-256 hecho a mano (vectores NIST), el lector/escritor de chunk-stream RTMP, la máquina del corte programado, las tablas de atajos y de mapeos MIDI (incluido el dispositivo que distingue dos controladoras), la protección contra crasheos (distinguir crasheo, parada y congelado, el codificador sin pérdidas de la pantalla de reconexión comprobado con ffmpeg, el empaquetado por pista de Enhanced Broadcasting, el AAC en silencio), el motor de integraciones (pasos, plantillas, recetas, chat, tarjetas de Discord, la línea de tiempo del stream), y la descarga de autoactualización + verificación de checksum + intercambio del exe. **843 tests, todos en verde.**
@@ -346,7 +363,7 @@ El HTML del panel se minifica + gzipea en tiempo de compilación con `build.rs`
## Estado
-**Listo para uso diario en Windows.** Lo uso en mis propios streams, y un grupo creciente de streamers lo corre a diario también. CI ejecuta fmt + clippy (`-D warnings`) + 392 tests en cada push, y un commit etiquetado compila y publica una release con un `SHA256SUMS.txt` al lado (todavía sin certificado de firma de código, así que el SO puede avisar al primer arranque).
+**Listo para uso diario en Windows.** Lo uso en mis propios streams, y un grupo creciente de streamers lo corre a diario también. CI ejecuta fmt + clippy (`-D warnings`) + 843 tests en cada push, y un commit etiquetado compila y publica una release con un `SHA256SUMS.txt` al lado (todavía sin certificado de firma de código, así que el SO puede avisar al primer arranque).
**Lo áspero, con honestidad**
@@ -415,7 +432,7 @@ En la pestaña **Destinos** de InstantClone, nunca en OBS. OBS solo apunta a Ins
-Sí, ambas cosas. Cinco acciones - delay on/off, armar, activar, cortar a directo y **cortar cuando esto salga** - se asignan a un atajo de teclado global, a un pad o knob MIDI, o a los dos a la vez, en **Ajustes**. Los atajos funcionan con un juego en pantalla completa por delante, así no haces alt-tab a media partida, y toda combinación necesita un modificador (Ctrl, Alt, Shift o Win) para que una tecla suelta no dispare una acción de delay. Los mapeos MIDI se aprenden pulsando el control en vez de escribir un número de nota, y cada uno recuerda de qué dispositivo vino, así dos controladoras pueden mover acciones distintas. Por ahora solo en Windows.
+Sí, ambas cosas. Seis acciones - delay on/off, armar, activar, cortar a directo, **cortar cuando esto salga** y terminar la protección contra crasheos - se asignan a un atajo de teclado global, a un pad o knob MIDI, o a los dos a la vez, en **Ajustes**. Los atajos funcionan con un juego en pantalla completa por delante, así no haces alt-tab a media partida, y toda combinación necesita un modificador (Ctrl, Alt, Shift o Win) para que una tecla suelta no dispare una acción de delay. Los mapeos MIDI se aprenden pulsando el control en vez de escribir un número de nota, y cada uno recuerda de qué dispositivo vino, así dos controladoras pueden mover acciones distintas. Por ahora solo en Windows.
diff --git a/README.md b/README.md
index 55d45e9..1a0cd90 100644
--- a/README.md
+++ b/README.md
@@ -149,7 +149,7 @@ Simulcast one OBS feed to Twitch, YouTube, Kick, and custom RTMP at once, a free
**📱 Vertical (9:16) for free**
-Turn on Twitch **Dual Format** (Enhanced Broadcasting) and set any non-Twitch destination's format to **Vertical**. InstantClone reuses the 9:16 canvas OBS already makes for Twitch and sends it to YouTube Shorts, Kick mobile, or TikTok, with no extra encoding.
+In OBS, turn on **Enhanced Broadcasting** and pick your vertical canvas (for example Aitum Vertical) under **Additional canvas**, then set any non-Twitch destination's format to **Vertical**. InstantClone sends that 9:16 canvas to TikTok, YouTube Shorts, or Kick mobile, with or without a Twitch destination. Switch the Vertical destination on before you start streaming: without Twitch, the vertical track is only encoded while one is on. Streaming to Twitch too? Twitch **Dual Format** provides the same canvas.
@@ -191,13 +191,29 @@ Re-arm or nudge the delay up/down without disarming first, exposed as a single t
**⌨ Global hotkeys**
-Bind delay on/off, arm, activate, cut, and **cut after this airs** to a key combo that fires while a fullscreen game holds focus. Every binding needs a modifier so nothing trips mid-match, a combo another app already owns is flagged on the row instead of failing silently, and a refused action reaches you as a tray balloon.
+Bind delay on/off, arm, activate, cut, **cut after this airs**, and ending crash protection to a key combo that fires while a fullscreen game holds focus. Every binding needs a modifier so nothing trips mid-match, a combo another app already owns is flagged on the row instead of failing silently, and a refused action reaches you as a tray balloon.
**🎹 MIDI pads and decks**
-Map the same five actions to a pad or knob, learned by pressing the control rather than typing a note number. Each mapping remembers which device it came from, so two controllers can drive different actions even when they send the same note, and you can narrow which device InstantClone listens to.
+Map the same actions to a pad or knob, learned by pressing the control rather than typing a note number. Each mapping remembers which device it came from, so two controllers can drive different actions even when they send the same note, and you can narrow which device InstantClone listens to.
+
+
+
+
+
+
+**🛡 Crash protection**
+If OBS crashes or freezes, every destination stays live on a reconnect screen (five styles, your colours and text) with silent audio, for up to 5 minutes. OBS coming back resumes on the same connection at its first keyframe, Enhanced Broadcasting included: each track gets its own screen and OBS is handed the same Twitch session. HEVC and AV1 tracks hold their last frame. End it early from the dashboard, the OBS dock, the tray, a hotkey or a MIDI pad.
+
+
+
+
+
+
+**🧩 Integrations (experimental)**
+InstantClone talks to Discord, Twitch chat, your phone and your own tools. One click adds a crash alert, a `!delay` command, mod controls, a highlight button that clips what viewers actually saw, a Discord card that writes your stream's timeline, YouTube chapters, an on-air light and more, and a step builder covers the rest. They're new and may still change: please report what breaks or confuses you with the tab's **Report it** link.
@@ -216,7 +232,7 @@ Map the same five actions to a pad or knob, learned by pressing the control rath
-**Two-phase by design.** You **arm** a buffer (a target size in seconds). InstantClone pre-fills it from the live OBS feed without touching what's going out. Once it's full you hit **Activate**, and the switch to delayed is instant on screen: the player just jumps from the live edge to a point N seconds back.
+**Two-phase by design.** You **arm** a buffer (a target size in seconds). InstantClone pre-fills it from the live OBS feed without touching what's going out. Once it's full you hit **Activate**, and the switch to delayed is instant on screen: the player just jumps from the live edge to the keyframe at least N seconds back, so viewers are never closer to live than you set.
@@ -249,6 +265,7 @@ Map the same five actions to a pad or knob, learned by pressing the control rath
| POST | `/cut-after` | | Mark the live edge; auto-cut once it airs everywhere. |
| POST | `/cut-after/cancel` | | Drop a pending scheduled cut. |
| POST | `/delay` | `ms=NNN` | One-shot: arm, auto-activate as soon as ready. |
+| POST | `/crash-protection/end` | | End the reconnect screen now: every destination ends. |
| GET | `/state` | | One-shot JSON snapshot. |
| GET | `/events` | | Server-sent stream of state JSON. Push-only. |
@@ -306,7 +323,7 @@ One-button arming. Add `/activate` and `/stop` to two more buttons for full dela
- **Full OBS-parity RTMP handshake.** `connect` carries the same codec-capability bag librtmp ships (`audioCodecs=3191`, `videoCodecs=252`, `videoFunction=1`), the Enhanced-RTMP `fourCcList` (AVC / HEVC / AV1 / VP9 / Opus / AC-3 / FLAC), `Set Chunk Size` before connect, `FCUnpublish → deleteStream` on shutdown, and RTMP Acknowledgement (BYTES_READ_REPORT) at the peer-declared window/10 threshold on both ingest and egress.
- **Enhanced Broadcasting passthrough to Twitch.** When OBS hits multi-track "Auto" we proxy Twitch's `GetClientConfiguration`, route egress to the session-allocated IVS endpoint, and forward every per-track SPS/PPS bit-faithfully so the transcoded ladder lights up regardless of account tier. Non-Twitch destinations get the horizontal primary track by default; ladder tags with `TrackId != 0` are dropped to avoid the multi-frame-per-PTS storm that crashes YouTube's decoder. EB cuts land on the primary track's IDR (not whichever ladder rung's keyframe wins the `partition_point`) so the destination decoder always has its anchor.
-- **Vertical (9:16) canvas selection.** The vertical canvas is identified by decoding each track's SPS for orientation (portrait, largest area) rather than trusting Twitch's private session JSON, and it self-heals as Dual Format toggles on/off.
+- **Vertical (9:16) canvas selection.** The vertical canvas is identified by decoding each track's SPS for orientation (portrait, largest area) rather than trusting Twitch's private session JSON, and it self-heals as the vertical canvas comes and goes.
- **Twitch VOD audio, unlocked on the InstantClone service.** OBS hardcodes its VOD Track to the service literally named "Twitch" (`ServiceSupportsVodTrack == {"Twitch"}`), so it's locked on the InstantClone service. A tiny bundled OBS script (`optional-vod-unlocker.lua`, downloaded from the dashboard) attaches the same second audio encoder OBS's own VOD Track would, without the gate. Its wire-format reader matches OBS's `flv_packet_audio_ex` byte-for-byte (`AudioPacketType` in byte 0, `TrackId` at byte 6). OBS 32.2+ needs the script; older OBS can still use the built-in VOD Track checkbox (we write `EnableCustomServerVodTrack` to OBS 32's `user.ini`, falling back to `global.ini`).
- **Per-destination audio routing.** Non-selected tracks are dropped and the chosen one is flattened to a standard single-track tag (AAC rewritten to legacy `0xAF`), mirroring the video-side `flatten_multitrack_video`. If the chosen track isn't being sent, it falls back to the live track rather than going silent.
@@ -336,7 +353,7 @@ The dashboard HTML is minified + gzipped at build time by `build.rs` (`flate2`,
**Sync disk I/O on the ring-append hot path, by choice.** The buffered write lands in the OS page cache in microseconds and the kernel flushes in the background, so the page cache is already the async buffer; the index and the bytes advance under one lock so a reader never sees a tag whose bytes aren't on disk yet.
-**Tests.** `cargo test --release` covers the state machine (`arm → preparing → ready → active → cut`), AVC + Enhanced-RTMP IDR detection, AMF0 (including Strict Array + recursion guard), settings round-trip, ring-buffer eviction with in-flight-read protection, HTTP parsing, CSRF policy, port pre-flight, content negotiation, Enhanced Broadcasting per-track seq-header cache + TrackId-aware tag selection, multi-track audio + per-destination routing, SPS orientation parsing for vertical selection, the OBS `services.json` patcher, the update-check parser, the hand-rolled SHA-256 (NIST vectors), the RTMP chunk-stream reader/writer, the scheduled safe-cut state machine, the hotkey and MIDI binding tables (including the device that tells two controllers apart), and the self-update download + checksum-verify + exe swap. **392 tests, all green.**
+**Tests.** `cargo test --release` covers the state machine (`arm → preparing → ready → active → cut`), AVC + Enhanced-RTMP IDR detection, AMF0 (including Strict Array + recursion guard), settings round-trip, ring-buffer eviction with in-flight-read protection, HTTP parsing, CSRF policy, port pre-flight, content negotiation, Enhanced Broadcasting per-track seq-header cache + TrackId-aware tag selection, multi-track audio + per-destination routing, SPS orientation parsing for vertical selection, the Enhanced Broadcasting config built from OBS's canvases and GPU encoders, the OBS `services.json` patcher, the update-check parser, the hand-rolled SHA-256 (NIST vectors), the RTMP chunk-stream reader/writer, the scheduled safe-cut state machine, the hotkey and MIDI binding tables (including the device that tells two controllers apart), crash protection (crash vs. stop vs. freeze detection, the lossless reconnect-screen encoder checked against ffmpeg, per-track framing for Enhanced Broadcasting, silent AAC), the integrations engine (steps, templates, recipes, chat, Discord cards, the stream timeline), and the self-update download + checksum-verify + exe swap. **843 tests, all green.**
@@ -346,7 +363,7 @@ The dashboard HTML is minified + gzipped at build time by `build.rs` (`flate2`,
## Status
-**Daily-driver ready on Windows.** I use it on my own streams, and a growing group of streamers now run it daily too. CI runs fmt + clippy (`-D warnings`) + 392 tests on every push, and a tagged commit auto-builds and publishes a release with a `SHA256SUMS.txt` alongside (no code-signing certificate yet, so the OS may warn on first launch).
+**Daily-driver ready on Windows.** I use it on my own streams, and a growing group of streamers now run it daily too. CI runs fmt + clippy (`-D warnings`) + 843 tests on every push, and a tagged commit auto-builds and publishes a release with a `SHA256SUMS.txt` alongside (no code-signing certificate yet, so the OS may warn on first launch).
**What's rough, honestly**
@@ -415,7 +432,7 @@ In InstantClone's **Destinations** tab, never in OBS. OBS only ever points at In
-Yes, both. Five actions - delay on/off, arm, activate, cut to live, and **cut after this airs** - bind to a global keyboard shortcut, a MIDI pad or knob, or both at once, in **Settings**. Hotkeys fire while a fullscreen game holds focus, so you never alt-tab mid-match, and every binding needs a modifier (Ctrl, Alt, Shift or Win) so a stray keypress can't trip a delay action. MIDI mappings are learned by pressing the control rather than typing a note number, and each one remembers which device it came from, so two controllers can drive different actions. Windows only for now.
+Yes, both. Six actions - delay on/off, arm, activate, cut to live, **cut after this airs**, and ending crash protection - bind to a global keyboard shortcut, a MIDI pad or knob, or both at once, in **Settings**. Hotkeys fire while a fullscreen game holds focus, so you never alt-tab mid-match, and every binding needs a modifier (Ctrl, Alt, Shift or Win) so a stray keypress can't trip a delay action. MIDI mappings are learned by pressing the control rather than typing a note number, and each one remembers which device it came from, so two controllers can drive different actions. Windows only for now.
diff --git a/assets/fonts/Inter-Regular-slate.ttf b/assets/fonts/Inter-Regular-slate.ttf
new file mode 100644
index 0000000..bea36c7
Binary files /dev/null and b/assets/fonts/Inter-Regular-slate.ttf differ
diff --git a/assets/fonts/Inter-SemiBold-slate.ttf b/assets/fonts/Inter-SemiBold-slate.ttf
new file mode 100644
index 0000000..d24ee49
Binary files /dev/null and b/assets/fonts/Inter-SemiBold-slate.ttf differ
diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt
new file mode 100644
index 0000000..9b2ca37
--- /dev/null
+++ b/assets/fonts/OFL.txt
@@ -0,0 +1,92 @@
+Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION AND CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/build.rs b/build.rs
index 570f4bc..6010b44 100644
--- a/build.rs
+++ b/build.rs
@@ -29,6 +29,8 @@ fn main() {
// overlay runtime), so skip the line minifier and let gzip do it.
("dock.js", false),
("overlay-runtime.js", false),
+ // Integrations UI: template-literal heavy like the dock.
+ ("integrations.js", false),
] {
let src_path = web_dir.join(name);
println!("cargo:rerun-if-changed={}", src_path.display());
diff --git a/obs/instantclone-vod-track.lua b/obs/instantclone-vod-track.lua
index 517f3cc..0347dc8 100644
--- a/obs/instantclone-vod-track.lua
+++ b/obs/instantclone-vod-track.lua
@@ -25,18 +25,38 @@
-- next start, on unload) is exactly what raced OBS and crashed
-- (c0000005 in receive_audio / obs_encoder_release). So: no encoder state
-- kept, no deferred releases, nothing to clean up.
+--
+-- ONE EXCEPTION (OBS bug, every version 28-32): on OBS's own plain stream
+-- outputs, StartStreaming -> SetupVodTrack -> clear_archive_encoder calls
+-- obs_encoder_release() on the index-1 encoder it only BORROWED from
+-- obs_output_get_audio_encoder. That stray release destroys our encoder
+-- (and obs_encoder_destroy detaches it) before the stream starts, so OBS
+-- sends one audio track. On those outputs we hand our create-reference to
+-- OBS instead of releasing it: the stray release consumes it and the
+-- output is again the sole owner. Enhanced Broadcasting streams through a
+-- separate multitrack output that clear_archive_encoder never touches, so
+-- there we release normally. (With OBS's native Twitch VOD enabled, OBS
+-- replaces our encoder instead; the one idle encoder left over is inert.)
local obs = obslua
local ENCODER_ID = "ffmpeg_aac" -- always-present AAC encoder
local ENCODER_NAME = "InstantClone VOD audio" -- how we recognise our own track
+-- OBS's plain stream outputs (Advanced / Simple output mode), the ones
+-- clear_archive_encoder stray-releases on. See the OWNERSHIP exception.
+local PLAIN_STREAM_OUTPUTS = { adv_stream = true, simple_stream = true }
+
-- ── settings (mirrored from the script properties UI) ────────────────
local enabled = true
local vod_track = 2 -- OBS mixer track (2..6) that carries the VOD audio
local vod_bitrate = 160 -- kbps
local verbose = false -- extra diagnostic logging (off for clean logs)
+-- Whether this stream start attached our track, so the post-start check
+-- can warn when it went missing. A flag only - never an encoder handle.
+local attached = false
+
local function log(msg)
obs.script_log(obs.LOG_INFO, "[InstantClone VOD] " .. msg)
end
@@ -60,6 +80,7 @@ end
-- Called on STREAMING_STARTING - the pre-start window OBS uses for its own
-- native VOD setup, so the encoder is picked up when the output starts.
local function on_streaming_starting()
+ attached = false
local output = obs.obs_frontend_get_streaming_output()
if output == nil then
log("no streaming output at start - cannot attach VOD track")
@@ -85,10 +106,11 @@ local function on_streaming_starting()
return
end
- -- Create a fresh encoder, attach it, and immediately release our
- -- create-reference. The output now owns it (and releases the previous
- -- index-1 encoder as part of the set). We keep no reference and never
- -- release it again - see the OWNERSHIP note.
+ -- Create a fresh encoder, attach it, and give up our create-reference:
+ -- released here, or on a plain output left for OBS's stray release.
+ -- The output now owns it (and releases the previous index-1 encoder as
+ -- part of the set). We keep no reference and never release it again -
+ -- see the OWNERSHIP note.
local settings = obs.obs_data_create()
obs.obs_data_set_int(settings, "bitrate", vod_bitrate)
local enc = obs.obs_audio_encoder_create(ENCODER_ID, ENCODER_NAME, settings, vod_track - 1, nil)
@@ -100,14 +122,19 @@ local function on_streaming_starting()
end
obs.obs_encoder_set_audio(enc, obs.obs_get_audio())
obs.obs_output_set_audio_encoder(output, enc, 1)
- obs.obs_encoder_release(enc) -- balance our create-ref; the output owns it now
+ if not PLAIN_STREAM_OUTPUTS[obs.obs_output_get_name(output)] then
+ obs.obs_encoder_release(enc) -- balance our create-ref; the output owns it now
+ end
+ attached = true
log(string.format("VOD track attached: OBS track %d -> stream audio 2 @ %d kbps", vod_track, vod_bitrate))
obs.obs_output_release(output)
end
--- Diagnostic only (verbose): confirm our track is at index 1 after start.
+-- Diagnostic: confirm our track is still at index 1 after start. Always
+-- warns when an attached track went missing (the stream then carries one
+-- audio track and Twitch has no VOD audio); the full line is verbose-only.
local function verify_after_start()
- if not verbose then
+ if not (attached or verbose) then
return
end
local output = obs.obs_frontend_get_streaming_output()
@@ -116,6 +143,11 @@ local function verify_after_start()
end
local present, ours = inspect_index_1(output)
vlog(string.format("post-start check: idx1 present=%s ours=%s", tostring(present), tostring(ours)))
+ if attached and not present then
+ log("WARNING: VOD track missing after stream start - this stream has no VOD audio")
+ elseif attached and not ours then
+ log("OBS's native VOD track replaced ours - using OBS's VOD track")
+ end
obs.obs_output_release(output)
end
diff --git a/scripts/subset_slate_fonts.py b/scripts/subset_slate_fonts.py
new file mode 100644
index 0000000..976a3bb
--- /dev/null
+++ b/scripts/subset_slate_fonts.py
@@ -0,0 +1,73 @@
+"""Build the Inter subsets the crash-protection reconnect screen draws with.
+
+Keeps Basic Latin, Latin-1 and common punctuation, drops hinting and
+OpenType layout, and decomposes composite glyphs so the built-in
+rasterizer (src/slate/font.rs) only ever sees simple quadratic outlines.
+
+Usage:
+ pip install fonttools
+ python scripts/subset_slate_fonts.py path/to/Inter-4.1/extras/ttf
+
+Inter is by Rasmus Andersson, SIL Open Font License 1.1
+(assets/fonts/OFL.txt). Download: https://github.com/rsms/inter/releases
+"""
+
+import sys
+from pathlib import Path
+
+from fontTools import subset
+from fontTools.pens.recordingPen import DecomposingRecordingPen
+from fontTools.pens.ttGlyphPen import TTGlyphPen
+from fontTools.ttLib import TTFont
+
+WEIGHTS = ("Regular", "SemiBold")
+UNICODES = (
+ list(range(0x20, 0x7F))
+ + list(range(0xA0, 0x100))
+ + [0x2013, 0x2014, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2026]
+)
+OUT_DIR = Path(__file__).resolve().parent.parent / "assets" / "fonts"
+
+
+def subset_font(source: Path) -> TTFont:
+ options = subset.Options()
+ options.hinting = False
+ options.layout_features = []
+ options.name_IDs = [0, 1, 2, 3, 4, 5, 6, 13, 14]
+ options.notdef_outline = True
+ font = TTFont(source)
+ subsetter = subset.Subsetter(options)
+ subsetter.populate(unicodes=UNICODES)
+ subsetter.subset(font)
+ return font
+
+
+def decompose_composites(font: TTFont) -> None:
+ glyph_set = font.getGlyphSet()
+ glyf = font["glyf"]
+ for name in font.getGlyphOrder():
+ if not glyf[name].isComposite():
+ continue
+ recording = DecomposingRecordingPen(glyph_set)
+ glyph_set[name].draw(recording)
+ pen = TTGlyphPen(None)
+ recording.replay(pen)
+ glyf[name] = pen.glyph()
+ glyf[name].recalcBounds(glyf)
+
+
+def main() -> None:
+ if len(sys.argv) != 2:
+ sys.exit(__doc__)
+ source_dir = Path(sys.argv[1])
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
+ for weight in WEIGHTS:
+ font = subset_font(source_dir / f"Inter-{weight}.ttf")
+ decompose_composites(font)
+ target = OUT_DIR / f"Inter-{weight}-slate.ttf"
+ font.save(target)
+ print(f"{target.name}: {target.stat().st_size} bytes")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/auth.rs b/src/auth.rs
index c566f2c..cc6ed9b 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -254,23 +254,105 @@ mod tests {
assert!(a.begin_login_attempt(ip).is_ok());
}
+ /// The lockout `client` is currently serving, read back through `check`.
+ /// Measured just after the failure that armed it, so it sits a hair under
+ /// the armed value; `assert_lockout` allows for that.
+ fn remaining_lockout(r: &RateLimiter, client: &str) -> Duration {
+ r.check(client).expect_err("client should be locked out")
+ }
+
+ fn assert_lockout(r: &RateLimiter, client: &str, want_secs: u64) {
+ let left = remaining_lockout(r, client);
+ let want = Duration::from_secs(want_secs);
+ assert!(
+ left <= want && left > want - Duration::from_secs(1),
+ "expected a ~{want_secs}s lockout, got {left:?}"
+ );
+ }
+
+ /// Each failure past the threshold doubles the lockout until it reaches
+ /// `max_lockout`, where it stays. Read back without sleeping: `check`
+ /// reports how long is left, which right after arming is the armed value.
#[test]
- fn check_and_count_counts_at_check_time() {
- // The gate must count the attempt itself, so concurrent callers can't
- // all pass before any failure lands (the TOCTOU the login await opened).
+ fn lockout_doubles_past_the_threshold_up_to_the_cap() {
let r = RateLimiter::new(
- 3,
- Duration::from_secs(30),
- Duration::from_secs(600),
- Duration::from_secs(600),
+ 2,
+ Duration::from_secs(10),
+ Duration::from_secs(45),
+ Duration::from_secs(3600),
);
let c = "peer";
- assert!(r.check_and_count(c).is_ok()); // 1
- assert!(r.check_and_count(c).is_ok()); // 2
- assert!(r.check_and_count(c).is_ok()); // 3 -> arms the lock
- assert!(r.check_and_count(c).is_err()); // 4 -> locked, no verify happens
- r.record_success(c);
- assert!(r.check_and_count(c).is_ok()); // cleared
+ r.record_failure(c);
+ assert!(r.check(c).is_ok(), "one failure is under the threshold");
+ for want in [10, 20, 40, 45, 45] {
+ r.record_failure(c);
+ assert_lockout(&r, c, want);
+ }
+ }
+
+ /// A lockout never outlasts the forgiveness window. `check` forgets an
+ /// entry once it is older than the window, so a longer lock would be
+ /// wiped early and the two settings would disagree about when a client
+ /// may try again.
+ #[test]
+ fn lockout_is_clamped_to_the_forgiveness_window() {
+ let r = RateLimiter::new(
+ 1,
+ Duration::from_secs(10),
+ Duration::from_secs(3600),
+ Duration::from_secs(25),
+ );
+ let c = "peer";
+ for want in [10, 20, 25, 25] {
+ r.record_failure(c);
+ assert_lockout(&r, c, want);
+ }
+ }
+
+ /// A client that keeps failing must not overflow the doubling. Without the
+ /// cap on the shift, the 33rd failure past the threshold is `1u32 << 32`:
+ /// a panic in a debug build, and in release a shift that wraps back to a
+ /// short lockout, handing the most persistent guesser the fastest retries.
+ #[test]
+ fn endless_failures_do_not_overflow_the_doubling() {
+ let day = Duration::from_secs(24 * 3600);
+ let r = RateLimiter::new(1, Duration::from_secs(1), day, day);
+ let c = "peer";
+ for _ in 0..100 {
+ r.record_failure(c);
+ }
+ // The shift is capped at 6: base * 64.
+ assert_lockout(&r, c, 64);
+ }
+
+ /// The tracked-client map is bounded, and when a flood fills it the
+ /// oldest entry is the one dropped. `MAX_TRACKED` is private to
+ /// `bump_failure`; the 8192 here mirrors it.
+ #[test]
+ fn a_full_table_evicts_the_oldest_client() {
+ const MAX_TRACKED: usize = 8192;
+ let r = RateLimiter::new(
+ 1,
+ Duration::from_secs(60),
+ Duration::from_secs(60),
+ Duration::from_secs(3600),
+ );
+ r.record_failure("oldest");
+ // Make every later entry strictly newer than "oldest" so it is the
+ // unique minimum. A busy-wait on the clock, well under a microsecond,
+ // rather than a sleep.
+ let armed = Instant::now();
+ while Instant::now() <= armed {}
+ for i in 1..MAX_TRACKED {
+ r.record_failure(&format!("client-{i}"));
+ }
+ assert_eq!(r.attempts.lock().len(), MAX_TRACKED, "table is full");
+
+ r.record_failure("newcomer");
+ assert_eq!(r.attempts.lock().len(), MAX_TRACKED, "table stays bounded");
+ assert!(r.check("oldest").is_ok(), "the oldest entry was evicted");
+ assert!(r.check("client-1").is_err(), "newer entries are kept");
+ assert!(r.check("newcomer").is_err(), "the newcomer is tracked");
}
#[test]
diff --git a/src/autostart.rs b/src/autostart.rs
index 8f70bd9..339f373 100644
--- a/src/autostart.rs
+++ b/src/autostart.rs
@@ -43,8 +43,15 @@ fn run_command() -> io::Result {
/// means the same thing to the user either way.
#[cfg(windows)]
pub fn is_enabled() -> bool {
+ is_enabled_under(RUN_KEY)
+}
+
+/// `is_enabled` against any HKCU subkey. Split out so the round-trip test
+/// can use a private key instead of the developer's real Run key.
+#[cfg(windows)]
+fn is_enabled_under(subkey: &str) -> bool {
use windows_sys::Win32::System::Registry::{RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_SZ};
- let subkey = wide(RUN_KEY);
+ let subkey = wide(subkey);
let name = wide(VALUE_NAME);
// Null output buffer + null size: asks only "does this value exist,
// and is it a string?" without reading the data back.
@@ -69,10 +76,17 @@ pub fn is_enabled() -> bool {
/// of pointing at an executable that no longer exists.
#[cfg(windows)]
pub fn set(enabled: bool) -> io::Result<()> {
+ set_under(RUN_KEY, enabled)
+}
+
+/// `set` against any HKCU subkey. Split out so the round-trip test can use
+/// a private key instead of the developer's real Run key.
+#[cfg(windows)]
+fn set_under(subkey: &str, enabled: bool) -> io::Result<()> {
use windows_sys::Win32::System::Registry::{
RegDeleteKeyValueW, RegSetKeyValueW, HKEY_CURRENT_USER, REG_SZ,
};
- let subkey = wide(RUN_KEY);
+ let subkey = wide(subkey);
let name = wide(VALUE_NAME);
let rc = if enabled {
@@ -297,29 +311,47 @@ mod tests {
);
}
- /// Round-trip against the real registry. Writes under HKCU, which
- /// needs no elevation, and restores whatever state the machine was in
- /// so running the suite never changes the developer's own startup.
+ /// Private HKCU key the round-trip test owns outright. A single level so
+ /// deleting it leaves nothing behind, and never the real Run key: a test
+ /// that touched that could rewrite or drop the developer's own startup
+ /// entry, and a panic midway would leave it that way.
+ const TEST_KEY: &str = r"Software\InstantCloneAutostartTest";
+
+ /// Deletes `TEST_KEY` on drop, so a failed assertion still cleans up.
+ struct TestKeyGuard;
+
+ impl Drop for TestKeyGuard {
+ fn drop(&mut self) {
+ use windows_sys::Win32::System::Registry::{RegDeleteTreeW, HKEY_CURRENT_USER};
+ let subkey = wide(TEST_KEY);
+ unsafe { RegDeleteTreeW(HKEY_CURRENT_USER, subkey.as_ptr()) };
+ }
+ }
+
+ /// Round-trip through the same code path `set`/`is_enabled` use, but
+ /// against a private key, so running the suite never changes the
+ /// developer's own startup.
#[test]
fn enable_disable_round_trip() {
- let original = is_enabled();
+ let _cleanup = TestKeyGuard;
+ assert!(
+ !is_enabled_under(TEST_KEY),
+ "stale test key from an earlier run"
+ );
- set(true).expect("enable");
- assert!(is_enabled(), "value must exist after enabling");
+ set_under(TEST_KEY, true).expect("enable");
+ assert!(
+ is_enabled_under(TEST_KEY),
+ "value must exist after enabling"
+ );
- set(false).expect("disable");
- assert!(!is_enabled(), "value must be gone after disabling");
+ set_under(TEST_KEY, false).expect("disable");
+ assert!(
+ !is_enabled_under(TEST_KEY),
+ "value must be gone after disabling"
+ );
// Disabling an already-absent value is a no-op, not an error.
- set(false).expect("disable twice");
-
- if original {
- set(true).expect("restore");
- }
- assert_eq!(
- is_enabled(),
- original,
- "test must leave startup state as found"
- );
+ set_under(TEST_KEY, false).expect("disable twice");
}
}
diff --git a/src/buffer.rs b/src/buffer.rs
index 5b2a4c2..6b63b06 100644
--- a/src/buffer.rs
+++ b/src/buffer.rs
@@ -83,6 +83,11 @@ struct RingInner {
/// `index`: every IDR push_back also push_backs here; every eviction
/// also pops the IDR-front if it matches by seq.
idr_index: VecDeque,
+ /// Payload bytes of every tag in `index`, kept in step with it. Near
+ /// `capacity` means the ring is full: new tags are overwriting the
+ /// oldest ones, so the buffer reaches back no further (see
+ /// `is_saturated`).
+ indexed_bytes: u64,
}
impl DiskRing {
@@ -122,6 +127,7 @@ impl DiskRing {
next_seq: 0,
index: VecDeque::with_capacity(65_536),
idr_index: VecDeque::with_capacity(2_048),
+ indexed_bytes: 0,
}),
video_seq_headers: Mutex::new(std::collections::BTreeMap::new()),
audio_seq_headers: Mutex::new(std::collections::BTreeMap::new()),
@@ -194,6 +200,7 @@ impl DiskRing {
while let Some(front) = inner.index.front().copied() {
if write_overlaps(offset, len, self.capacity, front.offset, front.len as u64) {
inner.index.pop_front();
+ inner.indexed_bytes -= u64::from(front.len);
if front.is_idr {
// Front of idr_index MUST be this same IDR - both
// queues are time-ordered and we only ever push at
@@ -237,6 +244,7 @@ impl DiskRing {
is_idr,
};
inner.index.push_back(meta);
+ inner.indexed_bytes += len;
if is_idr && crate::h264::is_primary_video_idr(payload) {
// Only the PRIMARY track's IDRs become cut candidates. Each
// EB ladder rung has its own IDR cadence; OBS aligns them
@@ -385,39 +393,46 @@ impl DiskRing {
}
}
- /// Return the most recent IDR (used to seed the egress state cleanly
- /// without walking the entire index entry-by-entry).
- pub fn newest_idr(&self) -> Option {
+ /// The newest primary-track IDR stamped at or before `ts`, or None when
+ /// every IDR in the ring is newer. A delay lands here so viewers are
+ /// never closer to live than asked: the nearest IDR can be the newer one.
+ pub fn newest_idr_at_or_before(&self, ts: u64) -> Option {
let inner = self.inner.lock();
- inner.index.iter().rev().find(|m| m.is_idr).copied()
+ let pos = inner.idr_index.partition_point(|m| m.ts_ms <= ts);
+ pos.checked_sub(1)
+ .and_then(|below| inner.idr_index.get(below).copied())
+ }
+
+ /// Return the most recent primary-track IDR (used to seed the egress
+ /// state cleanly without walking the entire index entry-by-entry).
+ ///
+ /// This and the two lookups below read `idr_index`, not `index`: a
+ /// tag's `is_idr` is also true for an Enhanced Broadcasting rung's
+ /// keyframe, which OBS sends right after the primary's for the same
+ /// frame. Seeding on that one starts a horizontal destination one tag
+ /// past its own keyframe, on P-frames with no reference.
+ pub fn newest_idr(&self) -> Option {
+ self.inner.lock().idr_index.back().copied()
}
- /// Most recent IDR whose seq is strictly greater than `min_seq`.
- /// Used after a publisher reconnect to skip stale IDRs from the
- /// previous session that still happen to live in the ring.
+ /// Most recent primary IDR whose seq is strictly greater than
+ /// `min_seq`. Used after a publisher reconnect to skip stale IDRs from
+ /// the previous session that still happen to live in the ring.
pub fn newest_idr_after(&self, min_seq: u64) -> Option {
let inner = self.inner.lock();
- inner
- .index
- .iter()
- .rev()
- .find(|m| m.is_idr && m.seq > min_seq)
- .copied()
+ inner.idr_index.back().filter(|m| m.seq > min_seq).copied()
}
- /// OLDEST IDR whose seq is >= `min_seq`. Used by the egress pump
- /// after eviction skip-ahead - landing on a random P-frame would
+ /// OLDEST primary IDR whose seq is >= `min_seq`. Used by the egress
+ /// pump after eviction skip-ahead - landing on a random P-frame would
/// stream P-frames that reference absent reference frames and the
/// player would show macroblocking until the next IDR. Returning
/// the *earliest* IDR at or after the skip target loses the least
/// content while keeping the decode chain valid.
pub fn oldest_idr_at_or_after(&self, min_seq: u64) -> Option {
let inner = self.inner.lock();
- inner
- .index
- .iter()
- .find(|m| m.is_idr && m.seq >= min_seq)
- .copied()
+ let pos = inner.idr_index.partition_point(|m| m.seq < min_seq);
+ inner.idr_index.get(pos).copied()
}
/// Seq of the most recently appended tag, or None if the ring is empty.
@@ -446,6 +461,7 @@ impl DiskRing {
}
if front.ts_ms < cutoff {
inner.index.pop_front();
+ inner.indexed_bytes -= u64::from(front.len);
// Keep the IDR-only index in sync - same defensive front
// check as the byte-overlap eviction path in `append`.
if front.is_idr && inner.idr_index.front().map(|m| m.seq) == Some(front.seq) {
@@ -477,6 +493,15 @@ impl DiskRing {
let mut inner = self.inner.lock();
inner.index.clear();
inner.idr_index.clear();
+ inner.indexed_bytes = 0;
+ }
+
+ /// Whether the ring is full: the tags it holds fill all but the last
+ /// 1/16 of it, so every new tag overwrites an old one and the buffer
+ /// reaches back no further at this bitrate. A delay longer than that
+ /// can never fill; the controller then delays by what the ring holds.
+ pub fn is_saturated(&self) -> bool {
+ self.inner.lock().indexed_bytes >= self.capacity - self.capacity / 16
}
}
@@ -683,41 +708,6 @@ mod tests {
assert_eq!(*t.0.metadata.lock(), Some(b"onMetaData".to_vec()));
}
- #[test]
- fn oversized_tag_is_rejected_silently() {
- // Capacity 4096, tag of 3000 bytes (> cap/2) - must be dropped.
- let t = tmp(4096);
- let big = vec![0u8; 3000];
- let r = t.0.append(9, 0, &big, false, false).unwrap();
- assert!(r.is_none());
- assert_eq!(t.0.front_seq(), None, "ring must remain empty");
- }
-
- #[test]
- fn wrapping_write_evicts_oldest_and_reads_correctly() {
- // Capacity 256. Write 6 × 80 = 480 bytes total - wraps the cursor
- // twice. Oldest tags get evicted; we read the latest one back.
- let t = tmp(256);
- let mut last_seq = 0;
- for i in 0..6 {
- let payload = vec![(i + 1) as u8; 80];
- last_seq =
- t.0.append(9, i as u64, &payload, false, false)
- .unwrap()
- .unwrap();
- }
- // Front is no longer seq=0
- let front = t.0.front_seq().unwrap();
- assert!(front > 0, "oldest tags must have been evicted");
-
- let mut buf = Vec::new();
- assert!(t.0.try_read_seq(last_seq, &mut buf).unwrap().is_some());
- assert_eq!(buf, vec![6u8; 80]);
-
- // The evicted oldest seq must read as None (it's gone).
- assert!(t.0.try_read_seq(0, &mut buf).unwrap().is_none());
- }
-
#[test]
fn find_idr_near_picks_closest() {
let t = tmp(8192);
@@ -818,45 +808,6 @@ mod tests {
assert!(t.0.try_read_seq(u64::MAX, &mut buf).unwrap().is_none());
}
- #[test]
- fn tag_exactly_at_half_capacity_is_accepted() {
- // The rejection threshold is `> cap/2`. A tag exactly at cap/2
- // bytes must be accepted (boundary check, not off-by-one).
- let t = tmp(2048);
- let payload = vec![0u8; 1024]; // exactly cap/2
- let r = t.0.append(9, 100, &payload, false, false).unwrap();
- assert!(
- r.is_some(),
- "tag at cap/2 must be accepted, not the rejection branch"
- );
- }
-
- #[test]
- fn ring_survives_multiple_full_wraps() {
- // Cap 512. Write 30 tags of 80 bytes = 2400 bytes total, ~4.7×
- // the capacity. The ring must keep the most recent tag readable
- // and the index must not corrupt itself across multiple wraps.
- let t = tmp(512);
- let mut last_seq = 0;
- for i in 0..30u32 {
- let payload = vec![i as u8; 80];
- last_seq =
- t.0.append(9, (i * 100) as u64, &payload, false, false)
- .unwrap()
- .unwrap();
- }
- let mut buf = Vec::new();
- let r = t.0.try_read_seq(last_seq, &mut buf).unwrap();
- assert!(r.is_some());
- assert_eq!(
- buf.first(),
- Some(&29u8),
- "latest tag's bytes must be intact"
- );
- // Front seq is well past 0 (many evictions happened)
- assert!(t.0.front_seq().unwrap() > 20);
- }
-
#[test]
fn all_idr_queries_return_none_on_empty_ring() {
// Every IDR-lookup variant must early-return None instead of
@@ -880,6 +831,62 @@ mod tests {
assert_eq!(t.0.find_idr_near(1000, 0).unwrap().ts_ms, 1000);
}
+ /// A Twitch Enhanced Broadcasting keyframe for a NON-primary ladder
+ /// rung: OneTrack multitrack, FrameType 1, TrackId `track`. Ingest
+ /// stores it with `is_idr = true` (classify_video_tag reads the outer
+ /// FrameType), but it is not a keyframe of the horizontal primary.
+ fn rung_idr_payload(track: u8) -> Vec {
+ let mut v = vec![0x96, 0x01, b'a', b'v', b'c', b'1', track];
+ v.resize(40, 0);
+ v
+ }
+
+ #[test]
+ fn idr_seeds_are_primary_track_keyframes_only() {
+ // With EB, OBS sends the primary's IDR and then each rung's IDR for
+ // the same frame. Seeding a Track(0) destination on a rung's IDR
+ // starts it one tag PAST its own keyframe, so it opens on primary
+ // P-frames with no reference: exactly the glitch the primary-only
+ // `idr_index` exists to prevent, reintroduced by the seed lookups
+ // that walked `index` for any `is_idr`.
+ let t = tmp(8192);
+ let p_frame = [0x27u8; 40];
+ let first =
+ t.0.append(9, 1000, &primary_idr_payload(40), true, false)
+ .unwrap()
+ .unwrap();
+ let rung =
+ t.0.append(9, 1000, &rung_idr_payload(1), true, false)
+ .unwrap()
+ .unwrap();
+ t.0.append(9, 1033, &p_frame, false, false).unwrap();
+ let second =
+ t.0.append(9, 2000, &primary_idr_payload(40), true, false)
+ .unwrap()
+ .unwrap();
+ t.0.append(9, 2000, &rung_idr_payload(2), true, false)
+ .unwrap();
+ t.0.append(9, 2033, &p_frame, false, false).unwrap();
+
+ assert_eq!(t.0.newest_idr().map(|m| m.seq), Some(second));
+ assert_eq!(t.0.newest_idr_after(first).map(|m| m.seq), Some(second));
+ assert!(
+ t.0.newest_idr_after(second).is_none(),
+ "only rung IDRs follow"
+ );
+ assert_eq!(
+ t.0.oldest_idr_at_or_after(rung).map(|m| m.seq),
+ Some(second)
+ );
+ assert_eq!(
+ t.0.oldest_idr_at_or_after(first).map(|m| m.seq),
+ Some(first)
+ );
+ // The rung's own flag is kept: the vertical-canvas pump reads
+ // `meta.is_idr` to find its own canvas's first keyframe.
+ assert!(t.0.find_by_seq(rung).unwrap().1.is_idr);
+ }
+
#[test]
fn newest_idr_after_skips_stale_publisher_idrs() {
// Models the publisher-reconnect case: there are old IDRs in the
@@ -897,16 +904,58 @@ mod tests {
#[cfg(test)]
mod edge_tests {
use super::*;
+ use std::sync::atomic::{AtomicU32, Ordering};
+
+ static UNIQ: AtomicU32 = AtomicU32::new(0);
+
+ /// A ring in its own temp file, deleted on drop - including when an
+ /// assertion fails mid-test. The name is process id plus a counter, so
+ /// tests running in parallel (or two `cargo test` runs) never share a
+ /// file the way a wall-clock name could.
+ struct Ring(DiskRing, std::path::PathBuf);
- fn ring(capacity: u64) -> (DiskRing, std::path::PathBuf) {
- let nanos = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_nanos())
- .unwrap_or(0);
- let path = std::env::temp_dir().join(format!("ic-edge-ring-{nanos}-{capacity}.buf"));
+ impl Drop for Ring {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_file(&self.1);
+ }
+ }
+
+ impl std::ops::Deref for Ring {
+ type Target = DiskRing;
+ fn deref(&self) -> &DiskRing {
+ &self.0
+ }
+ }
+
+ fn ring(capacity: u64) -> Ring {
+ let n = UNIQ.fetch_add(1, Ordering::SeqCst);
+ let path =
+ std::env::temp_dir().join(format!("ic-edge-ring-{}-{}.buf", std::process::id(), n));
let _ = std::fs::remove_file(&path);
- let r = DiskRing::create(&path, capacity).expect("ring create");
- (r, path)
+ Ring(
+ DiskRing::create(&path, capacity).expect("ring create"),
+ path,
+ )
+ }
+
+ /// Legacy AVC keyframe shape, so it passes the primary-IDR gate.
+ fn idr(len: usize) -> Vec {
+ let mut v = vec![0x17];
+ v.resize(len, 0);
+ v
+ }
+
+ /// Payload whose every byte depends on both the tag and the position,
+ /// so a read that mixes two tags or rotates the split cannot pass.
+ fn distinct(tag: u64, len: usize) -> Vec {
+ (0..len).map(|j| (tag * 7 + j as u64 * 13) as u8).collect()
+ }
+
+ fn read(r: &DiskRing, seq: u64) -> Option> {
+ let mut buf = Vec::new();
+ r.try_read_seq(seq, &mut buf)
+ .expect("no io error")
+ .map(|()| buf)
}
/// A tag too big for the ring to hold half of can never be stored
@@ -914,7 +963,7 @@ mod edge_tests {
/// the index. The ring has to stay usable afterwards.
#[test]
fn an_oversized_tag_is_refused_and_leaves_the_ring_usable() {
- let (r, path) = ring(64 * 1024);
+ let r = ring(64 * 1024);
let half = 32 * 1024;
assert_eq!(
@@ -931,31 +980,23 @@ mod edge_tests {
.expect("no io error")
.is_some());
assert_eq!(r.latest_ts(), Some(2_000));
-
- let _ = std::fs::remove_file(&path);
}
/// An empty payload is a degenerate tag, not a reason to panic or to
/// desynchronise the write cursor from the index.
#[test]
fn an_empty_payload_does_not_break_the_cursor() {
- let (r, path) = ring(64 * 1024);
+ let r = ring(64 * 1024);
r.append(9, 1_000, &[], false, false).expect("no io error");
r.append(9, 2_000, &[0x27; 100], false, false)
.expect("no io error");
assert_eq!(r.latest_ts(), Some(2_000));
- let mut buf = Vec::new();
let seq = r.latest_seq().expect("a populated ring");
- assert!(r
- .try_read_seq(seq, &mut buf)
- .expect("no io error")
- .is_some());
assert_eq!(
- buf.len(),
- 100,
+ read(&r, seq).map(|b| b.len()),
+ Some(100),
"the tag after an empty one reads back whole"
);
- let _ = std::fs::remove_file(&path);
}
/// Trim takes a cutoff derived from the newest timestamp. When every tag
@@ -963,7 +1004,7 @@ mod edge_tests {
/// clock jump from emptying the buffer.
#[test]
fn trim_keeps_everything_newer_than_its_cutoff() {
- let (r, path) = ring(64 * 1024);
+ let r = ring(64 * 1024);
for i in 1..=10u64 {
r.append(9, i * 100, &[0x27; 64], false, false)
.expect("no io error");
@@ -975,31 +1016,152 @@ mod edge_tests {
// future timestamp used to trigger.
r.trim_older_than(0, 1_000_000, u64::MAX);
assert_eq!(r.oldest_ts(), None);
- let _ = std::fs::remove_file(&path);
+ }
+
+ /// The one tag whose bytes are split across the end of the file: the
+ /// write and the read each take two syscalls, and both halves have to
+ /// land in the right order at the right offsets.
+ #[test]
+ fn a_tag_straddling_the_end_of_the_file_reads_back_exactly() {
+ let r = ring(256);
+ for tag in 0..4u64 {
+ r.append(9, tag * 10, &distinct(tag, 80), false, false)
+ .expect("no io error");
+ }
+ // Offsets 0, 80, 160, then 240..256 + 0..64, which overwrote tag 0.
+ assert_eq!(read(&r, 0), None, "tag 0's bytes were overwritten");
+ for tag in 1..4u64 {
+ assert_eq!(read(&r, tag), Some(distinct(tag, 80)), "tag {tag}");
+ }
}
/// Writes wrap the file. The index has to keep pointing at bytes that
- /// still belong to the tag it names, across a full lap of the ring.
+ /// still belong to the tag it names, across several laps of the ring.
+ /// 300-byte tags do not divide the capacity, so the split point moves
+ /// on every lap, and every live tag is checked after every append.
#[test]
- fn tags_read_back_correctly_after_the_ring_laps() {
- let (r, path) = ring(16 * 1024);
- let mut last_seq = 0;
- for i in 1..=200u64 {
- let payload = vec![(i % 251) as u8; 300];
- if let Some(seq) = r.append(9, i * 10, &payload, false, false).expect("no io") {
- last_seq = seq;
+ fn every_live_tag_reads_back_exactly_across_several_laps() {
+ let r = ring(16 * 1024);
+ for tag in 0..120u64 {
+ let seq = r
+ .append(9, tag * 10, &distinct(tag, 300), false, false)
+ .expect("no io error")
+ .expect("fits");
+ assert_eq!(seq, tag);
+ let front = r.front_seq().expect("a populated ring");
+ assert!(
+ (seq + 1 - front) * 300 <= 16 * 1024,
+ "live tags fit the file"
+ );
+ for live in front..=seq {
+ assert_eq!(read(&r, live), Some(distinct(live, 300)), "tag {live}");
}
}
- let mut buf = Vec::new();
- assert!(r
- .try_read_seq(last_seq, &mut buf)
- .expect("no io error")
- .is_some());
- assert_eq!(buf.len(), 300);
+ let front = r.front_seq().expect("a populated ring");
+ assert!(front > 60, "two laps evicted the early tags");
+ assert_eq!(read(&r, front - 1), None, "an evicted tag reads as gone");
+ }
+
+ /// Both ways a keyframe leaves the index - overwritten by a wrap, or
+ /// aged out by trim - must also take it out of the cut-point index, or
+ /// a cut would seek to bytes that now belong to another tag.
+ #[test]
+ fn evicted_keyframes_are_never_returned_as_cut_points() {
+ let r = ring(256);
+ r.append(9, 1_000, &idr(80), true, false)
+ .expect("no io error");
+ for i in 1..4u64 {
+ r.append(9, 1_000 + i * 33, &[0x27; 80], false, false)
+ .expect("no io error");
+ }
+ assert!(r.find_idr_near(1_000, 10_000).is_none(), "wrapped away");
+ assert!(r.newest_idr().is_none());
+ assert!(r.oldest_idr_at_or_after(0).is_none());
+
+ let r = ring(64 * 1024);
+ r.append(9, 100, &idr(40), true, false)
+ .expect("no io error");
+ r.append(9, 200, &[0x27; 40], false, false)
+ .expect("no io error");
+ r.append(9, 5_000, &idr(40), true, false)
+ .expect("no io error");
+ r.append(9, 5_100, &[0x27; 40], false, false)
+ .expect("no io error");
+ r.trim_older_than(1_000, 5_100, u64::MAX);
+ assert_eq!(r.oldest_ts(), Some(5_000));
+ assert!(r.find_idr_near(100, 1_000).is_none(), "trimmed away");
+ assert_eq!(r.find_idr_near(100, 10_000).map(|m| m.ts_ms), Some(5_000));
+ assert_eq!(r.oldest_idr_at_or_after(0).map(|m| m.ts_ms), Some(5_000));
+ }
+
+ /// `clear` runs when a new publisher takes over. Seqs keep counting
+ /// from the old high-water mark so a destination's saved position can
+ /// never alias a new tag, and nothing from the old session - bytes or
+ /// cut points - may surface again.
+ #[test]
+ fn clear_forgets_the_old_session_but_keeps_counting_seqs() {
+ let r = ring(64 * 1024);
+ r.append(9, 90_000, &idr(40), true, false)
+ .expect("no io error");
+ r.append(9, 90_033, &[0x27; 40], false, false)
+ .expect("no io error");
+ r.clear();
+
+ assert_eq!(r.latest_seq(), None);
+ assert!(r.newest_idr().is_none());
+ assert!(r.find_idr_near(90_000, 1_000).is_none());
+ assert_eq!(read(&r, 0), None);
+
+ let seq = r.append(9, 10, &[0x27; 40], false, false).unwrap().unwrap();
+ assert_eq!(seq, 2, "continues above the old high-water mark");
+ assert_eq!(read(&r, 1), None, "old seqs stay gone");
assert!(
- buf.iter().all(|b| *b == buf[0]),
- "the newest tag read back as a mix of two writes"
+ r.newest_idr_after(1).is_none(),
+ "no keyframe in this session yet"
);
- let _ = std::fs::remove_file(&path);
+ let key = r.append(9, 20, &idr(40), true, false).unwrap().unwrap();
+ assert_eq!(r.newest_idr_after(1).map(|m| m.seq), Some(key));
+ }
+
+ #[test]
+ fn find_idr_near_before_the_first_keyframe_and_on_a_tie() {
+ let r = ring(64 * 1024);
+ r.append(9, 1_000, &idr(40), true, false)
+ .expect("no io error");
+ r.append(9, 2_000, &idr(40), true, false)
+ .expect("no io error");
+ // Before every keyframe: only the one above can match.
+ assert_eq!(r.find_idr_near(800, 300).map(|m| m.ts_ms), Some(1_000));
+ assert!(r.find_idr_near(800, 100).is_none());
+ // Equidistant: the earlier one wins, so the delay is never less
+ // than asked for when the two are otherwise equal.
+ assert_eq!(r.find_idr_near(1_500, 500).map(|m| m.ts_ms), Some(1_000));
+ assert!(r.find_idr_near(1_500, 499).is_none());
+ }
+
+ /// `is_saturated` follows what the ring holds: filling it past a lap
+ /// trips it (every new tag now overwrites the oldest), and trimming or
+ /// clearing releases it.
+ #[test]
+ fn saturation_follows_what_the_ring_holds() {
+ let r = ring(1_600);
+ let append = |from: u64, to: u64| {
+ for i in from..to {
+ r.append(8, i * 20, &[0u8; 100], false, false).unwrap();
+ }
+ };
+ append(0, 10);
+ assert!(!r.is_saturated(), "1000 of 1600 bytes held");
+ append(10, 40);
+ assert!(
+ r.is_saturated(),
+ "a full lap: each tag overwrites the oldest"
+ );
+ r.trim_older_than(100, 39 * 20, u64::MAX);
+ assert!(!r.is_saturated(), "trimmed to the last 100 ms");
+ append(40, 80);
+ assert!(r.is_saturated(), "full again");
+ r.clear();
+ assert!(!r.is_saturated(), "cleared");
}
}
diff --git a/src/compat.rs b/src/compat.rs
index f4a6f5c..fc1311f 100644
--- a/src/compat.rs
+++ b/src/compat.rs
@@ -95,10 +95,19 @@ fn keyframe_issue(params: &StreamParams, live: &[&Destination]) -> Option Option {
- if params.height == 0 || params.height <= KICK_MAX_HEIGHT {
+ let short_side = if params.width == 0 {
+ params.height
+ } else {
+ params.width.min(params.height)
+ };
+ if short_side == 0 || short_side <= KICK_MAX_HEIGHT {
return None;
}
if !live.iter().any(|d| d.platform == "kick") {
@@ -106,7 +115,7 @@ fn resolution_issue(params: &StreamParams, live: &[&Destination]) -> Option=` line. See `crate::integrations`.
+ pub integrations: Vec,
+ /// Discord webhooks the integrations can post to.
+ pub discord_channels: Vec,
+ /// Phone pushes (ntfy).
+ pub phone: PhoneConnection,
+ /// Whether the old single `discord_webhook_url` has been turned into a
+ /// Discord connection plus a "Stream alerts" integration. False only in
+ /// configs written before integrations existed.
+ pub integrations_migrated: bool,
+ /// The user's own Twitch app (Client ID from dev.twitch.tv), used to log
+ /// in to Twitch instead of the one built into releases. Empty uses the
+ /// built-in app. Not a secret: public apps have no client secret.
+ pub twitch_client_id: String,
+ /// During Twitch Shared Chat, messages from the partner channels'
+ /// viewers trigger chat integrations too. Off: only this channel's.
+ pub twitch_shared_chat: bool,
}
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
pub struct DelayProfile {
pub name: String,
pub delay_ms: u32,
@@ -155,11 +179,12 @@ pub const HK_MOD_CONTROL: u32 = 0x0002;
pub const HK_MOD_SHIFT: u32 = 0x0004;
pub const HK_MOD_WIN: u32 = 0x0008;
-/// The five delay actions a hotkey or MIDI control can drive, in the order
-/// everything else keys off: `Hotkeys::entries`, `MidiBindings::entries`, and
-/// the tray's `RegisterHotKey` ids. Adding an action means adding it here and
-/// to the two structs below, and nothing else has to agree by hand.
-pub const ACTIONS: [&str; 5] = ["toggle", "arm", "activate", "cut", "cut_after"];
+/// The actions a hotkey or MIDI control can drive, in the order everything
+/// else keys off: `Hotkeys::entries`, `MidiBindings::entries`, and the
+/// tray's `RegisterHotKey` ids. Adding an action means adding it here and to
+/// the two structs below, and nothing else has to agree by hand. New actions
+/// go at the end so existing tray ids stay put.
+pub const ACTIONS: [&str; 6] = ["toggle", "arm", "activate", "cut", "cut_after", "end_hold"];
/// Global hotkey bindings, one canonical combo string per delay action
/// (e.g. "Ctrl+Alt+D"; empty means unbound). Windows binds them via
@@ -180,19 +205,22 @@ pub struct Hotkeys {
pub cut: String,
/// Schedule a cut for the moment the current live edge airs.
pub cut_after: String,
+ /// End a crash-protection hold now (every destination ends).
+ pub end_hold: String,
}
impl Hotkeys {
/// Stable (action-name, binding) pairs. One source of truth for the
/// save writer, the JSON serializer, and the tray registrar, so a new
/// action is added in exactly one place.
- pub fn entries(&self) -> [(&'static str, &str); 5] {
+ pub fn entries(&self) -> [(&'static str, &str); 6] {
[
("toggle", &self.toggle),
("arm", &self.arm),
("activate", &self.activate),
("cut", &self.cut),
("cut_after", &self.cut_after),
+ ("end_hold", &self.end_hold),
]
}
@@ -203,6 +231,7 @@ impl Hotkeys {
"activate" => &mut self.activate,
"cut" => &mut self.cut,
"cut_after" => &mut self.cut_after,
+ "end_hold" => &mut self.end_hold,
_ => return None,
})
}
@@ -242,10 +271,10 @@ impl Hotkeys {
}
}
-/// MIDI controller bindings, one signature string per delay action (empty
+/// MIDI controller bindings, one signature string per action (empty
/// means unbound). A signature is `note::` for a note-on pad
/// or `cc::` for a control-change knob/button, channel
-/// 1-16 and data 0-127. Same five actions as `Hotkeys`; the MIDI listener
+/// 1-16 and data 0-127. Same actions as `Hotkeys`; the MIDI listener
/// thread matches an incoming message against these and fires the shared
/// controller action. Windows-only in effect (winmm), round-tripped
/// everywhere.
@@ -256,17 +285,19 @@ pub struct MidiBindings {
pub activate: String,
pub cut: String,
pub cut_after: String,
+ pub end_hold: String,
}
impl MidiBindings {
/// Stable (action-name, signature) pairs, mirroring `Hotkeys::entries`.
- pub fn entries(&self) -> [(&'static str, &str); 5] {
+ pub fn entries(&self) -> [(&'static str, &str); 6] {
[
("toggle", &self.toggle),
("arm", &self.arm),
("activate", &self.activate),
("cut", &self.cut),
("cut_after", &self.cut_after),
+ ("end_hold", &self.end_hold),
]
}
@@ -277,6 +308,7 @@ impl MidiBindings {
"activate" => &mut self.activate,
"cut" => &mut self.cut,
"cut_after" => &mut self.cut_after,
+ "end_hold" => &mut self.end_hold,
_ => return None,
})
}
@@ -533,7 +565,7 @@ fn vk_to_key(vk: u32) -> Option {
/// One streaming destination - Twitch, YouTube, a private server, etc.
/// Each destination runs its own RTMP egress and is paced/cut/timestamp-
/// rewritten independently from the same shared DiskRing.
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
pub struct Destination {
pub id: String, // stable across renames; UI key
pub name: String, // user label ("Twitch", "Backup", etc.)
@@ -585,15 +617,15 @@ pub struct Destination {
/// non-Twitch platforms (Twitch gets the native dual-canvas
/// passthrough). One of:
/// "horizontal" (default) - the primary 16:9 canvas (TrackId 0)
- /// "vertical" - the 9:16 canvas Twitch Dual Format /
- /// Enhanced Broadcasting produces, reused
- /// for YouTube Shorts / Kick mobile / etc.
+ /// "vertical" - the 9:16 canvas OBS sends with Enhanced
+ /// Broadcasting (its Additional canvas, or
+ /// Twitch Dual Format), for TikTok /
+ /// YouTube Shorts / Kick mobile.
///
- /// Vertical only has data on the wire while Twitch Dual Format (EB)
- /// is active in OBS. With EB off there is no vertical canvas, so a
- /// vertical destination waits (sends no video) until one appears -
- /// see `h264::detect_vertical_primary_track`. Unknown / empty values
- /// fall back to "horizontal".
+ /// Vertical only has data on the wire while OBS sends that canvas.
+ /// Without it a vertical destination waits (sends no video) until one
+ /// appears - see `h264::detect_vertical_primary_track`. Unknown / empty
+ /// values fall back to "horizontal".
pub stream_format: String,
/// Which OBS audio track(s) this destination receives. Only meaningful
/// once OBS sends a second audio track (the VOD-unlocker script, or
@@ -700,9 +732,121 @@ impl Destination {
pub fn wants_vertical(&self) -> bool {
self.platform != "twitch" && self.stream_format == "vertical"
}
+
+ /// Whether this destination's ingest plays `codec` over Enhanced RTMP.
+ /// H.264 plays everywhere; HEVC and AV1 only where known to work
+ /// (YouTube takes both; Restream took an HEVC vertical live). Kick,
+ /// Trovo and custom servers get H.264, since a wrong guess is a stream
+ /// that never starts.
+ pub fn plays(&self, codec: crate::h264::VideoCodec) -> bool {
+ use crate::h264::VideoCodec;
+ match codec {
+ VideoCodec::Avc => true,
+ VideoCodec::Hevc => matches!(self.platform.as_str(), "youtube" | "restream"),
+ VideoCodec::Av1 => self.platform == "youtube",
+ VideoCodec::Vp9 | VideoCodec::Unknown => false,
+ }
+ }
}
impl Settings {
+ /// `integration.=`, `connection.discord.=` and
+ /// `connection.phone=` lines. Compact JSON has no newline, so each
+ /// stays one line of the key=value format.
+ fn write_integration_lines(&self, f: &mut impl Write) -> io::Result<()> {
+ if self.integrations_migrated {
+ writeln!(f, "integrations_migrated=true")?;
+ }
+ for c in &self.discord_channels {
+ let v = json::obj([("name", json::str(&c.name)), ("url", json::str(&c.url))]);
+ writeln!(
+ f,
+ "connection.discord.{}={}",
+ one_line(&c.id),
+ one_line(&v.to_json())
+ )?;
+ }
+ if self.phone != PhoneConnection::default() {
+ let v = json::obj([
+ ("server", json::str(&self.phone.server)),
+ ("topic", json::str(&self.phone.topic)),
+ ]);
+ writeln!(f, "connection.phone={}", one_line(&v.to_json()))?;
+ }
+ for i in &self.integrations {
+ writeln!(
+ f,
+ "integration.{}={}",
+ one_line(&i.id),
+ one_line(&i.to_json().to_json())
+ )?;
+ }
+ Ok(())
+ }
+
+ /// Load one integration line. A line that no longer parses (a hand edit,
+ /// a newer version's step type) is skipped rather than failing the
+ /// whole config: the other integrations and settings still load.
+ fn load_integration(&mut self, value: &str) {
+ if self.integrations.len() >= MAX_INTEGRATIONS {
+ return;
+ }
+ let Ok(parsed) = json::parse(value) else {
+ return;
+ };
+ let Ok(integration) = Integration::from_json(&parsed) else {
+ return;
+ };
+ if crate::integrations::model::valid_id(&integration.id)
+ && !self.integrations.iter().any(|i| i.id == integration.id)
+ {
+ self.integrations.push(integration);
+ }
+ }
+
+ fn load_discord_channel(&mut self, id: &str, value: &str) {
+ if !crate::integrations::model::valid_id(id)
+ || self.discord_channels.len() >= 32
+ || self.discord_channels.iter().any(|c| c.id == id)
+ {
+ return;
+ }
+ if let Ok(v) = json::parse(value) {
+ self.discord_channels.push(DiscordChannel {
+ id: id.to_string(),
+ name: v.str_or("name", "Discord").to_string(),
+ url: v.str_or("url", "").to_string(),
+ });
+ }
+ }
+
+ /// One-time move of the old single Discord webhook into integrations:
+ /// a Discord connection plus a "Stream alerts" integration that posts
+ /// the same messages as before. Runs once per config.
+ fn migrate_discord_webhook(&mut self) {
+ if self.integrations_migrated {
+ return;
+ }
+ self.integrations_migrated = true;
+ let url = std::mem::take(&mut self.discord_webhook_url);
+ if url.trim().is_empty() {
+ return;
+ }
+ let channel_id = "discord".to_string();
+ if !self.discord_channels.iter().any(|c| c.id == channel_id) {
+ self.discord_channels.push(DiscordChannel {
+ id: channel_id.clone(),
+ name: "Discord".to_string(),
+ url,
+ });
+ }
+ self.integrations
+ .push(crate::integrations::presets::legacy_alerts(
+ "stream-alerts".to_string(),
+ &channel_id,
+ ));
+ }
+
pub fn defaults() -> Self {
Self {
ingest_port: 1935,
@@ -755,7 +899,7 @@ impl Settings {
tracing_enabled: false,
// Behaviour toggles default off so the two-phase
// arm/activate ceremony stays the canonical flow. Streamers
- // who always want delay opt in once via System -> Behavior.
+ // who always want delay opt in once via System -> General.
auto_arm_on_connect: false,
auto_activate_when_ready: false,
// 15 s is the same default the wizard suggests and matches
@@ -778,6 +922,15 @@ impl Settings {
// Likewise no MIDI bindings until the user maps a controller.
midi: MidiBindings::default(),
midi_device: String::new(),
+ crash_protection: CrashProtection::default(),
+ integrations: Vec::new(),
+ discord_channels: Vec::new(),
+ phone: PhoneConnection::default(),
+ // A fresh install has no old webhook to migrate; `load` sets
+ // this after its one-time pass either way.
+ integrations_migrated: false,
+ twitch_client_id: String::new(),
+ twitch_shared_chat: false,
}
}
@@ -787,7 +940,11 @@ impl Settings {
// must see them stay deleted across restarts.
s.profiles.clear();
s.destinations.clear();
- let text = fs::read_to_string(path)?;
+ // Lossy on purpose: a hand edit saved as ANSI is not valid UTF-8,
+ // and failing the whole read over one accented character would
+ // cost the user every other setting. Only the damaged line changes.
+ let bytes = fs::read(path)?;
+ let text = String::from_utf8_lossy(&bytes);
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
@@ -824,6 +981,7 @@ impl Settings {
audio_track: "auto".into(),
});
}
+ s.migrate_discord_webhook();
// Clamp / sanitize on load - hand-edited values can otherwise
// hit divide-by-zero (buffer_mb=0 → `% capacity` in DiskRing) or
// bind two services to the same port (one will silently fail).
@@ -868,11 +1026,57 @@ impl Settings {
}
}
- pub fn load_or_default(path: &Path) -> Self {
- match Self::load(path) {
- Ok(s) => s,
- Err(_) => Self::defaults().with_smart_defaults(),
- }
+ /// Load the config for process startup. Only a missing file means
+ /// "fresh install": that case gets smart defaults, saved at once so the
+ /// file appears for the user to find.
+ ///
+ /// Any other read error (antivirus or a sync client holding the file,
+ /// a permissions problem) is retried briefly. If it persists, the file
+ /// is copied aside to `.unreadable` and startup runs on defaults
+ /// with `may_save == false`, so startup itself never writes over it.
+ /// A later save from the dashboard does replace it, which is safe only
+ /// because the copy exists; when the copy cannot be made either, this
+ /// returns the error and the caller must not start.
+ pub fn load_for_startup(path: &Path) -> io::Result {
+ Self::load_for_startup_with(path, Self::load)
+ }
+
+ /// `load_for_startup` with the loader injected, so tests can simulate a
+ /// locked file without one.
+ fn load_for_startup_with(
+ path: &Path,
+ load: impl Fn(&Path) -> io::Result,
+ ) -> io::Result {
+ let mut attempt = 1;
+ let error = loop {
+ match load(path) {
+ Ok(settings) => return Ok(StartupConfig::loaded(settings)),
+ Err(e) if e.kind() == io::ErrorKind::NotFound => {
+ let settings = Self::defaults().with_smart_defaults();
+ let _ = settings.save(path);
+ return Ok(StartupConfig::loaded(settings));
+ }
+ Err(e) if attempt >= STARTUP_READ_ATTEMPTS => break e,
+ Err(_) => {
+ attempt += 1;
+ std::thread::sleep(STARTUP_READ_RETRY_DELAY);
+ }
+ }
+ };
+ let backup = copy_aside(path, "unreadable")?;
+ Ok(StartupConfig {
+ settings: Self::defaults().with_smart_defaults(),
+ may_save: false,
+ notice: Some(format!(
+ "InstantClone couldn't read its settings file, so it started with \
+ default settings.\n\nFile: {}\nError: {error}\n\nA copy of the file \
+ was saved as:\n{}\n\nClose whatever is holding the file and restart \
+ InstantClone to use your settings again. Until then, any settings \
+ change replaces the file; the copy stays either way.",
+ path.display(),
+ backup.display()
+ )),
+ })
}
/// On a brand-new install, pick smarter starting values than the
@@ -931,6 +1135,11 @@ impl Settings {
// Legacy single-destination fields: kept for backward compat with
// older binaries that don't know about `destination.*`. We mirror
// destinations[0] into them so a downgrade still works.
+ //
+ // With no destinations the key and URL are written empty, never
+ // from the loaded legacy fields: those still hold whatever the
+ // file had, and `load` would migrate them back into an enabled
+ // destination, undoing the user's delete of the last one.
let mirror = self.destinations.first();
writeln!(
f,
@@ -944,20 +1153,12 @@ impl Settings {
writeln!(
f,
"stream_key={}",
- one_line(
- mirror
- .map(|d| d.stream_key.as_str())
- .unwrap_or(&self.stream_key)
- )
+ one_line(mirror.map(|d| d.stream_key.as_str()).unwrap_or(""))
)?;
writeln!(
f,
"custom_egress_url={}",
- one_line(
- mirror
- .map(|d| d.custom_egress_url.as_str())
- .unwrap_or(&self.custom_egress_url)
- )
+ one_line(mirror.map(|d| d.custom_egress_url.as_str()).unwrap_or(""))
)?;
writeln!(f, "ingest_port={}", self.ingest_port)?;
writeln!(f, "ingest_bind_all={}", self.ingest_bind_all)?;
@@ -1037,6 +1238,12 @@ impl Settings {
writeln!(f, "hotkey.{}={}", action, combo)?;
}
}
+ if !self.twitch_client_id.is_empty() {
+ writeln!(f, "twitch_client_id={}", one_line(&self.twitch_client_id))?;
+ }
+ if self.twitch_shared_chat {
+ writeln!(f, "twitch_shared_chat=true")?;
+ }
// MIDI bindings, same only-when-bound rule.
if !self.midi_device.is_empty() {
writeln!(f, "midi_device={}", one_line(&self.midi_device))?;
@@ -1046,6 +1253,9 @@ impl Settings {
writeln!(f, "midi.{}={}", action, sig)?;
}
}
+ // Crash protection: only fields that differ from the default.
+ self.crash_protection.write_lines(&mut f)?;
+ self.write_integration_lines(&mut f)?;
for (i, p) in self.profiles.iter().enumerate() {
writeln!(f, "profile.{}.name={}", i, one_line(&p.name))?;
writeln!(f, "profile.{}.delay_ms={}", i, p.delay_ms)?;
@@ -1187,11 +1397,30 @@ impl Settings {
self.hotkeys.set(&k["hotkey.".len()..], value);
}
"midi_device" => self.midi_device = sanitize_device_name(value),
+ "twitch_client_id" => self.twitch_client_id = value.trim().to_string(),
+ "twitch_shared_chat" => self.twitch_shared_chat = value == "true",
// midi.=. `set` validates and drops anything
// malformed, so a hand-edited config can't load a bad signature.
k if k.starts_with("midi.") => {
self.midi.set(&k["midi.".len()..], value);
}
+ k if k.starts_with(crash_protection::KEY_PREFIX) => {
+ self.crash_protection
+ .set(&k[crash_protection::KEY_PREFIX.len()..], value);
+ }
+ "integrations_migrated" => self.integrations_migrated = value == "true",
+ k if k.starts_with("integration.") => self.load_integration(value),
+ k if k.starts_with("connection.discord.") => {
+ self.load_discord_channel(&k["connection.discord.".len()..], value)
+ }
+ "connection.phone" => {
+ if let Ok(v) = json::parse(value) {
+ self.phone = PhoneConnection {
+ server: v.str_or("server", "").to_string(),
+ topic: v.str_or("topic", "").to_string(),
+ };
+ }
+ }
k if k.starts_with("profile.") => {
let rest = &k[8..];
if let Some(dot) = rest.find('.') {
@@ -1431,20 +1660,22 @@ impl Settings {
// Hotkey bindings. Not secrets, so always emitted; the dashboard
// only surfaces the editor on Windows (where they take effect).
let hotkeys = format!(
- r#"{{"toggle":{t},"arm":{a},"activate":{ac},"cut":{c},"cut_after":{ca}}}"#,
+ r#"{{"toggle":{t},"arm":{a},"activate":{ac},"cut":{c},"cut_after":{ca},"end_hold":{eh}}}"#,
t = json_str(&self.hotkeys.toggle),
a = json_str(&self.hotkeys.arm),
ac = json_str(&self.hotkeys.activate),
c = json_str(&self.hotkeys.cut),
ca = json_str(&self.hotkeys.cut_after),
+ eh = json_str(&self.hotkeys.end_hold),
);
let midi = format!(
- r#"{{"toggle":{t},"arm":{a},"activate":{ac},"cut":{c},"cut_after":{ca}}}"#,
+ r#"{{"toggle":{t},"arm":{a},"activate":{ac},"cut":{c},"cut_after":{ca},"end_hold":{eh}}}"#,
t = json_str(&self.midi.toggle),
a = json_str(&self.midi.arm),
ac = json_str(&self.midi.activate),
c = json_str(&self.midi.cut),
ca = json_str(&self.midi.cut_after),
+ eh = json_str(&self.midi.end_hold),
);
// The two raw credentials are emitted only for a full session; a
// dock-token caller gets them blanked (see the doc comment above).
@@ -1459,7 +1690,7 @@ impl Settings {
""
};
format!(
- r#"{{"configured":{c},"ingest_port":{ip},"ingest_bind_all":{iba},"web_port":{wp},"web_bind_all":{wba},"buffer_mb":{bm},"buffer_path":{bp},"target_delay_ms":{td},"obs_url":{ou},"discord_webhook_url":{dw},"webhook_set":{ws},"overlays_dir":{ov},"tracing_enabled":{te},"auto_arm_on_connect":{aaoc},"auto_activate_when_ready":{aawr},"auto_arm_delay_ms":{aadm},"overlays_seeded":{os},"start_with_windows":{sww},"update_check_enabled":{uce},"open_dashboard_on_launch":{odol},"ingest_key":{ik},"auth_enabled":{ae},"dock_token":{dt},"os":{osname},"version":{ver},"hotkeys":{hotkeys},"midi":{midi},"midi_device":{midid},"destinations":{dests}}}"#,
+ r#"{{"configured":{c},"ingest_port":{ip},"ingest_bind_all":{iba},"web_port":{wp},"web_bind_all":{wba},"buffer_mb":{bm},"buffer_path":{bp},"target_delay_ms":{td},"obs_url":{ou},"discord_webhook_url":{dw},"webhook_set":{ws},"overlays_dir":{ov},"tracing_enabled":{te},"auto_arm_on_connect":{aaoc},"auto_activate_when_ready":{aawr},"auto_arm_delay_ms":{aadm},"overlays_seeded":{os},"start_with_windows":{sww},"update_check_enabled":{uce},"open_dashboard_on_launch":{odol},"ingest_key":{ik},"auth_enabled":{ae},"dock_token":{dt},"os":{osname},"version":{ver},"hotkeys":{hotkeys},"midi":{midi},"midi_device":{midid},"crash_protection":{cp},"twitch_client_id":{tci},"twitch_app_built_in":{tbi},"destinations":{dests}}}"#,
c = self.configured,
sww = start_with_windows,
ik = json_str(ik_shown),
@@ -1488,6 +1719,9 @@ impl Settings {
hotkeys = hotkeys,
midi = midi,
midid = json_str(&self.midi_device),
+ tci = json_str(&self.twitch_client_id),
+ tbi = !crate::integrations::twitch::default_client_id().is_empty(),
+ cp = self.crash_protection.to_json(),
dests = dests,
)
}
@@ -1522,6 +1756,12 @@ impl Settings {
.into(),
);
}
+ if !is_twitch_client_id(&self.twitch_client_id) {
+ errs.push(
+ "That Twitch Client ID doesn't look right: copy the Client ID (letters and numbers) from your app at dev.twitch.tv/console"
+ .into(),
+ );
+ }
if self.destinations.len() > MAX_DESTINATIONS {
errs.push(format!("too many destinations (max {})", MAX_DESTINATIONS));
}
@@ -1532,6 +1772,12 @@ impl Settings {
errs.push("destination is missing a name".into());
continue;
}
+ // A switched-off destination is never streamed, so a half-filled
+ // one (no server URL or key yet) must not block saving anything
+ // else. Switching it on runs the checks below.
+ if !d.enabled {
+ continue;
+ }
if d.platform == "custom" || d.platform == "kick" {
if d.custom_egress_url.is_empty() {
let need = if d.platform == "kick" {
@@ -1793,6 +2039,14 @@ fn os_name() -> &'static str {
}
}
+/// Empty (use the built-in app) or a Twitch Client ID: Twitch issues 30
+/// lowercase letters and digits; a little slack either way in case that
+/// ever changes, but never spaces, quotes or anything else.
+fn is_twitch_client_id(id: &str) -> bool {
+ id.is_empty()
+ || ((20..=64).contains(&id.len()) && id.chars().all(|c| c.is_ascii_alphanumeric()))
+}
+
/// Strip control characters (notably CR/LF) from a value bound for the
/// line-based config file. The format is `key=value` per line, so a newline in
/// a value would inject a spurious second setting on the next load - e.g. a
@@ -1808,7 +2062,7 @@ fn one_line(s: &str) -> std::borrow::Cow<'_, str> {
}
}
-fn json_str(s: &str) -> String {
+pub(crate) fn json_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
@@ -1826,6 +2080,216 @@ fn json_str(s: &str) -> String {
out
}
+/// Attempts at reading an existing config before startup treats it as
+/// unreadable. Antivirus scans and sync clients hold a file for well under
+/// a second, so a short retry rides those out without delaying a real
+/// failure noticeably.
+const STARTUP_READ_ATTEMPTS: u32 = 4;
+const STARTUP_READ_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100);
+
+/// What `Settings::load_for_startup` hands to `main`.
+pub struct StartupConfig {
+ pub settings: Settings,
+ /// False when the settings are stand-in defaults for a file that exists
+ /// but could not be read: startup must not save them over it.
+ pub may_save: bool,
+ /// Message the user must see before the app runs, if any.
+ pub notice: Option,
+}
+
+impl StartupConfig {
+ fn loaded(settings: Settings) -> Self {
+ Self {
+ settings,
+ may_save: true,
+ notice: None,
+ }
+ }
+}
+
+/// Copy `path` to the first free `.`, `..2`, ...
+/// so an earlier copy is never overwritten. Returns where it went.
+fn copy_aside(path: &Path, suffix: &str) -> io::Result {
+ let name = path
+ .file_name()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "config path has no file name"))?
+ .to_string_lossy()
+ .into_owned();
+ for n in 1..=99 {
+ let candidate = if n == 1 {
+ path.with_file_name(format!("{name}.{suffix}"))
+ } else {
+ path.with_file_name(format!("{name}.{suffix}.{n}"))
+ };
+ if !candidate.exists() {
+ fs::copy(path, &candidate)?;
+ return Ok(candidate);
+ }
+ }
+ Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ format!("too many {name}.{suffix} copies already; move some away"),
+ ))
+}
+
+/// Strict JSON validity check (RFC 8259, one value, nothing trailing). The
+/// crate hand-writes every JSON document it emits, and a substring assert
+/// cannot catch a stray comma or an unescaped newline, so tests parse what
+/// they build with this. It also vets JSON read from OBS's files before it
+/// goes back to OBS, which refuses to stream on a malformed config. No
+/// dependency: a JSON crate would be pulled in for a yes or no.
+pub(crate) fn is_valid_json(s: &str) -> bool {
+ let mut parser = json_check::Parser {
+ bytes: s.as_bytes(),
+ pos: 0,
+ };
+ parser.skip_ws();
+ let ok = parser.value(0);
+ parser.skip_ws();
+ ok && parser.pos == parser.bytes.len()
+}
+
+mod json_check {
+ /// Deep enough for anything we emit, shallow enough that a runaway
+ /// nesting bug fails the test instead of overflowing the stack.
+ const MAX_DEPTH: usize = 64;
+
+ pub(super) struct Parser<'a> {
+ pub(super) bytes: &'a [u8],
+ pub(super) pos: usize,
+ }
+
+ impl Parser<'_> {
+ fn peek(&self) -> Option {
+ self.bytes.get(self.pos).copied()
+ }
+
+ fn eat(&mut self, b: u8) -> bool {
+ let hit = self.peek() == Some(b);
+ if hit {
+ self.pos += 1;
+ }
+ hit
+ }
+
+ pub(super) fn skip_ws(&mut self) {
+ while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
+ self.pos += 1;
+ }
+ }
+
+ pub(super) fn value(&mut self, depth: usize) -> bool {
+ if depth > MAX_DEPTH {
+ return false;
+ }
+ match self.peek() {
+ Some(b'{') => self.container(depth, b'}', true),
+ Some(b'[') => self.container(depth, b']', false),
+ Some(b'"') => self.string(),
+ Some(b't') => self.literal(b"true"),
+ Some(b'f') => self.literal(b"false"),
+ Some(b'n') => self.literal(b"null"),
+ _ => self.number(),
+ }
+ }
+
+ /// Object (`keyed`) or array body, after the opening bracket.
+ fn container(&mut self, depth: usize, close: u8, keyed: bool) -> bool {
+ self.pos += 1;
+ self.skip_ws();
+ if self.eat(close) {
+ return true;
+ }
+ loop {
+ self.skip_ws();
+ if keyed {
+ if !self.string() {
+ return false;
+ }
+ self.skip_ws();
+ if !self.eat(b':') {
+ return false;
+ }
+ self.skip_ws();
+ }
+ if !self.value(depth + 1) {
+ return false;
+ }
+ self.skip_ws();
+ if self.eat(close) {
+ return true;
+ }
+ if !self.eat(b',') {
+ return false;
+ }
+ }
+ }
+
+ fn string(&mut self) -> bool {
+ if !self.eat(b'"') {
+ return false;
+ }
+ while let Some(b) = self.peek() {
+ self.pos += 1;
+ match b {
+ b'"' => return true,
+ b'\\' => {
+ let Some(esc) = self.peek() else { return false };
+ self.pos += 1;
+ if esc == b'u' {
+ let hex = self.bytes.get(self.pos..self.pos + 4);
+ if !hex.is_some_and(|h| h.iter().all(u8::is_ascii_hexdigit)) {
+ return false;
+ }
+ self.pos += 4;
+ } else if !b"\"\\/bfnrt".contains(&esc) {
+ return false;
+ }
+ }
+ 0x00..=0x1f => return false,
+ _ => {}
+ }
+ }
+ false
+ }
+
+ fn literal(&mut self, word: &[u8]) -> bool {
+ let hit = self.bytes[self.pos..].starts_with(word);
+ if hit {
+ self.pos += word.len();
+ }
+ hit
+ }
+
+ fn digits(&mut self) -> usize {
+ let start = self.pos;
+ while self.peek().is_some_and(|b| b.is_ascii_digit()) {
+ self.pos += 1;
+ }
+ self.pos - start
+ }
+
+ fn number(&mut self) -> bool {
+ self.eat(b'-');
+ if self.eat(b'0') {
+ // A leading zero stands alone: `01` is not JSON.
+ } else if self.digits() == 0 {
+ return false;
+ }
+ if self.eat(b'.') && self.digits() == 0 {
+ return false;
+ }
+ if self.eat(b'e') || self.eat(b'E') {
+ let _ = self.eat(b'+') || self.eat(b'-');
+ if self.digits() == 0 {
+ return false;
+ }
+ }
+ true
+ }
+ }
+}
+
/// Best-effort free space query. Returns None if the OS doesn't tell us
/// (in which case the caller keeps the default).
fn free_space_bytes(path: &Path) -> Option {
@@ -1955,6 +2419,22 @@ mod tests {
use super::*;
use std::path::PathBuf;
+ /// HEVC or AV1 goes only where it is known to play; a wrong guess is a
+ /// vertical stream that never starts, so the rest get H.264.
+ #[test]
+ fn only_known_platforms_are_sent_hevc_or_av1() {
+ use crate::h264::VideoCodec::{Av1, Avc, Hevc, Unknown};
+ let on = |platform: &str| test_destination("d", platform, "key");
+ for platform in ["youtube", "restream", "kick", "trovo", "custom", "twitch"] {
+ assert!(on(platform).plays(Avc), "{platform} plays H.264");
+ assert!(!on(platform).plays(Unknown), "{platform}");
+ }
+ assert!(on("youtube").plays(Hevc) && on("restream").plays(Hevc));
+ assert!(!on("kick").plays(Hevc) && !on("custom").plays(Hevc));
+ assert!(on("youtube").plays(Av1));
+ assert!(!on("restream").plays(Av1) && !on("kick").plays(Av1));
+ }
+
/// Both legs, IPv4 first, in both bind modes. Order is contractual:
/// `spawn_ingest_legs` treats only the first address as required, so a
/// reordering here would silently make the IPv4 listener give up after
@@ -2405,6 +2885,31 @@ mod tests {
);
}
+ /// A switched-off, half-filled destination must not block saving other
+ /// settings (turning on crash protection failed on one); switching it
+ /// on still needs its fields.
+ #[test]
+ fn a_disabled_incomplete_destination_does_not_block_saving() {
+ let mut s = Settings::defaults();
+ s.destinations.push(Destination {
+ id: "k".into(),
+ name: "test3".into(),
+ enabled: false,
+ platform: "kick".into(),
+ stream_key: String::new(),
+ custom_egress_url: String::new(),
+ twitch_ingest: String::new(),
+ youtube_ingest: String::new(),
+ vod_audio: false,
+ vod_audio_inject_eb: false,
+ stream_format: "horizontal".into(),
+ audio_track: "auto".into(),
+ });
+ assert!(s.validate().is_empty(), "{:?}", s.validate());
+ s.destinations[0].enabled = true;
+ assert!(s.validate().iter().any(|e| e.contains("Kick Server URL")));
+ }
+
#[test]
fn destination_custom_rtmps_url_is_accepted() {
// The Custom platform is the escape hatch for any TLS ingest
@@ -2477,238 +2982,6 @@ mod tests {
);
}
- #[test]
- fn settings_save_load_roundtrip_preserves_destinations_and_profiles() {
- // Build a config that exercises every field the on-disk format
- // touches: ports, paths, multiple destinations including a
- // custom-URL one, multiple profiles, and a webhook URL.
- let mut s = Settings::defaults();
- s.ingest_port = 1936;
- s.web_port = 7898;
- s.buffer_mb = 200;
- s.discord_webhook_url = "https://discord.com/api/webhooks/123/abc".into();
- s.destinations.clear();
- s.destinations.push(Destination {
- id: "tw1".into(),
- name: "Twitch".into(),
- enabled: true,
- platform: "twitch".into(),
- stream_key: "live_secret".into(),
- custom_egress_url: String::new(),
- twitch_ingest: "fra".into(),
- youtube_ingest: String::new(),
- vod_audio: false,
- vod_audio_inject_eb: false,
- stream_format: "horizontal".into(),
- audio_track: "auto".into(),
- });
- s.destinations.push(Destination {
- id: "yt1".into(),
- name: "YT Backup".into(),
- enabled: false,
- platform: "youtube".into(),
- stream_key: "yt_secret".into(),
- custom_egress_url: String::new(),
- twitch_ingest: String::new(),
- youtube_ingest: "backup".into(),
- vod_audio: false,
- vod_audio_inject_eb: false,
- // Non-default value so the round-trip proves stream_format
- // both writes (only when != "horizontal") and reads back.
- stream_format: "vertical".into(),
- // Non-default: the clean track routed to YouTube (copyright).
- // Proves audio_track writes only when != "auto" and reads back.
- audio_track: "2".into(),
- });
- s.profiles = vec![
- DelayProfile {
- name: "Quick".into(),
- delay_ms: 10_000,
- },
- DelayProfile {
- name: "Long".into(),
- delay_ms: 120_000,
- },
- ];
-
- let path = std::env::temp_dir().join(format!("ic-test-cfg-{}.ini", std::process::id()));
- let _ = std::fs::remove_file(&path);
- s.save(&path).expect("save");
- let loaded = Settings::load(&path).expect("load");
- let _ = std::fs::remove_file(&path);
-
- assert_eq!(loaded.ingest_port, 1936);
- assert_eq!(loaded.web_port, 7898);
- assert_eq!(loaded.buffer_mb, 200);
- assert_eq!(
- loaded.discord_webhook_url,
- "https://discord.com/api/webhooks/123/abc"
- );
- assert_eq!(loaded.destinations.len(), 2);
- assert_eq!(loaded.destinations[0].id, "tw1");
- assert_eq!(loaded.destinations[0].name, "Twitch");
- assert_eq!(loaded.destinations[0].stream_key, "live_secret");
- assert_eq!(loaded.destinations[0].twitch_ingest, "fra");
- assert!(loaded.destinations[0].enabled);
- assert!(!loaded.destinations[1].enabled);
- assert_eq!(loaded.destinations[1].youtube_ingest, "backup");
- assert_eq!(loaded.destinations[0].stream_format, "horizontal");
- assert_eq!(loaded.destinations[1].stream_format, "vertical");
- // audio_track: default stays "auto", the non-default "2" survives.
- assert_eq!(loaded.destinations[0].audio_track, "auto");
- assert_eq!(loaded.destinations[1].audio_track, "2");
- assert_eq!(loaded.profiles.len(), 2);
- assert_eq!(loaded.profiles[0].delay_ms, 10_000);
- assert_eq!(loaded.profiles[1].name, "Long");
- }
-
- #[test]
- fn dock_layouts_round_trip_through_save_and_load() {
- // Dock layouts are opaque single-line JSON blobs stored as
- // `dock.=`. Prove a value containing `=`, `:`, and quotes
- // survives the line parser (it splits on the first `=` only, so the
- // JSON body is preserved verbatim).
- let mut s = Settings::defaults();
- s.docks.insert(
- "default".into(),
- r#"{"v":1,"preset":"minimal","w":{"number":{"on":true,"step":5}}}"#.into(),
- );
- s.docks
- .insert("gaming".into(), r#"{"preset":"delay","eq":"a=b"}"#.into());
-
- let path = std::env::temp_dir().join(format!("ic-test-docks-{}.ini", std::process::id()));
- let _ = std::fs::remove_file(&path);
- s.save(&path).expect("save");
- let loaded = Settings::load(&path).expect("load");
- let _ = std::fs::remove_file(&path);
-
- assert_eq!(loaded.docks.len(), 2);
- assert_eq!(
- loaded.docks.get("default").map(String::as_str),
- Some(r#"{"v":1,"preset":"minimal","w":{"number":{"on":true,"step":5}}}"#)
- );
- assert_eq!(
- loaded.docks.get("gaming").map(String::as_str),
- Some(r#"{"preset":"delay","eq":"a=b"}"#),
- "value after the first = (including embedded =) must survive"
- );
- }
-
- #[test]
- fn behavior_toggles_round_trip_through_save_and_load() {
- // v0.1.4 added three Settings fields for the auto-arm /
- // auto-activate behaviour. Save them, load them back, assert
- // every field survives. The format::Display writer is
- // conditional (only emits non-default values) so this also
- // verifies the apply_field reader sees those conditional keys.
- let path = std::env::temp_dir().join(format!(
- "ic-test-behavior-roundtrip-{}-{}.ini",
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_nanos()
- ));
- let _ = std::fs::remove_file(&path);
-
- let mut s = Settings::defaults();
- s.auto_arm_on_connect = true;
- s.auto_activate_when_ready = true;
- s.auto_arm_delay_ms = 30_000;
- s.overlays_seeded = true;
- // Need a destination so save() doesn't bail on the
- // configured-but-empty path.
- s.destinations.push(Destination {
- id: "test".into(),
- name: "Test".into(),
- enabled: true,
- platform: "twitch".into(),
- stream_key: "test".into(),
- custom_egress_url: String::new(),
- twitch_ingest: String::new(),
- youtube_ingest: String::new(),
- vod_audio: false,
- vod_audio_inject_eb: false,
- stream_format: "horizontal".into(),
- audio_track: "auto".into(),
- });
- s.save(&path).expect("save");
-
- let loaded = Settings::load(&path).expect("load");
- assert!(
- loaded.auto_arm_on_connect,
- "auto_arm_on_connect must survive round-trip"
- );
- assert!(
- loaded.auto_activate_when_ready,
- "auto_activate_when_ready must survive round-trip"
- );
- assert_eq!(
- loaded.auto_arm_delay_ms, 30_000,
- "auto_arm_delay_ms must survive round-trip"
- );
- assert!(
- loaded.overlays_seeded,
- "overlays_seeded must survive round-trip"
- );
-
- let _ = std::fs::remove_file(&path);
- }
-
- #[test]
- fn default_true_toggles_round_trip_when_disabled() {
- // update_check_enabled and open_dashboard_on_launch both default
- // true and are written only when turned off. Flipping them off must
- // survive a save/load, or a privacy opt-out would silently revert.
- let path = std::env::temp_dir().join(format!(
- "ic-test-optout-roundtrip-{}-{}.ini",
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_nanos()
- ));
- let _ = std::fs::remove_file(&path);
-
- let mut s = Settings::defaults();
- assert!(s.update_check_enabled, "defaults on");
- assert!(s.open_dashboard_on_launch, "defaults on");
- s.update_check_enabled = false;
- s.open_dashboard_on_launch = false;
- s.destinations.push(Destination {
- id: "test".into(),
- name: "Test".into(),
- enabled: true,
- platform: "twitch".into(),
- stream_key: "test".into(),
- custom_egress_url: String::new(),
- twitch_ingest: String::new(),
- youtube_ingest: String::new(),
- vod_audio: false,
- vod_audio_inject_eb: false,
- stream_format: "horizontal".into(),
- audio_track: "auto".into(),
- });
- s.save(&path).expect("save");
-
- let loaded = Settings::load(&path).expect("load");
- assert!(
- !loaded.update_check_enabled,
- "update_check_enabled opt-out must survive round-trip"
- );
- assert!(
- !loaded.open_dashboard_on_launch,
- "open_dashboard_on_launch opt-out must survive round-trip"
- );
-
- // A fresh config with neither key present must read as the on
- // default, not accidentally inherit the false above.
- let fresh = Settings::defaults();
- assert!(fresh.update_check_enabled && fresh.open_dashboard_on_launch);
-
- let _ = std::fs::remove_file(&path);
- }
-
#[test]
fn behavior_defaults_omitted_from_save_to_keep_config_lean() {
// The save writer skips default values for the behaviour keys
@@ -2833,16 +3106,119 @@ mod tests {
let _ = std::fs::remove_file(&path);
}
+ /// The actual atomicity guarantee: when the write cannot complete, the
+ /// config on disk is the old one, byte for byte. A directory squatting
+ /// on the `.tmp` name makes the temp write fail the way a full disk or
+ /// an antivirus lock would. A save that wrote in place (truncate, then
+ /// write) would already have destroyed the original at that point.
+ #[test]
+ fn a_failed_save_leaves_the_existing_file_untouched() {
+ let path = scratch_config_path("atomic");
+ let mut s = Settings::defaults();
+ s.web_port = 9001;
+ s.save(&path).expect("first save");
+ let before = std::fs::read(&path).unwrap();
+
+ let mut tmp_name = path.file_name().unwrap().to_os_string();
+ tmp_name.push(".tmp");
+ std::fs::create_dir(path.with_file_name(tmp_name)).unwrap();
+ s.web_port = 9002;
+ let result = s.save(&path);
+
+ let after = std::fs::read(&path).unwrap();
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ assert!(result.is_err(), "the blocked temp write must surface");
+ assert_eq!(after, before, "the original config must be untouched");
+ }
+
#[test]
fn missing_config_file_falls_back_to_defaults() {
- // load_or_default must not panic on a non-existent path - that's
- // the cold-start case where there's nothing to read yet.
- let path = std::env::temp_dir().join(format!("ic-no-such-file-{}.ini", std::process::id()));
- let _ = std::fs::remove_file(&path);
- let s = Settings::load_or_default(&path);
+ // The cold-start case: nothing to read yet. Defaults, written out
+ // at once so the user can find the file, and normal saving after.
+ let path = scratch_config_path("missing");
+ let startup = Settings::load_for_startup(&path).expect("fresh install");
+ let written = path.exists();
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ let s = startup.settings;
assert_eq!(s.ingest_port, 1935);
assert_eq!(s.web_port, 7799);
assert!(!s.configured);
+ assert!(startup.may_save && startup.notice.is_none());
+ assert!(written, "a fresh install writes its config file");
+ }
+
+ /// Stand-in for a file another process holds open: every read fails
+ /// with something other than NotFound.
+ fn locked_read(_: &Path) -> io::Result {
+ Err(io::Error::from(io::ErrorKind::PermissionDenied))
+ }
+
+ /// The data-loss path: a config that exists but cannot be read must
+ /// never be replaced by defaults at startup. Its bytes are copied
+ /// aside first, the original is left alone, and startup is told not
+ /// to save. Before this, a single failed read meant every destination
+ /// and stream key was overwritten with defaults on launch.
+ #[test]
+ fn an_unreadable_config_is_copied_aside_and_not_overwritten() {
+ let path = scratch_config_path("locked");
+ let original = b"configured=true\ndestination.0.stream_key=live_abc\n".to_vec();
+ std::fs::write(&path, &original).unwrap();
+ let earlier_copy = path.with_file_name("instantclone.cfg.unreadable");
+ std::fs::write(&earlier_copy, b"an older copy").unwrap();
+
+ let startup = Settings::load_for_startup_with(&path, locked_read).expect("copy made");
+
+ let on_disk = std::fs::read(&path).unwrap();
+ let first_copy = std::fs::read(&earlier_copy).unwrap();
+ let new_copy = std::fs::read(path.with_file_name("instantclone.cfg.unreadable.2"));
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ assert_eq!(on_disk, original, "the original must be untouched");
+ assert_eq!(
+ first_copy, b"an older copy",
+ "an earlier copy is not clobbered"
+ );
+ assert_eq!(new_copy.expect("a second copy"), original);
+ assert!(!startup.may_save, "startup must not save defaults over it");
+ assert!(!startup.settings.configured, "runs on defaults meanwhile");
+ assert!(startup.notice.is_some(), "and the user is told why");
+ }
+
+ /// No copy, no start: if the file cannot even be copied, running on
+ /// defaults would let the first settings change destroy the only copy.
+ #[test]
+ fn an_unreadable_config_that_cannot_be_copied_stops_startup() {
+ // A directory at the config path reads and copies as an error on
+ // every OS, without needing a real lock.
+ let path = scratch_config_path("dir");
+ std::fs::create_dir(&path).unwrap();
+ let result = Settings::load_for_startup_with(&path, locked_read);
+ let still_there = path.is_dir();
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ assert!(result.is_err());
+ assert!(still_there);
+ }
+
+ /// A lock that clears during the retry window loads normally, with no
+ /// copy and no notice: a brief antivirus scan is not worth a dialog.
+ #[test]
+ fn a_briefly_locked_config_loads_after_a_retry() {
+ let path = scratch_config_path("brief");
+ std::fs::write(&path, "configured=true\nweb_port=9124\n").unwrap();
+ let reads = std::cell::Cell::new(0);
+ let flaky = |p: &Path| {
+ reads.set(reads.get() + 1);
+ if reads.get() == 1 {
+ locked_read(p)
+ } else {
+ Settings::load(p)
+ }
+ };
+ let startup = Settings::load_for_startup_with(&path, flaky).expect("load");
+ let copied = path.with_file_name("instantclone.cfg.unreadable").exists();
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ assert_eq!(startup.settings.web_port, 9124);
+ assert!(startup.may_save && startup.notice.is_none());
+ assert!(!copied);
}
#[test]
@@ -2878,28 +3254,6 @@ mod tests {
assert_eq!(parse_hotkey("Ctrl+Home").expect("valid").1, 0x24);
}
- #[test]
- fn parse_hotkey_rejects_bare_keys_and_multi_key_chords() {
- // A bare key (no modifier) is the misclick risk we refuse, and a
- // two-letter chord is not expressible via RegisterHotKey.
- assert!(parse_hotkey("D").is_none(), "bare key must be rejected");
- assert!(
- parse_hotkey("F8").is_none(),
- "bare function key must be rejected"
- );
- assert!(
- parse_hotkey("Ctrl+D+L").is_none(),
- "multi-key chord must be rejected"
- );
- assert!(
- parse_hotkey("Ctrl+Alt").is_none(),
- "modifiers with no main key"
- );
- assert!(parse_hotkey("Ctrl+Q1").is_none(), "unknown key token");
- assert!(parse_hotkey("Ctrl+F25").is_none(), "F-key out of range");
- assert!(parse_hotkey("").is_none());
- }
-
/// `ACTIONS` is what the tray derives its RegisterHotKey ids from and
/// what both `unbind_elsewhere` walks, so it has to stay in step with
/// the two structs. A reorder here without one there would file a
@@ -3048,7 +3402,7 @@ mod tests {
}
// And nothing it injected survives a reload as a real setting.
- let back = Settings::load_or_default(&path);
+ let back = Settings::load(&path).expect("load");
assert!(back.dashboard_password_hash.is_empty());
assert!(!back.web_bind_all);
assert_eq!(back.web_port, s.web_port);
@@ -3084,7 +3438,7 @@ mod tests {
)
.expect("write");
- let s = Settings::load_or_default(&path);
+ let s = Settings::load(&path).expect("load");
assert_eq!(s.web_port, 8080, "a good line after bad ones still loads");
assert_eq!(s.hotkeys.toggle, "Ctrl+Alt+D", "and so does a good binding");
assert!(
@@ -3189,30 +3543,42 @@ mod tests {
/// Combos are typed by users and hand-edited in the config file.
#[test]
fn hotkey_parsing_rejects_the_awkward_shapes() {
- // A modifier is mandatory: that requirement is the misclick guard.
- assert!(canonicalize_hotkey("D").is_none(), "a bare key is refused");
- assert!(canonicalize_hotkey("Ctrl").is_none(), "modifiers alone too");
- assert!(canonicalize_hotkey("Ctrl+Alt").is_none());
- assert!(canonicalize_hotkey("").is_none());
- assert!(canonicalize_hotkey("+++").is_none());
- assert!(canonicalize_hotkey("Ctrl+NotAKey").is_none());
- // Two keys is not a combo we can register.
- assert!(canonicalize_hotkey("Ctrl+A+B").is_none());
- // Order and case do not matter, and the output is canonical.
- assert_eq!(
- canonicalize_hotkey("alt+CTRL+d").as_deref(),
- canonicalize_hotkey("Ctrl+Alt+D").as_deref()
- );
- // A repeated modifier is the same combo, not a different one.
- assert_eq!(
- canonicalize_hotkey("Ctrl+Ctrl+Alt+D").as_deref(),
- canonicalize_hotkey("Ctrl+Alt+D").as_deref()
- );
- // Whitespace around the tokens survives a hand edit.
- assert_eq!(
- canonicalize_hotkey(" Ctrl + Alt + D ").as_deref(),
- canonicalize_hotkey("Ctrl+Alt+D").as_deref()
- );
+ let refused = [
+ // A modifier is mandatory: that requirement is the misclick guard.
+ ("D", "a bare key"),
+ ("F8", "a bare function key"),
+ ("Ctrl", "a modifier alone"),
+ ("Ctrl+Alt", "modifiers with no main key"),
+ ("", "nothing"),
+ ("+++", "only separators"),
+ ("Ctrl+NotAKey", "an unknown key name"),
+ ("Ctrl+Q1", "an unknown key token"),
+ ("Ctrl+F25", "an F-key out of range"),
+ // Two main keys is a chord RegisterHotKey cannot express.
+ ("Ctrl+A+B", "two main keys"),
+ ];
+ for (combo, why) in refused {
+ assert!(
+ parse_hotkey(combo).is_none(),
+ "{why} must be refused: {combo:?}"
+ );
+ assert!(canonicalize_hotkey(combo).is_none(), "{why}: {combo:?}");
+ }
+ // Order, case, repeats and stray whitespace all land on the one
+ // canonical spelling. Compared against the literal, so a parser
+ // that refused both sides cannot pass as None == None.
+ for combo in [
+ "Ctrl+Alt+D",
+ "alt+CTRL+d",
+ "Ctrl+Ctrl+Alt+D",
+ " Ctrl + Alt + D ",
+ ] {
+ assert_eq!(
+ canonicalize_hotkey(combo).as_deref(),
+ Some("Ctrl+Alt+D"),
+ "{combo:?}"
+ );
+ }
}
#[test]
@@ -3350,6 +3716,530 @@ Name@x"
// An empty signature must never match an unbound action.
assert_eq!(m.action_for(""), None);
}
+
+ /// Unique scratch path per call, so parallel tests never share a file
+ /// and none of them can reach the user's real settings.
+ fn scratch_config_path(tag: &str) -> PathBuf {
+ use std::sync::atomic::{AtomicU32, Ordering};
+ static NEXT: AtomicU32 = AtomicU32::new(0);
+ let dir = std::env::temp_dir().join(format!(
+ "ic-cfg-{tag}-{}-{}",
+ std::process::id(),
+ NEXT.fetch_add(1, Ordering::Relaxed)
+ ));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir.join("instantclone.cfg")
+ }
+
+ fn test_destination(id: &str, platform: &str, key: &str) -> Destination {
+ Destination {
+ id: id.into(),
+ name: id.into(),
+ enabled: true,
+ platform: platform.into(),
+ stream_key: key.into(),
+ custom_egress_url: String::new(),
+ twitch_ingest: String::new(),
+ youtube_ingest: String::new(),
+ vod_audio: false,
+ vod_audio_inject_eb: false,
+ stream_format: "horizontal".into(),
+ audio_track: "auto".into(),
+ }
+ }
+
+ /// A `Settings` with EVERY field away from its default, built as a
+ /// struct literal with no `..Default` so adding a field to `Settings`,
+ /// `Destination`, `DelayProfile`, `Hotkeys` or `MidiBindings` does not
+ /// compile until it is given a non-default value here. That is the
+ /// point: the round-trip test below then fails until `save` writes the
+ /// new field and `apply_field` reads it back.
+ ///
+ /// The legacy `platform`/`stream_key`/`custom_egress_url` fields mirror
+ /// `destinations[0]`, as `save` writes them.
+ fn every_field_non_default() -> Settings {
+ let first = Destination {
+ id: "d-main".into(),
+ name: "Main custom".into(),
+ enabled: false,
+ platform: "custom".into(),
+ stream_key: "key-one".into(),
+ custom_egress_url: "rtmps://ingest.example.com/app".into(),
+ twitch_ingest: "fra".into(),
+ youtube_ingest: "backup".into(),
+ vod_audio: true,
+ vod_audio_inject_eb: true,
+ stream_format: "vertical".into(),
+ audio_track: "2".into(),
+ };
+ let second = Destination {
+ id: "d-yt".into(),
+ name: "YouTube".into(),
+ enabled: true,
+ platform: "youtube".into(),
+ stream_key: "key-two".into(),
+ custom_egress_url: String::new(),
+ twitch_ingest: String::new(),
+ youtube_ingest: String::new(),
+ vod_audio: false,
+ vod_audio_inject_eb: false,
+ stream_format: "horizontal".into(),
+ audio_track: "both".into(),
+ };
+ Settings {
+ ingest_port: 1940,
+ ingest_bind_all: true,
+ ingest_key: "ingest-secret".into(),
+ platform: first.platform.clone(),
+ stream_key: first.stream_key.clone(),
+ custom_egress_url: first.custom_egress_url.clone(),
+ web_port: 7810,
+ web_bind_all: true,
+ dashboard_password_hash: "pbkdf2$1000$c2FsdA$aGFzaA".into(),
+ dock_token: "0123abcd".into(),
+ buffer_mb: 777,
+ buffer_path: PathBuf::from("D:/buffers/ic.buf"),
+ target_delay_ms: 45_000,
+ armed_delay_ms: 60_000,
+ configured: true,
+ profiles: vec![
+ DelayProfile {
+ name: "Scrim".into(),
+ delay_ms: 20_000,
+ },
+ DelayProfile {
+ name: "Finals".into(),
+ delay_ms: 180_000,
+ },
+ ],
+ destinations: vec![first, second],
+ // Migrated away on load (see `migrate_discord_webhook`), so a
+ // round-trip only holds for the empty value.
+ discord_webhook_url: String::new(),
+ overlays_dir: PathBuf::from("D:/overlays"),
+ tracing_enabled: true,
+ auto_arm_on_connect: true,
+ auto_activate_when_ready: true,
+ auto_arm_delay_ms: 25_000,
+ update_check_enabled: false,
+ open_dashboard_on_launch: false,
+ overlays_seeded: true,
+ // A layout value holding its own `=`: the line parser must split
+ // on the first `=` only.
+ docks: BTreeMap::from([
+ ("main".to_string(), r#"{"w":["delay","cut"]}"#.to_string()),
+ (
+ "gaming".to_string(),
+ r#"{"preset":"delay","eq":"a=b"}"#.to_string(),
+ ),
+ ]),
+ hotkeys: Hotkeys {
+ toggle: "Ctrl+Alt+D".into(),
+ arm: "Ctrl+Alt+A".into(),
+ activate: "Ctrl+Shift+F9".into(),
+ cut: "Alt+Win+C".into(),
+ cut_after: "Ctrl+PageUp".into(),
+ end_hold: "Ctrl+Alt+E".into(),
+ },
+ midi: MidiBindings {
+ toggle: "note:1:36".into(),
+ arm: "note:2:37".into(),
+ activate: "cc:1:20".into(),
+ cut: "cc:16:21".into(),
+ cut_after: "note:1:38".into(),
+ end_hold: "note:3:40".into(),
+ },
+ midi_device: "Launchpad Mini".into(),
+ crash_protection: crate::crash_protection::CrashProtection {
+ enabled: true,
+ hold_secs: 120,
+ theme: crate::crash_protection::SlateTheme::Orbit,
+ accent: crate::slate::Rgb {
+ r: 250,
+ g: 60,
+ b: 90,
+ },
+ background: Some(crate::slate::Rgb {
+ r: 10,
+ g: 20,
+ b: 30,
+ }),
+ headline: "Back in a sec".into(),
+ subline: "OBS is restarting".into(),
+ every_disconnect: true,
+ },
+ integrations: vec![
+ crate::integrations::presets::build("crash_alert", "alerts1".into(), "mods")
+ .unwrap(),
+ crate::integrations::presets::build("mod_controls", "mods2".into(), "").unwrap(),
+ ],
+ discord_channels: vec![DiscordChannel {
+ id: "mods".into(),
+ name: "Mods = team".into(),
+ url: "https://discord.com/api/webhooks/1/abc".into(),
+ }],
+ phone: PhoneConnection {
+ server: "https://ntfy.example".into(),
+ topic: "texaz-alerts".into(),
+ },
+ integrations_migrated: true,
+ twitch_client_id: "abcdefghij0123456789klmnopqrst".into(),
+ twitch_shared_chat: true,
+ }
+ }
+
+ /// A pasted Client ID is checked before it is saved, so a stray space
+ /// or the client secret by mistake never reaches Twitch.
+ #[test]
+ fn a_twitch_client_id_must_look_like_one() {
+ let twitch_errors = |id: &str| {
+ let mut s = Settings::defaults();
+ s.twitch_client_id = id.into();
+ s.validate()
+ .iter()
+ .filter(|e| e.contains("Twitch Client ID"))
+ .count()
+ };
+ assert_eq!(twitch_errors(""), 0);
+ assert_eq!(twitch_errors("abcdefghij0123456789klmnopqrst"), 0);
+ for bad in [
+ "short",
+ "has space in it 0123456789abc",
+ "abc\"defghij0123456789klmnopq",
+ ] {
+ assert_eq!(twitch_errors(bad), 1, "{bad}");
+ }
+ }
+
+ /// The old single webhook becomes a Discord connection plus a "Stream
+ /// alerts" integration, once, and a config saved afterwards keeps them.
+ #[test]
+ fn old_discord_webhook_is_migrated_once() {
+ let path = scratch_config_path("webhook-migration");
+ std::fs::write(
+ &path,
+ "discord_webhook_url=https://discord.com/api/webhooks/9/tok
+",
+ )
+ .unwrap();
+ let loaded = Settings::load(&path).unwrap();
+ assert!(loaded.discord_webhook_url.is_empty());
+ assert!(loaded.integrations_migrated);
+ assert_eq!(loaded.discord_channels.len(), 1);
+ assert_eq!(
+ loaded.discord_channels[0].url,
+ "https://discord.com/api/webhooks/9/tok"
+ );
+ assert_eq!(loaded.integrations.len(), 1);
+ assert_eq!(loaded.integrations[0].preset, "stream_alerts");
+ assert!(loaded.integrations[0].enabled);
+
+ loaded.save(&path).unwrap();
+ let again = Settings::load(&path).unwrap();
+ assert_eq!(again.integrations.len(), 1, "migrated exactly once");
+ assert_eq!(again.discord_channels, loaded.discord_channels);
+ let _ = std::fs::remove_file(&path);
+ }
+
+ /// A damaged integration line is skipped; everything else still loads.
+ #[test]
+ fn a_broken_integration_line_does_not_cost_the_rest() {
+ let path = scratch_config_path("broken-integration");
+ let good = crate::integrations::presets::build("delay_command", "good".into(), "")
+ .unwrap()
+ .to_json()
+ .to_json();
+ std::fs::write(
+ &path,
+ format!(
+ "web_port=7811
+integration.bad={{not json
+integration.good={good}
+integrations_migrated=true
+"
+ ),
+ )
+ .unwrap();
+ let loaded = Settings::load(&path).unwrap();
+ assert_eq!(loaded.web_port, 7811);
+ assert_eq!(loaded.integrations.len(), 1);
+ assert_eq!(loaded.integrations[0].id, "good");
+ let _ = std::fs::remove_file(&path);
+ }
+
+ /// Every setting survives save then load unchanged. The one test that
+ /// notices a field `save` forgets to write or `apply_field` forgets to
+ /// read, which otherwise shows up as a setting that resets on restart.
+ #[test]
+ fn every_setting_round_trips_through_save_and_load() {
+ let path = scratch_config_path("full-roundtrip");
+ let original = every_field_non_default();
+ original.save(&path).unwrap();
+ let loaded = Settings::load(&path);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ assert_eq!(loaded.unwrap(), original);
+ }
+
+ /// Guards the guard: if `every_field_non_default` drifted back to a
+ /// default somewhere, the round trip would stop covering that field.
+ #[test]
+ fn the_round_trip_fixture_really_is_non_default() {
+ let s = every_field_non_default();
+ let d = Settings::defaults();
+ assert_ne!(s.ingest_port, d.ingest_port);
+ assert_ne!(s.web_port, d.web_port);
+ assert_ne!(s.buffer_mb, d.buffer_mb);
+ assert_ne!(s.buffer_path, d.buffer_path);
+ assert_ne!(s.overlays_dir, d.overlays_dir);
+ assert_ne!(s.auto_arm_delay_ms, d.auto_arm_delay_ms);
+ assert_ne!(s.profiles, d.profiles);
+ assert_ne!(s.hotkeys, d.hotkeys);
+ assert_ne!(s.midi, d.midi);
+ for (action, combo) in s.hotkeys.entries() {
+ assert_eq!(
+ canonicalize_hotkey(combo).as_deref(),
+ Some(combo),
+ "{action}"
+ );
+ }
+ for (action, sig) in s.midi.entries() {
+ assert_eq!(canonicalize_midi(sig).as_deref(), Some(sig), "{action}");
+ }
+ }
+
+ /// A config exactly as 0.1.14 wrote it (same `save` order and keys).
+ /// Upgrading must keep every value, and anything added since must load
+ /// at its default.
+ #[test]
+ fn a_0_1_14_config_loads_intact() {
+ let path = scratch_config_path("v0114");
+ std::fs::write(
+ &path,
+ "# InstantClone - written by the app. Hand edits OK.\n\
+ configured=true\n\
+ platform=twitch\n\
+ stream_key=live_123\n\
+ custom_egress_url=\n\
+ ingest_port=1935\n\
+ ingest_bind_all=false\n\
+ web_port=7799\n\
+ web_bind_all=false\n\
+ buffer_mb=500\n\
+ buffer_path=./instantclone.buf\n\
+ target_delay_ms=0\n\
+ armed_delay_ms=30000\n\
+ discord_webhook_url=\n\
+ overlays_dir=./overlays\n\
+ tracing_enabled=false\n\
+ overlays_seeded=true\n\
+ hotkey.toggle=Ctrl+Alt+D\n\
+ profile.0.name=Quick\n\
+ profile.0.delay_ms=15000\n\
+ destination.0.id=main\n\
+ destination.0.name=Main\n\
+ destination.0.enabled=true\n\
+ destination.0.platform=twitch\n\
+ destination.0.stream_key=live_123\n\
+ destination.0.custom_egress_url=\n\
+ destination.0.twitch_ingest=\n\
+ destination.0.youtube_ingest=\n\
+ destination.0.vod_audio=true\n",
+ )
+ .unwrap();
+ let loaded = Settings::load(&path);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+
+ let mut expected = Settings::defaults();
+ expected.configured = true;
+ expected.stream_key = "live_123".into();
+ expected.armed_delay_ms = 30_000;
+ expected.overlays_seeded = true;
+ expected.hotkeys.toggle = "Ctrl+Alt+D".into();
+ expected.profiles.truncate(1);
+ let mut main = test_destination("main", "twitch", "live_123");
+ main.name = "Main".into();
+ main.vod_audio = true;
+ expected.destinations = vec![main];
+ // Loading always completes the one-time webhook migration.
+ expected.integrations_migrated = true;
+ assert_eq!(loaded.unwrap(), expected);
+ }
+
+ /// Rolling back to this build after a newer one wrote the file: keys we
+ /// do not know are skipped without disturbing the ones we do.
+ #[test]
+ fn unknown_keys_from_a_newer_build_do_not_disturb_known_ones() {
+ let path = scratch_config_path("newer");
+ let original = every_field_non_default();
+ original.save(&path).unwrap();
+ let mut text = std::fs::read_to_string(&path).unwrap();
+ text.insert_str(0, "future=1\ncrash_protection.future_mode=on\n");
+ text.push_str("destination.0.newfield=x\ndestination.1.another=y\nfuture_tail=2\n");
+ std::fs::write(&path, &text).unwrap();
+
+ let loaded = Settings::load(&path).unwrap();
+ loaded.save(&path).unwrap();
+ let resaved = std::fs::read_to_string(&path).unwrap();
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ assert_eq!(loaded, original);
+ // Pinned behavior: unknown lines are dropped on the next save, so a
+ // rollback followed by any settings change loses the newer build's
+ // extra settings. Change this deliberately if that ever changes.
+ for unknown in [
+ "future=",
+ "future_mode",
+ "newfield",
+ "another=",
+ "future_tail",
+ ] {
+ assert!(!resaved.contains(unknown), "{unknown} survived:\n{resaved}");
+ }
+ }
+
+ /// A config hand-edited in Notepad and saved as ANSI turns an accented
+ /// character into a byte that is not UTF-8. That used to fail the whole
+ /// read, fall back to defaults, and have startup save the defaults over
+ /// every stream key. The damaged character may be lost; nothing else is.
+ #[test]
+ fn a_config_with_non_utf8_bytes_still_loads_its_other_fields() {
+ let path = scratch_config_path("ansi");
+ let mut bytes = b"configured=true\nweb_port=9123\ndestination.0.id=d1\n\
+ destination.0.enabled=true\ndestination.0.platform=twitch\n\
+ destination.0.stream_key=live_abc\ndestination.0.name=Espa"
+ .to_vec();
+ bytes.push(0xF1); // "ñ" in Windows-1252
+ bytes.extend_from_slice(b"a\n");
+ std::fs::write(&path, &bytes).unwrap();
+
+ let s = Settings::load(&path);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ let s = s.expect("a non-UTF-8 byte must not fail the whole load");
+ assert!(s.configured);
+ assert_eq!(s.web_port, 9123);
+ assert_eq!(s.destinations.len(), 1);
+ assert_eq!(s.destinations[0].stream_key, "live_abc");
+ assert!(s.destinations[0].name.starts_with("Espa"));
+ }
+
+ /// Deleting the last destination must stick. The legacy `stream_key=`
+ /// line used to keep the old key after the delete, and the next load's
+ /// single-destination migration rebuilt an ENABLED "Main" from it: a
+ /// stream the user removed would go live again after a restart.
+ #[test]
+ fn deleting_the_last_destination_survives_a_restart() {
+ let path = scratch_config_path("last-dest");
+ let mut s = Settings::defaults();
+ s.destinations
+ .push(test_destination("d1", "youtube", "secret-key"));
+ s.save(&path).unwrap();
+
+ let mut loaded = Settings::load(&path).unwrap();
+ assert_eq!(loaded.destinations.len(), 1);
+ loaded.destinations.clear();
+ loaded.save(&path).unwrap();
+
+ let reloaded = Settings::load(&path).unwrap();
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ assert!(
+ reloaded.destinations.is_empty(),
+ "deleted destination came back: {:?}",
+ reloaded.destinations
+ );
+ }
+
+ /// A config from before destinations existed (only the legacy
+ /// single-destination lines) still becomes one enabled destination.
+ #[test]
+ fn a_pre_destinations_config_still_migrates() {
+ let path = scratch_config_path("legacy");
+ std::fs::write(&path, "configured=true\nplatform=youtube\nstream_key=abc\n").unwrap();
+ let s = Settings::load(&path).unwrap();
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ assert_eq!(s.destinations.len(), 1);
+ let d = &s.destinations[0];
+ assert_eq!(
+ (d.platform.as_str(), d.stream_key.as_str()),
+ ("youtube", "abc")
+ );
+ assert!(d.enabled);
+ assert_eq!(
+ d.egress_url().as_deref(),
+ Some("rtmp://a.rtmp.youtube.com/live2/abc")
+ );
+ }
+
+ /// The custom-URL-only variant: a key embedded in the URL and no
+ /// `stream_key=` line must migrate too, and keep the URL intact.
+ #[test]
+ fn a_pre_destinations_custom_url_config_still_migrates() {
+ let path = scratch_config_path("legacy-custom");
+ let url = "rtmp://ingest.example.com/app/embedded-key";
+ std::fs::write(&path, format!("platform=custom\ncustom_egress_url={url}\n")).unwrap();
+ let s = Settings::load(&path).unwrap();
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ assert_eq!(s.destinations.len(), 1);
+ assert_eq!(s.destinations[0].platform, "custom");
+ assert_eq!(s.destinations[0].custom_egress_url, url);
+ assert_eq!(s.destinations[0].egress_url().as_deref(), Some(url));
+ }
+
+ /// Other tests trust `is_valid_json` to catch broken hand-written JSON,
+ /// so it has to reject exactly the mistakes those writers can make.
+ #[test]
+ fn json_validator_accepts_json_and_rejects_the_usual_breakage() {
+ let valid = [
+ r#"{}"#,
+ r#"[]"#,
+ r#" {"a":[1,-2.5e3,true,false,null,"x\né"],"b":{}} "#,
+ r#""plain string""#,
+ ];
+ for doc in valid {
+ assert!(is_valid_json(doc), "should accept: {doc}");
+ }
+ let invalid = [
+ "",
+ r#"{"a":1,}"#,
+ r#"[1,,2]"#,
+ r#"[,1]"#,
+ "{\"a\":\"raw\nnewline\"}",
+ r#"{"a":1}{"b":2}"#,
+ r#"{"a" 1}"#,
+ r#"{a:1}"#,
+ r#"[01]"#,
+ r#"["\x"]"#,
+ r#"[1"#,
+ ];
+ for doc in invalid {
+ assert!(!is_valid_json(doc), "should reject: {doc}");
+ }
+ }
+
+ /// The dashboard parses this on every poll; a stray comma or unescaped
+ /// character in any field blanks the whole page.
+ #[test]
+ fn to_json_is_valid_json_with_awkward_values() {
+ let mut s = Settings::defaults();
+ s.midi_device = "Pad \"8\"\\\ttab".into();
+ s.destinations.push(Destination {
+ id: "d1".into(),
+ name: "Main \"quoted\"".into(),
+ enabled: true,
+ platform: "custom".into(),
+ stream_key: "k\\ey".into(),
+ custom_egress_url: "rtmp://host/app".into(),
+ twitch_ingest: String::new(),
+ youtube_ingest: String::new(),
+ vod_audio: false,
+ vod_audio_inject_eb: false,
+ stream_format: String::new(),
+ audio_track: String::new(),
+ });
+ for secrets in [false, true] {
+ let json = s.to_json(false, secrets);
+ assert!(is_valid_json(&json), "invalid JSON: {json}");
+ }
+ }
}
// Tiny libc shim for Unix free-space - avoids adding the `libc` crate.
diff --git a/src/controller.rs b/src/controller.rs
index e25e296..ac93c83 100644
--- a/src/controller.rs
+++ b/src/controller.rs
@@ -11,6 +11,8 @@
use crate::buffer::{DiskRing, TagMeta};
use crate::compat::StreamParams;
use crate::h264::{AudioCodec, VideoCodec};
+use crate::integrations::host::fmt_duration;
+use crate::integrations::{Event, EventKind};
use crate::rtmp::client::{EgressClient, EgressSink, EgressUrl};
use std::collections::HashMap;
use std::io;
@@ -32,6 +34,62 @@ fn process_now_ms() -> u64 {
Instant::now().saturating_duration_since(*start).as_millis() as u64
}
+/// Rolling bitrate, lock-free: bytes are summed over windows of at least
+/// `RATE_WINDOW_MS` and the rate is the last full window. A window that
+/// hasn't rolled for `RATE_STALE_MS` means nothing has arrived since (the
+/// stream stopped or froze), so the rate reads 0 rather than holding the
+/// last second's value forever.
+#[derive(Default)]
+pub struct RateMeter {
+ window_bytes: AtomicU64,
+ /// 0 = no window yet.
+ window_start_ms: AtomicU64,
+ kbps: AtomicU32,
+}
+
+const RATE_WINDOW_MS: u64 = 1_000;
+const RATE_STALE_MS: u64 = 2_500;
+
+impl RateMeter {
+ pub fn note(&self, bytes: usize) {
+ self.note_at(bytes, process_now_ms());
+ }
+
+ fn note_at(&self, bytes: usize, now_ms: u64) {
+ let now_ms = now_ms.max(1);
+ let start = self.window_start_ms.load(Ordering::Relaxed);
+ // First bytes, or the first after a gap: they mark the start of a
+ // fresh window (so they count toward the time before it), instead
+ // of the silence being averaged into the rate.
+ if start == 0 || now_ms.saturating_sub(start) > RATE_STALE_MS {
+ self.window_bytes.store(0, Ordering::Relaxed);
+ self.window_start_ms.store(now_ms, Ordering::Relaxed);
+ return;
+ }
+ let total = self.window_bytes.fetch_add(bytes as u64, Ordering::Relaxed) + bytes as u64;
+ let elapsed = now_ms.saturating_sub(start);
+ if elapsed >= RATE_WINDOW_MS {
+ // bytes * 8 / ms = kbps
+ self.kbps
+ .store(((total * 8) / elapsed) as u32, Ordering::Relaxed);
+ self.window_bytes.store(0, Ordering::Relaxed);
+ self.window_start_ms.store(now_ms, Ordering::Relaxed);
+ }
+ }
+
+ pub fn kbps(&self) -> u32 {
+ self.kbps_at(process_now_ms())
+ }
+
+ fn kbps_at(&self, now_ms: u64) -> u32 {
+ let start = self.window_start_ms.load(Ordering::Relaxed);
+ if start == 0 || now_ms.saturating_sub(start) > RATE_STALE_MS {
+ return 0;
+ }
+ self.kbps.load(Ordering::Relaxed)
+ }
+}
+
/// Encode VideoCodec as a u8 for atomic storage.
fn enc_vcodec(c: VideoCodec) -> u8 {
match c {
@@ -110,6 +168,9 @@ impl ActivateError {
/// because a 24/7 relay logs for months: the oldest line is dropped rather
/// than letting the buffer track uptime.
const LOG_LINES_MAX: usize = 1_500;
+/// How long OBS gets to send its first video frame after coming back from
+/// a crash-protection hold before a missing picture counts as a freeze.
+const FIRST_VIDEO_GRACE: Duration = Duration::from_secs(10);
pub const NO_INGEST: &str = "OBS isn't sending anything yet - start streaming first";
@@ -152,7 +213,7 @@ pub struct DestinationState {
pub bytes_sent: AtomicU64,
pub cuts_performed: AtomicU32,
pub reconnects: AtomicU32,
- pub bitrate_kbps_out: AtomicU32,
+ pub rate_out: RateMeter,
/// Set by the supervisor when this destination is being removed or
/// the app is shutting down. The egress pump checks it once per loop
/// and tears down the upstream session politely (deleteStream) before
@@ -166,8 +227,6 @@ pub struct DestinationState {
/// bytes don't match and the upstream decoder silently rejects every
/// subsequent frame.
last_seq_header_gen: AtomicU32,
- rate_window_bytes: AtomicU64,
- rate_window_start_ms: AtomicU64,
/// True if this destination accepts Enhanced Broadcasting multi-track
/// video on the wire. Set by the supervisor to `true` when the
/// destination's platform is `twitch` and to `false` for everything
@@ -204,9 +263,9 @@ pub struct DestinationState {
/// The OneTrack TrackId of the vertical-canvas primary, discovered by
/// `h264::detect_vertical_primary_track` from the per-track seq-header
/// cache and refreshed whenever that cache changes. `0xFF` means
- /// "not resolved yet" (Twitch Dual Format isn't active, or no portrait
- /// track has been seen): a vertical destination then sends no video
- /// and surfaces a "waiting for Dual Format" status, while every other
+ /// "not resolved yet" (OBS sends no 9:16 canvas, from its Additional
+ /// canvas or Twitch Dual Format): a vertical destination then sends no
+ /// video and the dashboard says what's missing, while every other
/// destination is unaffected.
pub vertical_primary_track: AtomicU8,
/// Twitch only: when our /obs/multitrack-config proxy successfully
@@ -251,6 +310,19 @@ pub struct DestinationState {
pub session_epoch: AtomicU64,
}
+/// The Enhanced Broadcasting config last handed to OBS, and the Twitch
+/// session it opened on one destination.
+#[derive(Debug, Clone)]
+pub struct EbSession {
+ /// The rewritten GetClientConfiguration response OBS received.
+ pub config: String,
+ /// The session tokens OBS may publish with.
+ pub auths: Vec,
+ pub dest_id: String,
+ /// The IVS ingest URL the destination streams to for this session.
+ pub ivs_url: String,
+}
+
/// Outcome of finishing a VOD-session fetch, returned by
/// `complete_vod_fetch` so the supervisor can log it without re-deriving
/// what happened.
@@ -281,12 +353,10 @@ impl DestinationState {
bytes_sent: AtomicU64::new(0),
cuts_performed: AtomicU32::new(0),
reconnects: AtomicU32::new(0),
- bitrate_kbps_out: AtomicU32::new(0),
+ rate_out: RateMeter::default(),
shutdown_requested: AtomicBool::new(false),
last_seq_header_gen: AtomicU32::new(0),
eb_override_url: crate::sync::Mutex::new(None),
- rate_window_bytes: AtomicU64::new(0),
- rate_window_start_ms: AtomicU64::new(0),
// Default false: every newly-spawned destination flattens
// multi-track until the supervisor decides otherwise. This
// preserves beta.6 behaviour for any code path that creates
@@ -308,9 +378,9 @@ impl DestinationState {
/// The video egress policy for this destination right now. Read by
/// both the live send path and the seq-header replay so they always
/// agree on which canvas to forward. Returns `None` when a vertical
- /// destination has no resolved canvas yet (Twitch Dual Format isn't
- /// active): the caller drops all video and the dest waits, leaving
- /// every other destination untouched.
+ /// destination has no resolved canvas yet (no 9:16 canvas on the wire):
+ /// the caller drops all video and the dest waits, leaving every other
+ /// destination untouched.
pub fn video_egress(&self) -> Option {
use crate::h264::VideoEgress;
if self.pass_through_multitrack_video.load(Ordering::Relaxed) {
@@ -423,27 +493,20 @@ impl DestinationState {
outcome
}
- fn note_outbound_bytes(&self, n: usize) {
- let now = process_now_ms();
- let total = self
- .rate_window_bytes
- .fetch_add(n as u64, Ordering::Relaxed)
- + n as u64;
- let start = self.rate_window_start_ms.load(Ordering::Relaxed);
- if start == 0 {
- self.rate_window_start_ms.store(now, Ordering::Relaxed);
- return;
- }
- let elapsed = now.saturating_sub(start);
- if elapsed >= 1_000 {
- let kbps = ((total * 8) / elapsed.max(1)) as u32;
- self.bitrate_kbps_out.store(kbps, Ordering::Relaxed);
- self.rate_window_bytes.store(0, Ordering::Relaxed);
- self.rate_window_start_ms.store(now, Ordering::Relaxed);
- }
+ pub(crate) fn note_outbound_bytes(&self, n: usize) {
+ self.rate_out.note(n);
}
}
+/// `Controller::resumed_after_seq` when the ring held nothing.
+const NO_SEQ: u64 = u64::MAX;
+/// `Controller::eb_vertical_track` when the config has no vertical track.
+const NO_TRACK: u8 = 0xFF;
+
+/// `Controller::last_hold_end` values.
+const HOLD_END_RESUMED: u8 = 1;
+const HOLD_END_ENDED: u8 = 2;
+
/// What the main loop should do after a graceful shutdown: exit for good, or
/// relaunch a fresh process in place.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -494,6 +557,68 @@ pub struct Controller {
ingest_alive: AtomicBool,
buffer_building: AtomicBool,
publisher_token: AtomicU64,
+ /// Set when the live publisher sends FCUnpublish or deleteStream, read
+ /// (and cleared) when its connection closes: a disconnect after that
+ /// goodbye is a deliberate stop, not a crash.
+ unpublish_received: AtomicBool,
+ /// When the last video tag arrived (process ms), 0 before the first
+ /// one of a session or while crash protection is off. Used to spot a
+ /// frozen OBS.
+ last_video_tag_ms: AtomicU64,
+ /// OBS froze (a freeze hold opened) and has sent no video since. The
+ /// next video tag clears it and resumes; until then freeze detection
+ /// stays quiet, so a freeze hold that ran out or was ended doesn't
+ /// reopen itself. Kept apart from `hold` to stay lock-free per tag.
+ ingest_frozen: AtomicBool,
+ /// `hold` is Some. Lets the per-tag checks skip the lock.
+ hold_open: AtomicBool,
+ /// The newest ring seq when the last hold opened (`NO_SEQ`: none, or
+ /// OBS is back): the delay tail ends there. A frozen OBS can keep
+ /// sending audio, so the ring's own newest seq would keep moving.
+ hold_tail_end_seq: AtomicU64,
+ /// OBS is gone with no hold covering it (crash protection off, OBS
+ /// stopped on purpose, or the hold ran out): a destination still airing
+ /// the delay tail finishes it, then ends, as if OBS had sent its end
+ /// through the delay too. Not when the streamer ended it.
+ tail_plays_out: AtomicBool,
+ /// "The buffer holds only N s" was logged for the armed delay; cleared
+ /// on the next arm so each new value is checked once.
+ capacity_capped_logged: AtomicBool,
+ /// Process ms of the last "rejected publisher" log line (see
+ /// `log_rejected_publish`).
+ last_rejection_log_ms: AtomicU64,
+ /// The newest ring seq when the last hold ended because OBS came back
+ /// (`NO_SEQ` when the ring was empty): the video before it is from
+ /// before the hold, and a rejoin never starts there.
+ resumed_after_seq: AtomicU64,
+ /// The newest ring seq when a frozen OBS recovered after its hold had
+ /// already ended (`NO_SEQ`: none this session). The destinations ended
+ /// with the hold, so the video before it is over for viewers: a pump
+ /// that starts later never seeds there (see `fresh_from_seq`).
+ replay_floor_seq: AtomicU64,
+ /// How the last hold closed: 0 none yet, 1 resumed, 2 ended.
+ last_hold_end: AtomicU8,
+ /// Crash protection settings, mirrored from Settings by the supervisor.
+ crash_protection: crate::sync::Mutex,
+ /// `crash_protection.enabled`, readable without a lock: the ingest path
+ /// does its crash-protection bookkeeping only while this is set.
+ crash_protection_on: AtomicBool,
+ /// The open crash-protection hold: OBS dropped and destinations are
+ /// being kept live on the reconnect screen. See `crate::crash_hold`.
+ hold: crate::sync::Mutex