Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@ All notable changes will land here. Format loosely follows

## [Unreleased]

### Local test sink destination

**Try the whole pipeline with zero risk.** The in-binary RTMP sink
(`instantclone sink`, until now a CLI-only tool) is available as a
destination: pick **Local test sink** in the Destinations tab and
InstantClone spawns its own tiny receiver on this PC and streams to it.
No platform account, no stream key, nothing leaves your machine - test
arm / activate / cut (and vertical) end to end before touching a real
key.

- The managed child process follows the destination's lifecycle: spawned
when a sink destination is enabled (one child serves any number of
them), killed when the last one is disabled or removed, reaped and
respawned if it dies, and taken down with the app. It lives on fixed
high ports (rtmp :19350, player :19351) so manual `instantclone sink`
runs on the documented defaults (:1936/:1937) never collide with it.
- The destination card gets a **▶ Watch output** link to the sink's
built-in live player - watch exactly what a platform would receive,
delay and cuts included.
- The sink's own log lines (`publish accepted`, the 1 Hz stat windows)
are forwarded into the dashboard's Logs tab, so the feedback loop is
visible without a terminal.
- The destination form hides the stream-key field for it (any key works)
and explains the feature inline; the first-run wizard deliberately
does not offer it - the wizard wires your real platform.

### "Cut after this airs" - scheduled safe cut

**Stop counting your delay down in your head.** With a delay active, a new
Expand Down
8 changes: 4 additions & 4 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Cuando ya lo tenía hecho, las piezas que de verdad quería eran: una activació
<tr><td><b>RSS inactivo</b></td><td align="right"><code>~9 MB</code></td></tr>
<tr><td><b>Hilos</b></td><td align="right"><code>1 tokio + 1 bandeja</code></td></tr>
<tr><td><b>Deps en runtime</b></td><td align="right"><code>tokio, bytes, ureq</code></td></tr>
<tr><td><b>Tests</b></td><td align="right"><code>234 / 234</code></td></tr>
<tr><td><b>Tests</b></td><td align="right"><code>235 / 235</code></td></tr>
</table>

</td>
Expand Down Expand Up @@ -155,7 +155,7 @@ Seguramente desaparezca tu chat de twitch en OBS porque OBS detecta que no "vas
</table>

> [!TIP]
> Haz fan-out de un único feed de OBS a varios destinos a la vez. Añade Twitch, YouTube y un endpoint RTMP personalizado, activa cada uno por separado y mira su bitrate en vivo por destino.
> Haz fan-out de un único feed de OBS a varios destinos a la vez. Añade Twitch, YouTube y un endpoint RTMP personalizado, activa cada uno por separado y mira su bitrate en vivo por destino. También hay un destino **Local test sink**: InstantClone lanza su propio receptor diminuto en tu PC y emite hacia él, para ensayar armar/activar/cortar de punta a punta - sin stream key, sin que nada salga de tu máquina, y con un enlace **Watch output** que muestra exactamente lo que recibiría una plataforma.

> [!TIP]
> **Vertical gratis.** Activa el **Formato Dual** de Twitch (Enhanced Broadcasting) en OBS y pon el **Formato de stream** de cualquier destino que no sea Twitch en **Vertical**: InstantClone reutiliza el lienzo 9:16 que OBS ya genera para Twitch y lo envía a YouTube Shorts, Kick móvil o cualquier RTMP personalizado, sin codificación extra. El vertical solo fluye mientras el Formato Dual está activo; hasta entonces el destino muestra "Esperando Formato Dual" y nada más se ve afectado. (Twitch gestiona ambos lienzos por su cuenta, así que ahí la opción se oculta.)
Expand Down Expand Up @@ -281,15 +281,15 @@ cargo build --release

Sin npm, sin submódulos, sin SDK de plataforma. El HTML del panel se minifica y comprime con gzip en tiempo de compilación desde `build.rs` (usa `flate2`, solo build-dep) y se embebe en el binario; en runtime se sirve con `Content-Encoding: gzip`.

`cargo test --release` cubre la máquina de estados (`arm → preparing → ready → active → cut`), detección de IDR en AVC + Enhanced RTMP, codec AMF0 incluyendo Strict Array (la `fourCcList` de Enhanced-RTMP) + guardia de recursión, round-trip de settings, evicción del ring-buffer con protección de lecturas en vuelo, parsing HTTP, política CSRF, pre-flight de puertos, la negociación de contenido `accepts_gzip`, la caché de cabeceras de secuencia por-pista de Enhanced Broadcasting + selección de tags consciente del TrackId, el audio multi-pista de Enhanced-RTMP (pista de VOD de Twitch), el filtro de IDR de pista primaria para que los cortes con EB no glitcheen las escaleras, el parseo de orientación del SPS para la selección de lienzo vertical (9:16), la resolución de `user.ini` / `global.ini` en OBS 32, el parcheado de `services.json` de OBS, el parser de releases de GitHub + comparador SemVer-ish para el chequeo de actualizaciones, la implementación propia de SHA-256 (vectores NIST), el lector/escritor del flujo de chunks RTMP (cabeceras fmt 0-3, timestamps extendidos, fragmentación entre chunks, control Set-Chunk-Size / Window-Ack en banda, y guardias ante entrada malformada), la máquina de estados del corte programado ("cortar cuando esto salga"), y la descarga + verificación de checksum + intercambio del exe en disco de la auto-actualización. 234 tests, todos en verde.
`cargo test --release` cubre la máquina de estados (`arm → preparing → ready → active → cut`), detección de IDR en AVC + Enhanced RTMP, codec AMF0 incluyendo Strict Array (la `fourCcList` de Enhanced-RTMP) + guardia de recursión, round-trip de settings, evicción del ring-buffer con protección de lecturas en vuelo, parsing HTTP, política CSRF, pre-flight de puertos, la negociación de contenido `accepts_gzip`, la caché de cabeceras de secuencia por-pista de Enhanced Broadcasting + selección de tags consciente del TrackId, el audio multi-pista de Enhanced-RTMP (pista de VOD de Twitch), el filtro de IDR de pista primaria para que los cortes con EB no glitcheen las escaleras, el parseo de orientación del SPS para la selección de lienzo vertical (9:16), la resolución de `user.ini` / `global.ini` en OBS 32, el parcheado de `services.json` de OBS, el parser de releases de GitHub + comparador SemVer-ish para el chequeo de actualizaciones, la implementación propia de SHA-256 (vectores NIST), el lector/escritor del flujo de chunks RTMP (cabeceras fmt 0-3, timestamps extendidos, fragmentación entre chunks, control Set-Chunk-Size / Window-Ack en banda, y guardias ante entrada malformada), la máquina de estados del corte programado ("cortar cuando esto salga"), y la descarga + verificación de checksum + intercambio del exe en disco de la auto-actualización. 235 tests, todos en verde.

<br/>

<div align="center"><img src="docs/divider.svg" alt="" width="100%"/></div>

## Estado

**Listo para uso diario en Windows.** Lo uso en mis propios directos. CI corre fmt + clippy (con `-D warnings`) + 234 tests en cada push, y un tag dispara la build + publicación automática de la release con su `SHA256SUMS.txt` al lado (todavía no hay certificado de firma de código, así que el sistema operativo puede avisar en el primer lanzamiento).
**Listo para uso diario en Windows.** Lo uso en mis propios directos. CI corre fmt + clippy (con `-D warnings`) + 235 tests en cada push, y un tag dispara la build + publicación automática de la release con su `SHA256SUMS.txt` al lado (todavía no hay certificado de firma de código, así que el sistema operativo puede avisar en el primer lanzamiento).

**Lo que está sólido**

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Once it existed, the parts I'd actually wanted ended up in: a real two-phase arm
<tr><td><b>Idle RSS</b></td><td align="right"><code>~9 MB</code></td></tr>
<tr><td><b>Threads</b></td><td align="right"><code>1 tokio + 1 tray</code></td></tr>
<tr><td><b>Runtime deps</b></td><td align="right"><code>tokio, bytes, ureq</code></td></tr>
<tr><td><b>Tests</b></td><td align="right"><code>234 / 234</code></td></tr>
<tr><td><b>Tests</b></td><td align="right"><code>235 / 235</code></td></tr>
</table>

</td>
Expand Down Expand Up @@ -155,7 +155,7 @@ Click **Start Streaming**. The OBS pill in InstantClone turns green. Your real T
</table>

> [!TIP]
> Fan out one OBS feed to several destinations at once. Add Twitch, YouTube, and a custom RTMP endpoint, toggle each on independently, watch their per-destination bitrate live.
> Fan out one OBS feed to several destinations at once. Add Twitch, YouTube, and a custom RTMP endpoint, toggle each on independently, watch their per-destination bitrate live. There is also a **Local test sink** destination: InstantClone spawns its own tiny receiver on your PC and streams to it, so you can rehearse arm/activate/cut end to end - stream key not needed, nothing leaves your machine, and a **Watch output** link shows exactly what a platform would receive.

> [!TIP]
> **Go vertical for free.** Turn on Twitch **Dual Format** (Enhanced Broadcasting) in OBS and set any non-Twitch destination's **Stream format** to **Vertical** - InstantClone reuses the 9:16 canvas OBS is already making for Twitch and sends it to YouTube Shorts, Kick mobile, or any custom RTMP target, with no extra encoding. Vertical only flows while Dual Format is on; until then the destination shows "Waiting for Dual Format" and nothing else is affected. (Twitch handles both canvases itself, so the option is hidden there.)
Expand Down Expand Up @@ -281,15 +281,15 @@ cargo build --release

No npm. No submodules. No platform SDKs. The dashboard HTML is minified + gzipped at build time by `build.rs` (uses `flate2`, build-only) and embedded into the binary; at runtime it's served with `Content-Encoding: gzip`.

`cargo test --release` covers the state machine (`arm → preparing → ready → active → cut`), AVC + Enhanced RTMP IDR detection, AMF0 codec including Strict Array (Enhanced-RTMP `fourCcList`) + recursion guard, settings round-trip, ring-buffer eviction with in-flight-read protection, HTTP parsing, CSRF policy, port pre-flight, `accepts_gzip` content negotiation, Enhanced Broadcasting per-track seq-header cache + TrackId-aware tag selection, Enhanced-RTMP multi-track audio (Twitch's VOD audio track), primary-track IDR gate so EB cuts don't pixel-glitch ladder rungs, SPS orientation parsing for vertical-canvas (9:16) selection, OBS 32 `user.ini` / legacy `global.ini` path resolution, the OBS services.json patcher, the GitHub releases update-check parser + SemVer-ish comparator, the hand-rolled SHA-256 (NIST vectors), the RTMP chunk-stream reader/writer (fmt 0-3 headers, extended timestamps, cross-chunk fragmentation, in-band Set-Chunk-Size / Window-Ack control, and malformed-input guards), the scheduled safe-cut ("cut after this airs") state machine, and the self-update download + checksum-verify + on-disk exe swap. 234 tests, all green.
`cargo test --release` covers the state machine (`arm → preparing → ready → active → cut`), AVC + Enhanced RTMP IDR detection, AMF0 codec including Strict Array (Enhanced-RTMP `fourCcList`) + recursion guard, settings round-trip, ring-buffer eviction with in-flight-read protection, HTTP parsing, CSRF policy, port pre-flight, `accepts_gzip` content negotiation, Enhanced Broadcasting per-track seq-header cache + TrackId-aware tag selection, Enhanced-RTMP multi-track audio (Twitch's VOD audio track), primary-track IDR gate so EB cuts don't pixel-glitch ladder rungs, SPS orientation parsing for vertical-canvas (9:16) selection, OBS 32 `user.ini` / legacy `global.ini` path resolution, the OBS services.json patcher, the GitHub releases update-check parser + SemVer-ish comparator, the hand-rolled SHA-256 (NIST vectors), the RTMP chunk-stream reader/writer (fmt 0-3 headers, extended timestamps, cross-chunk fragmentation, in-band Set-Chunk-Size / Window-Ack control, and malformed-input guards), the scheduled safe-cut ("cut after this airs") state machine, and the self-update download + checksum-verify + on-disk exe swap. 235 tests, all green.

<br/>

<div align="center"><img src="docs/divider.svg" alt="" width="100%"/></div>

## Status

**Daily-driver ready on Windows.** I use it on my own streams. CI runs fmt + clippy (with `-D warnings`) + 234 tests on every push, and a tagged commit auto-builds + publishes a release artifact with a `SHA256SUMS.txt` checksum file 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. CI runs fmt + clippy (with `-D warnings`) + 235 tests on every push, and a tagged commit auto-builds + publishes a release artifact with a `SHA256SUMS.txt` checksum file alongside (no code-signing certificate yet, so the OS may warn on first launch).

**What's solid**

Expand Down
55 changes: 54 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ const MAX_PROFILES: usize = 256;
/// hand-edited config sets `buffer_mb=0`.
const MIN_BUFFER_MB: u64 = 50;

/// Ports for the built-in "Local test sink" destination (platform
/// `"sink"`). The egress supervisor spawns `instantclone sink` as a
/// child process on these when such a destination is enabled. Fixed
/// (not configurable) on purpose: the sink's own CLI defaults (1936 /
/// 1937) are what people use for MANUAL sink runs and the e2e script,
/// so the managed instance stays out of their way on a high pair.
pub const SINK_RTMP_PORT: u16 = 19350;
pub const SINK_WEB_PORT: u16 = 19351;

#[derive(Debug, Clone)]
pub struct Settings {
pub ingest_port: u16,
Expand Down Expand Up @@ -92,7 +101,9 @@ pub struct Destination {
pub id: String, // stable across renames; UI key
pub name: String, // user label ("Twitch", "Backup", etc.)
pub enabled: bool,
pub platform: String, // "twitch" | "youtube" | "kick" | "trovo" | "restream" | "custom"
// "twitch" | "youtube" | "kick" | "trovo" | "restream" | "custom"
// | "sink" (the built-in local test sink - see SINK_RTMP_PORT)
pub platform: String,
pub stream_key: String,
pub custom_egress_url: String, // used when platform == "custom"
/// Only meaningful when platform == "twitch". Empty = auto-route via
Expand Down Expand Up @@ -154,6 +165,14 @@ impl Destination {
/// rules as the top-level single-dest path: if a custom URL already has
/// app+key, the key field is ignored; otherwise the key is appended.
pub fn egress_url(&self) -> Option<String> {
// Local test sink: fully self-contained. The URL is fixed (the
// supervisor spawns the matching `instantclone sink` child on
// SINK_RTMP_PORT) and the stream key is ignored - the sink
// accepts any key, so requiring one would just add a fake
// field to fill in.
if self.platform == "sink" {
return Some(format!("rtmp://127.0.0.1:{}/live/test", SINK_RTMP_PORT));
}
if self.platform == "custom" {
let base = non_empty(&self.custom_egress_url)?
.trim_end_matches('/')
Expand Down Expand Up @@ -811,6 +830,8 @@ impl Settings {
errs.push(format!("{}: custom URL must start with rtmp://", d.name));
continue;
}
} else if d.platform == "sink" {
// Self-contained: fixed local URL, no key needed.
} else if platform_base(&d.platform).is_none() {
errs.push(format!("{}: unknown platform '{}'", d.name, d.platform));
continue;
Expand Down Expand Up @@ -1124,6 +1145,38 @@ mod tests {
assert!(is_path_safe(&PathBuf::from("/home/user/buf")));
}

#[test]
fn sink_destination_is_self_contained() {
// No stream key, no custom URL - the local test sink needs
// neither. The URL is the fixed managed-child endpoint, and
// validate() must not demand a key for it.
let d = Destination {
id: "x".into(),
name: "Test sink".into(),
enabled: true,
platform: "sink".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(),
};
assert_eq!(
d.egress_url().as_deref(),
Some(format!("rtmp://127.0.0.1:{}/live/test", SINK_RTMP_PORT).as_str())
);
assert!(d.is_well_formed());
let mut s = Settings::defaults();
s.destinations.push(d);
assert!(
s.validate().is_empty(),
"sink destination must validate without a key: {:?}",
s.validate()
);
}

#[test]
fn destination_egress_url_for_twitch_appends_key() {
let d = Destination {
Expand Down
Loading