Files
ShowenV2/src/core/config.rs

783 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<VideoItem>,
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<i32>,
#[serde(default)]
pub output_height: Option<i32>,
#[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<f64>,
#[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<VideoItem>,
#[serde(default)]
pub active: Vec<VideoItem>,
#[serde(default)]
pub sleep: Vec<VideoItem>,
#[serde(default)]
pub interact: Vec<VideoItem>,
#[serde(default)]
pub state_machine: Option<StateMachineConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateMachineConfig {
pub initial_state: String,
pub states: HashMap<String, StateConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateConfig {
pub name: String,
#[serde(default)]
pub mode: StateMode,
pub sequence: Vec<AnimationStep>,
#[serde(default)]
pub next_state: Option<String>,
#[serde(default)]
pub next_states: Option<Vec<NextStateEntry>>,
#[serde(default)]
pub transitions: Vec<StateTransition>,
#[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<i32>,
#[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<String>,
/// 人设 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<String>,
/// 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<String>,
/// 云端 API key (预留)
#[serde(default)]
pub cloud_api_key: Option<String>,
/// 模型存储目录 (默认 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<PathBuf>,
/// piper 依赖库路径 (libespeak-ng.so / libpiper_phonemize.so 所在)
#[serde(default)]
pub piper_lib_dir: Option<PathBuf>,
/// piper 模型 config json 路径 (.onnx.json)
#[serde(default)]
pub piper_config: Option<PathBuf>,
/// 临时文件目录
#[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<P: AsRef<Path>>(path: P) -> Result<Self> {
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<PathBuf>) -> Result<AppConfig> {
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(())
}