init: ShowenV2 项目骨架 — 数字生命窗口平台
- core/ 跨平台内核骨架 (Plugin trait, Message, ServiceManager, Config) - plugins/ 空桩 (video, http, ble, screen, wifi) - PROGRESS.md 进度跟踪, TEAM.md 团队档案 - cargo check 零 warning 通过 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
365
src/core/config.rs
Normal file
365
src/core/config.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub type Config = AppConfig;
|
||||
|
||||
#[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,
|
||||
#[serde(skip)]
|
||||
pub source_path: PathBuf,
|
||||
#[serde(skip)]
|
||||
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)]
|
||||
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)]
|
||||
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)]
|
||||
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() }
|
||||
|
||||
// ── 加载与验证 ──
|
||||
|
||||
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<()> {
|
||||
// 基础验证 — 完整验证在 Commit 2 补全
|
||||
if self.playlist.is_empty() {
|
||||
bail!("playlist 不能为空");
|
||||
}
|
||||
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 }
|
||||
Reference in New Issue
Block a user