783 lines
22 KiB
Rust
783 lines
22 KiB
Rust
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(())
|
||
}
|