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
4 changes: 2 additions & 2 deletions .github/ISSUE_TEMPLATE/integrations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ body:
id: version
attributes:
label: InstantClone version
description: Top-right of the dashboard, or the `v0.1.16` in the .exe name.
placeholder: "v0.1.16"
description: Top-right of the dashboard, or the `v0.1.17` in the .exe name.
placeholder: "v0.1.17"
validations:
required: true

Expand Down
16 changes: 14 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ name: release

# Trigger: pushing a tag like v0.1.0 or v0.2.3.
# What it does: builds the release binary, computes its SHA-256, and
# creates a GitHub Release with the .exe + a checksum file attached.
# Auto-generated release notes come from the commit log between tags.
# creates a GitHub Release with the .exe + a checksum file attached. The
# release's notes are written by hand (see "publish release").
on:
push:
tags: ["v*"]
Expand Down Expand Up @@ -33,6 +33,18 @@ jobs:
runs-on: windows-2022
steps:
- uses: actions/checkout@v7
# The tag must be the app's own version (Cargo.toml). A build that
# says it is another version than its release would see that release
# as newer than itself, and offer it as an update over and over.
- name: tag matches the app version
shell: bash
run: |
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)
if [ "$GITHUB_REF_NAME" != "v$version" ]; then
echo "::error::Tag $GITHUB_REF_NAME doesn't match the app version $version in Cargo.toml. Bump Cargo.toml, or tag v$version."
exit 1
fi
echo "Tag $GITHUB_REF_NAME matches the app version."
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
Expand Down
339 changes: 339 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "instantclone"
version = "0.1.16"
version = "0.1.17"
edition = "2021"
authors = ["s1moscs"]
license = "GPL-3.0-only"
Expand Down
76 changes: 61 additions & 15 deletions src/compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub struct StreamParams {
/// Mean IDR spacing in ms, 0 until enough keyframes have been seen.
pub keyframe_interval_ms: u32,
pub codec: VideoCodec,
/// Enhanced Broadcasting is on: Twitch picks its own tracks' codec.
pub multitrack: bool,
}

/// Warn above this measured IDR spacing. Every ingest we support asks for
Expand Down Expand Up @@ -127,25 +129,35 @@ fn resolution_issue(params: &StreamParams, live: &[&Destination]) -> Option<Stri
))
}

/// A codec the destination's ingest cannot decode.
///
/// Scoped to Kick on purpose. Twitch negotiates codecs itself under
/// Enhanced Broadcasting, and YouTube has added HEVC ingest paths, so
/// warning about either would risk a false positive. Kick's ingest is
/// H.264 and the failure is total, which makes it worth naming.
/// A codec a destination's ingest cannot decode (see `Destination::plays`):
/// the failure is total, so it is worth naming. Twitch negotiates its own
/// codec under Enhanced Broadcasting, and a custom ingest's codecs are
/// unknown, so neither is warned about.
fn codec_issue(params: &StreamParams, live: &[&Destination]) -> Option<String> {
if !matches!(
params.codec,
VideoCodec::Hevc | VideoCodec::Av1 | VideoCodec::Vp9
) {
if matches!(params.codec, VideoCodec::Avc | VideoCodec::Unknown) {
return None;
}
if !live.iter().any(|d| d.platform == "kick") {
return None;
let mut names: Vec<&'static str> = Vec::new();
for d in live {
let negotiated = d.platform == "twitch" && params.multitrack;
let label = platform_label(&d.platform);
if d.platform != "custom"
&& !negotiated
&& !d.plays(params.codec)
&& !names.contains(&label)
{
names.push(label);
}
}
let verb = match names.len() {
0 => return None,
1 => "needs",
_ => "need",
};
Some(format!(
"Video codec is {}. Kick needs H.264. {CODEC_FIX}",
params.codec.label()
"Video codec is {}. {} {verb} H.264. {CODEC_FIX}",
params.codec.label(),
join_names(&names)
))
}

Expand Down Expand Up @@ -217,6 +229,7 @@ mod tests {
height: 1080,
keyframe_interval_ms: 2_000,
codec: VideoCodec::Avc,
multitrack: false,
};
change(&mut params);
params
Expand All @@ -235,7 +248,7 @@ mod tests {
#[test]
fn stays_silent_when_nothing_is_wrong_or_nobody_minds() {
let kick = [dest("kick", true)];
let cases: [(&str, StreamParams, &[Destination]); 13] = [
let cases: [(&str, StreamParams, &[Destination]); 16] = [
(
"a healthy stream",
healthy(),
Expand Down Expand Up @@ -301,6 +314,21 @@ mod tests {
stream(|p| p.codec = VideoCodec::Unknown),
&kick,
),
(
"AV1 to YouTube only",
stream(|p| p.codec = VideoCodec::Av1),
&[dest("youtube", true)],
),
(
"HEVC that Twitch picked under Enhanced Broadcasting",
stream(|p| (p.codec, p.multitrack) = (VideoCodec::Hevc, true)),
&[dest("twitch", true)],
),
(
"AV1 to a custom server",
stream(|p| p.codec = VideoCodec::Av1),
&[dest("custom", true)],
),
];
for (case, params, dests) in cases {
assert_eq!(compat_warning(&params, dests), None, "{case}");
Expand Down Expand Up @@ -370,6 +398,24 @@ mod tests {
}
}

/// AV1 from OBS reaches only the platforms that play it: the rest are
/// named, Twitch too when it isn't choosing through Enhanced Broadcasting.
#[test]
fn a_codec_some_platforms_cannot_play_names_them() {
let av1 = stream(|p| p.codec = VideoCodec::Av1);
let dests = [
dest("youtube", true),
dest("twitch", true),
dest("kick", true),
];
assert_eq!(
compat_warning(&av1, &dests),
Some(format!(
"Video codec is AV1. Twitch and Kick need H.264. {CODEC_FIX}"
))
);
}

/// Multiple problems collapse into ONE line - the clutter guard.
#[test]
fn multiple_issues_join_into_a_single_line() {
Expand Down
64 changes: 62 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ pub struct Settings {
pub phone: PhoneConnection,
/// Where the alerts page puts its cards and how big they are.
pub alerts_look: crate::integrations::alerts::Look,
/// Goals and timers overlays draw (see `crate::integrations::goals`).
pub goals: Vec<crate::integrations::goals::Goal>,
pub timers: Vec<crate::integrations::goals::Timer>,
/// 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.
Expand Down Expand Up @@ -803,6 +806,14 @@ impl Settings {
let look = self.alerts_look.to_json().to_json();
writeln!(f, "alerts.look={}", one_line(&look))?;
}
for g in &self.goals {
let kept = g.to_json().to_json();
writeln!(f, "goal.{}={}", one_line(&g.id), one_line(&kept))?;
}
for t in &self.timers {
let kept = t.to_json().to_json();
writeln!(f, "timer.{}={}", one_line(&t.id), one_line(&kept))?;
}
for v in &self.user_values {
let kept = json::obj([
("label", json::str(&v.label)),
Expand Down Expand Up @@ -831,6 +842,30 @@ impl Settings {
/// whole config: the other integrations and settings still load.
/// One `integration_value.<name>=<json>` line. A broken or repeated
/// one is skipped, like a broken integration.
/// A `goal.<id>=<json>` line. One that doesn't read, or repeats an
/// id, is left out.
fn load_goal(&mut self, value: &str) {
use crate::integrations::goals::{Goal, MAX_GOALS};
let read = json::parse(value).ok().and_then(|v| Goal::from_json(&v));
let Some(goal) = read else {
return;
};
if self.goals.len() < MAX_GOALS && !self.goals.iter().any(|g| g.id == goal.id) {
self.goals.push(goal);
}
}

fn load_timer(&mut self, value: &str) {
use crate::integrations::goals::{Timer, MAX_TIMERS};
let read = json::parse(value).ok().and_then(|v| Timer::from_json(&v));
let Some(timer) = read else {
return;
};
if self.timers.len() < MAX_TIMERS && !self.timers.iter().any(|t| t.id == timer.id) {
self.timers.push(timer);
}
}

fn load_user_value(&mut self, name: &str, value: &str) {
use crate::integrations::model::{UserValue, MAX_USER_VALUES};
let Ok(mut parsed) = json::parse(value) else {
Expand Down Expand Up @@ -1000,6 +1035,8 @@ impl Settings {
discord_channels: Vec::new(),
phone: PhoneConnection::default(),
alerts_look: Default::default(),
goals: Vec::new(),
timers: Vec::new(),
// A fresh install has no old webhook to migrate; `load` sets
// this after its one-time pass either way.
integrations_migrated: false,
Expand Down Expand Up @@ -1057,9 +1094,12 @@ impl Settings {
}
s.migrate_discord_webhook();
// `!setdelay` copies from up to 0.1.15 (a 10 minute limit, and
// "Delay set to 0s"). The file catches up on the next save.
// "Delay set to 0s"), and Twitch alerts from before 0.1.17 (the
// viewer's words, the dock's events). The file catches up on the
// next save.
for integration in &mut s.integrations {
crate::integrations::presets::upgrade_setdelay(integration);
crate::integrations::presets::upgrade_twitch_alerts(integration);
}
// Clamp / sanitize on load - hand-edited values can otherwise
// hit divide-by-zero (buffer_mb=0 → `% capacity` in DiskRing) or
Expand Down Expand Up @@ -1520,6 +1560,8 @@ impl Settings {
self.alerts_look = crate::integrations::alerts::Look::from_json(&v);
}
}
k if k.starts_with("goal.") => self.load_goal(value),
k if k.starts_with("timer.") => self.load_timer(value),
"connection.phone" => {
if let Ok(v) = json::parse(value) {
self.phone = PhoneConnection {
Expand Down Expand Up @@ -2500,7 +2542,7 @@ pub fn parse_form(body: &str) -> std::collections::HashMap<String, String> {
out
}

fn url_decode(s: &str) -> String {
pub(crate) fn url_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
Expand Down Expand Up @@ -4040,8 +4082,26 @@ Name@x"
radius: Some(0),
sound: "media:ding.mp3".into(),
css: ".card .text { letter-spacing: 1px }".into(),
design: "my-alerts".into(),
..Default::default()
},
goals: vec![crate::integrations::goals::Goal {
id: "subs".into(),
name: "Sub goal = 50".into(),
counts: crate::integrations::goals::Counts::Subs,
target: 50,
each_stream: true,
}],
timers: vec![crate::integrations::goals::Timer {
id: "subathon".into(),
name: "Subathon".into(),
start_s: 3600,
per_sub_s: 300,
per_100_bits_s: 60,
per_follow_s: 0,
tiers: false,
max_s: 86400,
}],
integrations_migrated: true,
twitch_client_id: "abcdefghij0123456789klmnopqrst".into(),
twitch_shared_chat: true,
Expand Down
1 change: 1 addition & 0 deletions src/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1192,6 +1192,7 @@ impl Controller {
height: dims as u32,
keyframe_interval_ms: self.keyframe_interval_ms(),
codec: self.video_codec(),
multitrack: self.multitrack_video(),
}
}

Expand Down
Loading
Loading