use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct AppConfig { pub display: DisplayConfig, pub playlist: Vec, pub transition: TransitionConfig, pub playback: PlaybackConfig, pub scenes: ScenesConfig, pub remote_control: RemoteControlConfig, #[serde(default)] pub ble: BleConfig, /// 角色元信息 (M2.1 新增,向后兼容旧配置) #[serde(default)] pub character: CharacterConfig, /// AI 对话配置 (M2.1 新增) #[serde(default)] pub ai: AiConfig, #[serde(default)] pub source_path: PathBuf, #[serde(default)] pub source_dir: PathBuf, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct DisplayConfig { pub fullscreen: bool, pub window_title: String, pub rotation: i32, pub flip_horizontal: bool, pub flip_vertical: bool, #[serde(default)] pub offset_x: i32, #[serde(default)] pub offset_y: i32, #[serde(default)] pub prevent_screen_lock: bool, #[serde(default = "default_render_width")] pub render_width: i32, #[serde(default = "default_render_height")] pub render_height: i32, #[serde(default)] pub output_width: Option, #[serde(default)] pub output_height: Option, #[serde(default)] pub scale_mode: ScaleMode, #[serde(default = "default_allow_upscale")] pub allow_upscale: bool, pub perspective_correction: PerspectiveCorrectionConfig, #[serde(default)] pub chroma_key: ChromaKeyConfig, #[serde(default)] pub brightness_adjust: BrightnessAdjustConfig, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum ScaleMode { #[default] Fit, Stretch, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PerspectiveCorrectionConfig { pub enabled: bool, pub points: Vec<[i32; 2]>, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ChromaKeyConfig { #[serde(default)] pub enabled: bool, #[serde(default = "default_hsv_min")] pub hsv_min: [i32; 3], #[serde(default = "default_hsv_max")] pub hsv_max: [i32; 3], #[serde(default)] pub invert: bool, #[serde(default)] pub feather: i32, } impl Default for ChromaKeyConfig { fn default() -> Self { Self { enabled: false, hsv_min: default_hsv_min(), hsv_max: default_hsv_max(), invert: false, feather: 0, } } } fn default_hsv_min() -> [i32; 3] { [0, 0, 200] } fn default_hsv_max() -> [i32; 3] { [180, 30, 255] } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct BrightnessAdjustConfig { #[serde(default)] pub enabled: bool, #[serde(default = "default_subject_boost")] pub subject_boost: f64, #[serde(default = "default_background_suppress")] pub background_suppress: f64, #[serde(default = "default_brightness_threshold")] pub threshold: i32, } impl Default for BrightnessAdjustConfig { fn default() -> Self { Self { enabled: false, subject_boost: default_subject_boost(), background_suppress: default_background_suppress(), threshold: default_brightness_threshold(), } } } fn default_subject_boost() -> f64 { 1.5 } fn default_background_suppress() -> f64 { 0.3 } fn default_brightness_threshold() -> i32 { 30 } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct VideoItem { pub id: String, pub path: String, pub duration: Option, #[serde(default = "default_loop_count")] pub loop_count: i32, #[serde(default)] pub random_loop_range: Option<[i32; 2]>, } fn default_loop_count() -> i32 { 1 } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum TransitionType { Fade, Cut, None, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TransitionConfig { pub enabled: bool, #[serde(rename = "type")] pub transition_type: TransitionType, pub duration: f64, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PlaybackConfig { pub loop_playlist: bool, pub auto_start: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScenesConfig { #[serde(default)] pub rest: Vec, #[serde(default)] pub active: Vec, #[serde(default)] pub sleep: Vec, #[serde(default)] pub interact: Vec, #[serde(default)] pub state_machine: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StateMachineConfig { pub initial_state: String, pub states: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StateConfig { pub name: String, #[serde(default)] pub mode: StateMode, pub sequence: Vec, #[serde(default)] pub next_state: Option, #[serde(default)] pub next_states: Option>, #[serde(default)] pub transitions: Vec, #[serde(default = "default_weight")] pub weight: f32, #[serde(default)] pub defer_triggers: bool, #[serde(default)] pub ignore_triggers: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NextStateEntry { pub state: String, #[serde(default = "default_weight")] pub weight: f32, } fn default_weight() -> f32 { 1.0 } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum StateMode { #[default] FreeMode, InteractiveMode, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnimationStep { pub video_id: String, #[serde(default)] pub loop_count: Option, #[serde(default)] pub random_loop_range: Option<[i32; 2]>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StateTransition { pub trigger: TriggerType, pub target_state: String, #[serde(default)] pub priority: i32, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TriggerType { Button { name: String }, Voice { keyword: String }, Sensor { name: String }, Timer { seconds: f64 }, Random { probability: f64 }, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RemoteControlConfig { pub enabled: bool, pub host: String, pub port: u16, } /// BLE 配网配置 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct BleConfig { #[serde(default = "default_ble_enabled")] pub enabled: bool, #[serde(default = "default_ble_device_name")] pub device_name: String, } impl Default for BleConfig { fn default() -> Self { Self { enabled: default_ble_enabled(), device_name: default_ble_device_name(), } } } fn default_ble_enabled() -> bool { true } fn default_ble_device_name() -> String { "Showen".to_string() } // ── 角色元信息 (M2.1) ── /// 角色类型 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum CharacterType { #[default] Pet, Human, Singer, } /// 角色元信息配置块(内容包的一部分,切角色即切人设) #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct CharacterConfig { /// 角色显示名 (如 "小汪") #[serde(default)] pub name: String, /// 角色类型 #[serde(default)] pub character_type: CharacterType, /// 封面图相对路径 (内容包内) #[serde(default)] pub cover_image: Option, /// 人设 prompt (LLM system prompt) #[serde(default)] pub persona_prompt: String, /// 最大回复 token 数 #[serde(default = "default_character_max_tokens")] pub max_tokens: u16, /// TTS 音色标识 (piper 模型 ID,留空用默认) #[serde(default)] pub tts_voice: Option, /// talk 状态名 (语音回合期间切换到,留空用 "talk") #[serde(default = "default_talk_state")] pub talk_state: String, } fn default_character_max_tokens() -> u16 { 128 } fn default_talk_state() -> String { "talk".to_string() } // ── AI 配置 (M2.1) ── /// LLM 后端类型 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum LlmBackend { #[default] Local, /// 云端 (V1 仅占位,配置时返回"暂未支持") Cloud, } /// AI 推理配置 #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct AiConfig { /// LLM 后端 (local/cloud) #[serde(default)] pub backend: LlmBackend, /// 云端 endpoint (预留,V1 不实现) #[serde(default)] pub cloud_endpoint: Option, /// 云端 API key (预留) #[serde(default)] pub cloud_api_key: Option, /// 模型存储目录 (默认 model_store/) #[serde(default = "default_model_store")] pub model_store: PathBuf, /// 工具目录 (whisper-cli/llama-cli/piper 二进制所在) #[serde(default = "default_tools_dir")] pub tools_dir: PathBuf, /// whisper-cli 依赖库路径 (libggml*.so 所在,部署时标定) #[serde(default)] pub whisper_lib_dir: Option, /// piper 依赖库路径 (libespeak-ng.so / libpiper_phonemize.so 所在) #[serde(default)] pub piper_lib_dir: Option, /// piper 模型 config json 路径 (.onnx.json) #[serde(default)] pub piper_config: Option, /// 临时文件目录 #[serde(default = "default_tmp_dir")] pub tmp_dir: PathBuf, /// 上下文窗口轮数 #[serde(default = "default_context_window")] pub context_window: usize, } fn default_model_store() -> PathBuf { PathBuf::from("model_store") } fn default_tools_dir() -> PathBuf { PathBuf::from("tools") } fn default_tmp_dir() -> PathBuf { PathBuf::from("/tmp/showen_ai") } fn default_context_window() -> usize { 5 } // ── 加载与验证 ── impl AppConfig { pub fn from_file>(path: P) -> Result { let path = path.as_ref(); let raw = fs::read_to_string(path) .with_context(|| format!("读取配置文件失败: {}", path.display()))?; let mut config: AppConfig = serde_json::from_str(&raw) .with_context(|| format!("解析配置 JSON 失败: {}", path.display()))?; config.set_source_path(path)?; config.validate()?; Ok(config) } fn set_source_path(&mut self, source_path: &Path) -> Result<()> { if source_path.as_os_str().is_empty() { bail!("配置文件路径不能为空"); } self.source_path = source_path .canonicalize() .unwrap_or_else(|_| absolute_from_current_dir(source_path)); self.source_dir = self .source_path .parent() .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")); Ok(()) } pub fn validate(&self) -> Result<()> { self.display.validate()?; if self.playlist.is_empty() { bail!("playlist 不能为空"); } let mut playlist_ids = HashSet::new(); for (index, item) in self.playlist.iter().enumerate() { item.validate(&format!("playlist[{index}]"))?; if !playlist_ids.insert(item.id.as_str()) { bail!("playlist[{index}] id '{}' 重复", item.id); } } self.transition.validate()?; self.scenes.validate(&playlist_ids)?; self.remote_control.validate()?; Ok(()) } pub fn validate_paths(&self) -> Result<()> { for item in &self.playlist { let full_path = self.resolve_media_path(item); if !full_path.exists() { bail!( "视频文件 '{}' 不存在: {} (相对于 {})", item.id, full_path.display(), self.source_path.display() ); } } Ok(()) } pub fn resolve_media_path(&self, item: &VideoItem) -> PathBuf { let media_path = Path::new(&item.path); if media_path.is_absolute() { media_path.to_path_buf() } else { self.source_dir.join(media_path) } } } pub fn parse_str(raw: &str, source_path: impl Into) -> Result { let source_path = source_path.into(); let mut config: AppConfig = serde_json::from_str(raw).context("解析配置 JSON 失败")?; config.set_source_path(&source_path)?; config.validate()?; Ok(config) } fn absolute_from_current_dir(path: &Path) -> PathBuf { if path.is_absolute() { return path.to_path_buf(); } std::env::current_dir() .map(|cd| cd.join(path)) .unwrap_or_else(|_| path.to_path_buf()) } fn default_render_width() -> i32 { 1024 } fn default_render_height() -> i32 { 1024 } fn default_allow_upscale() -> bool { true } impl DisplayConfig { pub fn validate(&self) -> Result<()> { if self.window_title.trim().is_empty() { bail!("display.window_title 不能为空"); } if self.render_width <= 0 { bail!("display.render_width 必须大于 0"); } if self.render_height <= 0 { bail!("display.render_height 必须大于 0"); } if !matches!(self.rotation, 0 | 90 | 180 | 270) { bail!("display.rotation 只能是 0/90/180/270"); } self.perspective_correction.validate()?; self.chroma_key.validate()?; self.brightness_adjust.validate()?; Ok(()) } } impl PerspectiveCorrectionConfig { pub fn validate(&self) -> Result<()> { let point_count = self.points.len(); if point_count != 0 && point_count != 4 { bail!("display.perspective_correction.points 必须为 0 或 4 个点"); } if self.enabled && point_count != 4 { bail!("display.perspective_correction 启用时必须提供 4 个点"); } Ok(()) } } impl ChromaKeyConfig { pub fn validate(&self) -> Result<()> { let hsv_ranges = [180, 255, 255]; // H, S, V max values for (index, &max_val) in hsv_ranges.iter().enumerate() { validate_hsv_component( &format!("display.chroma_key.hsv_min[{index}]"), self.hsv_min[index], 0, max_val, )?; validate_hsv_component( &format!("display.chroma_key.hsv_max[{index}]"), self.hsv_max[index], 0, max_val, )?; if self.hsv_min[index] > self.hsv_max[index] { bail!("display.chroma_key hsv_min[{index}] 不能大于 hsv_max[{index}]"); } } if self.feather < 0 { bail!("display.chroma_key.feather 不能小于 0"); } Ok(()) } } impl BrightnessAdjustConfig { pub fn validate(&self) -> Result<()> { if !self.subject_boost.is_finite() || self.subject_boost < 0.0 { bail!("display.brightness_adjust.subject_boost 必须为非负有限数"); } if !self.background_suppress.is_finite() || !(0.0..=1.0).contains(&self.background_suppress) { bail!("display.brightness_adjust.background_suppress 必须在 0.0 到 1.0 之间"); } if !(0..=255).contains(&self.threshold) { bail!("display.brightness_adjust.threshold 必须在 0 到 255 之间"); } Ok(()) } } impl VideoItem { pub fn validate(&self, section: &str) -> Result<()> { if self.id.trim().is_empty() { bail!("{section}.id 不能为空"); } if self.path.trim().is_empty() { bail!("{section}.path 不能为空"); } if self.loop_count <= 0 { bail!("{section}.loop_count 必须大于 0"); } if let Some(duration) = self.duration { if !duration.is_finite() || duration <= 0.0 { bail!("{section}.duration 必须为正有限数"); } } if let Some([min, max]) = self.random_loop_range { if min <= 0 || max <= 0 { bail!("{section}.random_loop_range 值必须大于 0"); } if min > max { bail!("{section}.random_loop_range[0] 不能大于 random_loop_range[1]"); } } Ok(()) } } impl TransitionConfig { pub fn validate(&self) -> Result<()> { if !self.duration.is_finite() || self.duration < 0.0 { bail!("transition.duration 必须为非负有限数"); } if self.enabled && self.transition_type != TransitionType::None && self.duration <= 0.0 { bail!("transition 启用且 type 非 none 时,duration 必须大于 0"); } Ok(()) } } impl ScenesConfig { pub fn validate(&self, playlist_index: &HashSet<&str>) -> Result<()> { self.rest.validate_items("scenes.rest")?; self.active.validate_items("scenes.active")?; self.sleep.validate_items("scenes.sleep")?; self.interact.validate_items("scenes.interact")?; let Some(state_machine) = &self.state_machine else { return Ok(()); }; if state_machine.initial_state.trim().is_empty() { bail!("scenes.state_machine.initial_state 不能为空"); } if !state_machine .states .contains_key(&state_machine.initial_state) { bail!( "scenes.state_machine.initial_state '{}' 不存在于 states 中", state_machine.initial_state ); } let mut has_free_mode = false; for (state_id, state) in &state_machine.states { if state.mode == StateMode::FreeMode { has_free_mode = true; } for (step_index, step) in state.sequence.iter().enumerate() { if step.video_id.trim().is_empty() { bail!( "scenes.state_machine.states['{state_id}'].sequence[{step_index}].video_id 不能为空" ); } if !playlist_index.contains(step.video_id.as_str()) { bail!( "scenes.state_machine.states['{state_id}'].sequence[{step_index}] 引用的 video_id '{}' 不存在于 playlist 中", step.video_id ); } } if let Some(next_state) = &state.next_state { if next_state.trim().is_empty() { bail!("scenes.state_machine.states['{state_id}'].next_state 不能为空"); } if !state_machine.states.contains_key(next_state) { bail!( "scenes.state_machine.states['{state_id}'].next_state '{}' 不存在", next_state ); } } if let Some(next_states) = &state.next_states { for (entry_index, entry) in next_states.iter().enumerate() { if entry.state.trim().is_empty() { bail!( "scenes.state_machine.states['{state_id}'].next_states[{entry_index}].state 不能为空" ); } if !state_machine.states.contains_key(&entry.state) { bail!( "scenes.state_machine.states['{state_id}'].next_states[{entry_index}] 的目标状态 '{}' 不存在", entry.state ); } if entry.weight <= 0.0 { bail!( "scenes.state_machine.states['{state_id}'].next_states[{entry_index}].weight 必须大于 0" ); } } } for (transition_index, transition) in state.transitions.iter().enumerate() { if transition.target_state.trim().is_empty() { bail!( "scenes.state_machine.states['{state_id}'].transitions[{transition_index}].target_state 不能为空" ); } if !state_machine.states.contains_key(&transition.target_state) { bail!( "scenes.state_machine.states['{state_id}'].transitions[{transition_index}] 的 target_state '{}' 不存在", transition.target_state ); } } } if !has_free_mode { bail!("scenes.state_machine 至少需要一个 FreeMode 状态"); } Ok(()) } } impl RemoteControlConfig { pub fn validate(&self) -> Result<()> { if self.enabled && self.host.trim().is_empty() { bail!("remote_control.enabled 为 true 时,host 不能为空"); } if self.enabled && self.port == 0 { bail!("remote_control.enabled 为 true 时,port 不能为 0"); } Ok(()) } } trait ValidateVideoItems { fn validate_items(&self, section: &str) -> Result<()>; } impl ValidateVideoItems for [VideoItem] { fn validate_items(&self, section: &str) -> Result<()> { for (index, item) in self.iter().enumerate() { item.validate(&format!("{section}[{index}]"))?; } Ok(()) } } fn validate_hsv_component(field: &str, value: i32, min: i32, max: i32) -> Result<()> { if !(min..=max).contains(&value) { bail!("{field} 必须在 {min} 到 {max} 之间"); } Ok(()) }