新主题

This commit is contained in:
2026-08-06 00:41:10 -07:00
parent 7eb9d00a50
commit ac80cd1db4
35 changed files with 1693 additions and 24 deletions
+5
View File
@@ -14,6 +14,7 @@ use sha2::{Digest, Sha256};
use crate::{
credentials::normalize_cookiecloud_host,
overlay::{OverlaySettings, OverlayThemeId},
typography::FontFamilyId,
};
/// Process-level configuration. Tenant-owned room, CookieCloud and component
@@ -127,6 +128,8 @@ struct EmoticonsConfig {
#[derive(Deserialize, Default)]
struct OverlayFileConfig {
theme_id: Option<OverlayThemeId>,
font_family: Option<FontFamilyId>,
font_brightness: Option<u16>,
font_scale: Option<u16>,
max_visible: Option<u8>,
collapse_after_seconds: Option<u16>,
@@ -323,6 +326,8 @@ fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
let default = OverlaySettings::default();
OverlaySettings {
theme_id: file.theme_id.unwrap_or(default.theme_id),
font_family: file.font_family.unwrap_or(default.font_family),
font_brightness: file.font_brightness.unwrap_or(default.font_brightness),
font_scale: file.font_scale.unwrap_or(default.font_scale),
show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku),
show_enter: file.events.enter.unwrap_or(default.show_enter),
+5
View File
@@ -51,6 +51,10 @@ impl LiveEventKind {
pub struct PlatformViewer {
pub uid: String,
pub name: String,
/// Optional public avatar supplied by the live platform. Older event
/// formats omit it, so renderers must keep a local fallback.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
@@ -331,6 +335,7 @@ mod tests {
PlatformViewer {
uid: "42".into(),
name: "观众".into(),
avatar_url: None,
}
}
+20
View File
@@ -11,6 +11,7 @@ use serde_json::Value;
use crate::{
components::{ComponentDefinition, ComponentError, EventSubscription},
domain::LiveEventKind,
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
};
pub const GIFT_EFFECT_KIND: &str = "gift_effect";
@@ -21,6 +22,7 @@ pub const GIFT_EFFECT_NAME: &str = "礼物星雨";
pub enum GiftEffectThemeId {
#[default]
JadeStarfall,
MoonlitWater,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
@@ -45,6 +47,10 @@ impl MeteorTierSettings {
pub struct GiftEffectSettings {
#[serde(default)]
pub theme_id: GiftEffectThemeId,
#[serde(default)]
pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")]
pub font_brightness: u16,
pub high_value_threshold: i64,
pub featured_value_threshold: i64,
pub normal: MeteorTierSettings,
@@ -61,6 +67,8 @@ impl Default for GiftEffectSettings {
fn default() -> Self {
Self {
theme_id: GiftEffectThemeId::default(),
font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(),
high_value_threshold: 10_000,
featured_value_threshold: 100_000,
normal: MeteorTierSettings {
@@ -90,6 +98,7 @@ impl Default for GiftEffectSettings {
impl GiftEffectSettings {
pub fn sanitize(mut self) -> Self {
self.high_value_threshold = self.high_value_threshold.max(0);
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.featured_value_threshold =
self.featured_value_threshold.max(self.high_value_threshold);
self.normal = self.normal.sanitize();
@@ -155,11 +164,13 @@ mod tests {
let definition = GiftEffectDefinition;
let mut settings = definition.default_settings();
settings["normal"]["count"] = Value::from(0);
settings["fontBrightness"] = Value::from(999);
settings["featured"]["size"] = Value::from(9_999);
settings["highValueThreshold"] = Value::from(50_000);
settings["featuredValueThreshold"] = Value::from(10_000);
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["normal"]["count"], 1);
assert_eq!(sanitized["fontBrightness"], 180);
assert_eq!(sanitized["featured"]["size"], 480);
assert_eq!(sanitized["featuredValueThreshold"], 50_000);
}
@@ -175,4 +186,13 @@ mod tests {
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
assert!(!subscriptions.contains(LiveEventKind::Danmaku));
}
#[test]
fn moonlit_water_theme_is_accepted_and_serialized() {
let definition = GiftEffectDefinition;
let mut settings = definition.default_settings();
settings["themeId"] = Value::from("moonlit-water");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["themeId"], "moonlit-water");
}
}
+21
View File
@@ -17,6 +17,7 @@ use crate::{
},
domain::{ComponentMessage, LiveEvent, LiveEventKind, LiveEventPayload},
overlay::normalize_image_url,
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
};
pub const GIFT_MENU_KIND: &str = "gift_menu";
@@ -28,6 +29,7 @@ const MAX_MENU_ITEMS: usize = 100;
pub enum GiftMenuThemeId {
#[default]
JadeBanquet,
MoonlitWater,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Deserialize, Serialize)]
@@ -73,6 +75,10 @@ pub struct GiftMenuSettings {
#[serde(default)]
pub theme_id: GiftMenuThemeId,
#[serde(default)]
pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")]
pub font_brightness: u16,
#[serde(default)]
pub items: Vec<GiftMenuItem>,
pub visible_rows: u8,
pub row_height: u16,
@@ -87,6 +93,8 @@ impl Default for GiftMenuSettings {
fn default() -> Self {
Self {
theme_id: GiftMenuThemeId::default(),
font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(),
items: Vec::new(),
visible_rows: 4,
row_height: 88,
@@ -117,6 +125,7 @@ impl GiftMenuSettings {
}
}
self.visible_rows = self.visible_rows.clamp(1, 20);
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.row_height = self.row_height.clamp(44, 240);
self.scroll_speed_pixels_per_second = self.scroll_speed_pixels_per_second.min(240);
self.highlight_duration_ms = self.highlight_duration_ms.clamp(600, 12_000);
@@ -314,6 +323,7 @@ mod tests {
PlatformViewer {
uid: "42".into(),
name: "观众".into(),
avatar_url: None,
}
}
@@ -363,6 +373,7 @@ mod tests {
row_height: 1,
scroll_speed_pixels_per_second: u16::MAX,
font_scale: 1,
font_brightness: 1,
..GiftMenuSettings::default()
};
let sanitized = definition
@@ -372,6 +383,7 @@ mod tests {
assert_eq!(sanitized["rowHeight"], 44);
assert_eq!(sanitized["scrollSpeedPixelsPerSecond"], 240);
assert_eq!(sanitized["fontScale"], 50);
assert_eq!(sanitized["fontBrightness"], 70);
}
#[test]
@@ -507,4 +519,13 @@ mod tests {
.is_some()
);
}
#[test]
fn moonlit_water_theme_is_accepted_and_serialized() {
let definition = GiftMenuDefinition;
let mut settings = definition.default_settings();
settings["themeId"] = Value::from("moonlit-water");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["themeId"], "moonlit-water");
}
}
+5 -1
View File
@@ -993,7 +993,11 @@ async fn component_test_event(
same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?;
let component = state.repository.get_component(session.user.id, id).await?;
let viewer = |uid: String, name: String| PlatformViewer { uid, name };
let viewer = |uid: String, name: String| PlatformViewer {
uid,
name,
avatar_url: None,
};
let payload = match body {
TestEventRequest::Enter { uid, name } => LiveEventPayload::Enter(EnterEvent {
viewer: viewer(uid, name),
+1
View File
@@ -22,3 +22,4 @@ pub mod rate_limit;
pub mod realtime;
pub mod repository;
pub mod song_request;
pub mod typography;
+99 -2
View File
@@ -6,7 +6,7 @@
//! modeled by `libilibili` are interpreted only inside this provider boundary;
//! unknown events expose sanitized command names, never raw packets or secrets.
use std::{sync::Arc, time::Duration};
use std::{collections::HashMap, sync::Arc, time::Duration};
use async_trait::async_trait;
use libilibili::{
@@ -491,6 +491,7 @@ fn normalize_command(command: LiveCommand) -> Option<ProviderEvent> {
viewer: PlatformViewer {
uid: message.uid.to_string(),
name: message.username,
avatar_url: None,
},
name: message.gift_name,
quantity: bounded_i32(message.num).max(1),
@@ -534,6 +535,7 @@ fn normalize_danmaku(message: &DanmuMessage) -> Option<ProviderEvent> {
.uname
.clone()
.unwrap_or_else(|| format!("UID {uid}")),
avatar_url: danmaku_avatar(message),
},
emoticons: parse_danmaku_emoticons(message, &text),
text,
@@ -629,6 +631,7 @@ fn normalize_gift(message: &GiftMessage) -> Option<ProviderEvent> {
.uname
.clone()
.unwrap_or_else(|| format!("UID {uid}")),
avatar_url: avatar_from_extra(&message.data.extra),
},
// Blind-box SEND_GIFT events describe the revealed prize in the
// ordinary gift fields. The `blind_gift.original_*` fields identify
@@ -674,6 +677,7 @@ fn normalize_combo(message: &ComboSendMessage) -> Option<ProviderEvent> {
.uname
.clone()
.unwrap_or_else(|| format!("UID {uid}")),
avatar_url: avatar_from_extra(&message.data.extra),
},
name: message
.data
@@ -701,6 +705,7 @@ fn normalize_like(message: &LikeClickMessage) -> Option<ProviderEvent> {
.uname
.clone()
.unwrap_or_else(|| format!("UID {uid}")),
avatar_url: avatar_from_extra(&message.data.extra),
},
})
}
@@ -726,6 +731,7 @@ fn normalize_superchat(message: &SuperChatMessage) -> Option<ProviderEvent> {
viewer: PlatformViewer {
uid: uid.to_string(),
name,
avatar_url: avatar_from_extra(&message.data.extra),
},
message: message.data.message.clone(),
price: bounded_i64(message.data.price).unwrap_or(i64::MAX),
@@ -737,6 +743,7 @@ fn normalize_interaction(message: &InteractWordMessage) -> Option<ProviderEvent>
let viewer = PlatformViewer {
uid: message.data.uid.to_string(),
name: message.data.uname.clone(),
avatar_url: avatar_from_extra(&message.data.extra),
};
match message.data.msg_type {
1 => Some(ProviderEvent::Enter { viewer }),
@@ -761,16 +768,73 @@ fn sanitized_command(command: Option<&str>) -> String {
.collect()
}
/// Recover the public avatar from both legacy and current Bilibili sender
/// layouts. `libilibili` deliberately preserves unknown DANMU_MSG entries, so
/// this adapter can adopt new sender envelopes without exposing raw packets to
/// the rest of the application.
fn danmaku_avatar(message: &DanmuMessage) -> Option<String> {
message
.metadata
.extra
.values()
.chain(message.sender.extra.values())
.chain(message.unknown_info.values())
.chain(message.extra.values())
.find_map(avatar_from_value)
.or_else(|| {
message.extension.as_ref().and_then(|extension| {
avatar_from_extra(&extension.extra).or_else(|| {
extension
.extra_json
.as_deref()
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
.as_ref()
.and_then(avatar_from_value)
})
})
})
}
fn avatar_from_extra(extra: &HashMap<String, Value>) -> Option<String> {
["face", "avatar", "avatar_url"]
.into_iter()
.find_map(|key| {
extra
.get(key)
.and_then(Value::as_str)
.and_then(normalize_image_url)
})
.or_else(|| extra.values().find_map(avatar_from_value))
}
fn avatar_from_value(value: &Value) -> Option<String> {
[
"/sender_uinfo/base/face",
"/user/base/face",
"/user_info/face",
"/base/face",
"/face",
]
.into_iter()
.find_map(|path| {
value
.pointer(path)
.and_then(Value::as_str)
.and_then(normalize_image_url)
})
}
fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
let command = raw.get("cmd")?.as_str()?.split(':').next()?.to_owned();
let data = raw.get("data").unwrap_or(raw);
let viewer = |uid: &Value, name: &Value| {
let viewer = |uid: &Value, name: &Value, avatar: Option<&Value>| {
Some(PlatformViewer {
uid: uid
.as_i64()
.map(|id| id.to_string())
.or_else(|| uid.as_str().map(str::to_owned))?,
name: name.as_str()?.to_owned(),
avatar_url: avatar.and_then(Value::as_str).and_then(normalize_image_url),
})
};
let data_viewer = |value: &Value| {
@@ -780,6 +844,10 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
.get("uname")
.or_else(|| value.pointer("/sender_uinfo/base/name"))
.or_else(|| value.pointer("/user_info/uname"))?,
value
.get("face")
.or_else(|| value.pointer("/sender_uinfo/base/face"))
.or_else(|| value.pointer("/user_info/face")),
)
};
match command.as_str() {
@@ -1239,6 +1307,35 @@ mod tests {
}
}
#[test]
fn recovers_avatar_from_current_danmaku_sender_envelope() {
let mut metadata = vec![Value::Null; 16];
metadata[15] = json!({
"user": {
"base": {
"face": "//i0.hdslb.com/bfs/face/avatar.jpg"
}
}
});
let mut info = vec![Value::Null; 3];
info[0] = Value::Array(metadata);
info[1] = json!("晚上好");
info[2] = json!([12345, "观众"]);
let message = normalize_command(parse_command(json!({
"cmd": "DANMU_MSG",
"info": info
})))
.unwrap();
match message {
ProviderEvent::Danmaku { viewer, .. } => assert_eq!(
viewer.avatar_url.as_deref(),
Some("https://i0.hdslb.com/bfs/face/avatar.jpg")
),
_ => panic!("expected danmaku"),
}
}
#[test]
fn unknown_events_do_not_forward_raw_payload() {
let event = normalize_command(parse_command(json!({
+28
View File
@@ -12,6 +12,8 @@ use serde_json::Value;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness};
/// Stable identifier for a renderer theme.
///
/// This enum is deliberately shared by defaults, validation and serialization:
@@ -23,6 +25,7 @@ use uuid::Uuid;
pub enum OverlayThemeId {
#[default]
JadeScroll,
MoonlitWater,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -31,6 +34,10 @@ pub struct OverlaySettings {
/// Visual theme selected for this tenant-owned component instance.
#[serde(default)]
pub theme_id: OverlayThemeId,
#[serde(default)]
pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")]
pub font_brightness: u16,
#[serde(default = "default_font_scale")]
pub font_scale: u16,
pub show_danmaku: bool,
@@ -59,6 +66,8 @@ impl Default for OverlaySettings {
fn default() -> Self {
Self {
theme_id: OverlayThemeId::default(),
font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(),
font_scale: default_font_scale(),
show_danmaku: true,
show_enter: true,
@@ -83,6 +92,7 @@ impl Default for OverlaySettings {
impl OverlaySettings {
pub fn sanitize(mut self) -> Self {
self.max_visible = self.max_visible.clamp(1, 12);
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.font_scale = self.font_scale.clamp(50, 300);
self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000);
@@ -546,6 +556,7 @@ mod tests {
fn settings_are_bounded_before_persistence() {
let settings = OverlaySettings {
font_scale: 999,
font_brightness: 1,
max_visible: 99,
collapse_after_seconds: 1,
unfold_duration_ms: 9_000,
@@ -558,6 +569,7 @@ mod tests {
}
.sanitize();
assert_eq!(settings.font_scale, 300);
assert_eq!(settings.font_brightness, 70);
assert_eq!(settings.max_visible, 12);
assert_eq!(settings.collapse_after_seconds, 2);
assert_eq!(settings.unfold_duration_ms, 5_000);
@@ -580,12 +592,16 @@ mod tests {
let object = value.as_object_mut().expect("settings object");
object.insert("title".into(), json!("旧版弹幕栏标题"));
object.remove("fontScale");
object.remove("fontFamily");
object.remove("fontBrightness");
object.remove("unfoldDurationMs");
object.remove("particleCount");
object.remove("particleSpeed");
object.remove("themeId");
let settings: OverlaySettings = serde_json::from_value(value).expect("legacy settings");
assert_eq!(settings.theme_id, OverlayThemeId::JadeScroll);
assert_eq!(settings.font_family, FontFamilyId::FangSong);
assert_eq!(settings.font_brightness, 130);
assert_eq!(settings.font_scale, 140);
assert_eq!(settings.unfold_duration_ms, 1_000);
assert_eq!(settings.particle_count, 8);
@@ -599,4 +615,16 @@ mod tests {
let error = serde_json::from_value::<OverlaySettings>(value).expect_err("unknown theme");
assert!(error.to_string().contains("unknown variant"));
}
#[test]
fn moonlit_water_theme_is_a_valid_persisted_identifier() {
let mut value = json!(OverlaySettings::default());
value["themeId"] = json!("moonlit-water");
let settings: OverlaySettings = serde_json::from_value(value).expect("moonlit theme");
assert_eq!(settings.theme_id, OverlayThemeId::MoonlitWater);
assert_eq!(
serde_json::to_value(settings).unwrap()["themeId"],
"moonlit-water"
);
}
}
+1
View File
@@ -402,6 +402,7 @@ mod tests {
viewer: PlatformViewer {
uid: "42".into(),
name: "观众".into(),
avatar_url: None,
},
text: "晚上好".into(),
segments: Vec::new(),
+10
View File
@@ -22,6 +22,7 @@ use crate::{
domain::{ComponentMessage, LiveEvent, LiveEventKind, LiveEventPayload, PlatformViewer},
overlay::OverlayThemeId,
realtime::EventHub,
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
};
pub const SONG_REQUEST_KIND: &str = "song_request";
@@ -33,6 +34,10 @@ const SNAPSHOT_PAGE_SIZE: usize = 100;
pub struct SongRequestSettings {
#[serde(default)]
pub theme_id: OverlayThemeId,
#[serde(default)]
pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")]
pub font_brightness: u16,
#[serde(default = "default_font_scale")]
pub font_scale: u16,
#[serde(default = "default_scroll_speed")]
@@ -54,6 +59,8 @@ impl Default for SongRequestSettings {
fn default() -> Self {
Self {
theme_id: OverlayThemeId::default(),
font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(),
font_scale: default_font_scale(),
scroll_speed_pixels_per_second: default_scroll_speed(),
edge_pause_seconds: default_edge_pause(),
@@ -67,6 +74,7 @@ impl Default for SongRequestSettings {
impl SongRequestSettings {
pub fn sanitize(mut self) -> Self {
self.font_scale = self.font_scale.clamp(50, 250);
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.scroll_speed_pixels_per_second = self.scroll_speed_pixels_per_second.clamp(5, 200);
self.edge_pause_seconds = self.edge_pause_seconds.min(15);
self.max_queue_size = self.max_queue_size.min(10_000);
@@ -1022,6 +1030,7 @@ mod tests {
let bounded = SongRequestSettings {
font_scale: 999,
font_brightness: u16::MAX,
scroll_speed_pixels_per_second: 0,
edge_pause_seconds: 200,
max_queue_size: u32::MAX,
@@ -1031,6 +1040,7 @@ mod tests {
}
.sanitize();
assert_eq!(bounded.font_scale, 250);
assert_eq!(bounded.font_brightness, 180);
assert_eq!(bounded.scroll_speed_pixels_per_second, 5);
assert_eq!(bounded.edge_pause_seconds, 15);
assert_eq!(bounded.max_queue_size, 10_000);
+39
View File
@@ -0,0 +1,39 @@
//! Shared, validated typography settings for all built-in OBS components.
//!
//! Font IDs map to fixed frontend font stacks rather than accepting arbitrary
//! CSS. This keeps settings portable between OBS hosts and prevents untrusted
//! component settings from becoming a CSS injection boundary.
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum FontFamilyId {
Song,
#[default]
FangSong,
Kai,
}
pub const fn default_font_brightness() -> u16 {
130
}
pub fn sanitize_font_brightness(value: u16) -> u16 {
value.clamp(70, 180)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn font_ids_are_stable_and_brightness_is_bounded() {
assert_eq!(
serde_json::to_string(&FontFamilyId::FangSong).unwrap(),
"\"fang-song\""
);
assert_eq!(sanitize_font_brightness(1), 70);
assert_eq!(sanitize_font_brightness(u16::MAX), 180);
}
}