Skip to main content

chromaframe_mcp/
schema.rs

1use std::{borrow::Cow, path::PathBuf};
2
3use chromaframe_sdk::{GoalVector, Lab};
4use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
5use serde::{Deserialize, Deserializer, Serialize};
6
7pub const DEFAULT_CONFIDENCE: f32 = 0.65;
8pub const DEFAULT_RANKING_LIMIT: usize = 5;
9pub const MAX_RANKING_LIMIT: usize = 25;
10pub const MAX_CANDIDATE_COUNT: usize = 64;
11pub const MAX_CANDIDATE_NAME_BYTES: usize = 80;
12pub const MAX_PATH_BYTES: usize = 4096;
13pub const MAX_ENCODED_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
14
15#[derive(Debug, Clone, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct ReadinessInput {
18    #[serde(default)]
19    pub include_warnings: bool,
20}
21
22impl JsonSchema for ReadinessInput {
23    fn schema_name() -> Cow<'static, str> {
24        "ReadinessInput".into()
25    }
26
27    fn schema_id() -> Cow<'static, str> {
28        concat!(module_path!(), "::ReadinessInput").into()
29    }
30
31    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
32        json_schema!({
33            "type": "object",
34            "additionalProperties": false,
35            "properties": {
36                "include_warnings": { "type": "boolean", "default": false }
37            }
38        })
39    }
40}
41
42#[derive(Debug, Clone, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct ManualRankInput {
45    pub skin_lab: LabInput,
46
47    #[serde(default)]
48    pub brow_lab: Option<LabInput>,
49
50    #[serde(default)]
51    pub iris_lab: Option<LabInput>,
52
53    #[serde(default)]
54    pub sclera_lab: Option<LabInput>,
55
56    #[serde(default)]
57    pub lip_lab: Option<LabInput>,
58
59    #[serde(default)]
60    pub hair_lab: Option<LabInput>,
61
62    #[serde(default)]
63    pub beard_lab: Option<LabInput>,
64
65    #[serde(default)]
66    pub goal_vector: GoalVectorInput,
67
68    #[serde(default = "default_confidence")]
69    pub confidence: f32,
70
71    #[serde(default = "default_ranking_limit")]
72    pub limit: usize,
73
74    pub candidates: Vec<CandidateInput>,
75}
76
77impl JsonSchema for ManualRankInput {
78    fn schema_name() -> Cow<'static, str> {
79        "ManualRankInput".into()
80    }
81
82    fn schema_id() -> Cow<'static, str> {
83        concat!(module_path!(), "::ManualRankInput").into()
84    }
85
86    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
87        let lab_schema = generator.subschema_for::<LabInput>();
88        let candidate_schema = generator.subschema_for::<CandidateInput>();
89        let goal_schema = generator.subschema_for::<GoalVectorInput>();
90        json_schema!({
91            "type": "object",
92            "additionalProperties": false,
93            "required": ["skin_lab", "candidates"],
94            "properties": {
95                "skin_lab": lab_schema,
96                "brow_lab": lab_schema,
97                "iris_lab": lab_schema,
98                "sclera_lab": lab_schema,
99                "lip_lab": lab_schema,
100                "hair_lab": lab_schema,
101                "beard_lab": lab_schema,
102                "goal_vector": goal_schema,
103                "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": DEFAULT_CONFIDENCE },
104                "limit": { "type": "integer", "minimum": 1, "maximum": MAX_RANKING_LIMIT, "default": DEFAULT_RANKING_LIMIT },
105                "candidates": { "type": "array", "minItems": 1, "maxItems": MAX_CANDIDATE_COUNT, "items": candidate_schema }
106            }
107        })
108    }
109}
110
111#[derive(Debug, Clone)]
112pub struct AnalyzeImageInput {
113    pub image_path: PathBuf,
114    pub goal_vector: GoalVectorInput,
115    pub limit: usize,
116    pub candidates: Vec<CandidateInput>,
117}
118
119impl<'de> Deserialize<'de> for AnalyzeImageInput {
120    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
121    where
122        D: Deserializer<'de>,
123    {
124        #[derive(Deserialize)]
125        #[serde(deny_unknown_fields)]
126        struct RawAnalyzeImageInput {
127            image_path: String,
128            #[serde(default)]
129            goal_vector: GoalVectorInput,
130            #[serde(default = "default_ranking_limit")]
131            limit: usize,
132            candidates: Vec<CandidateInput>,
133        }
134
135        let raw = RawAnalyzeImageInput::deserialize(deserializer)?;
136        let image_path = parse_non_blank_path(raw.image_path, "image_path")?;
137
138        Ok(Self {
139            image_path,
140            goal_vector: raw.goal_vector,
141            limit: raw.limit,
142            candidates: raw.candidates,
143        })
144    }
145}
146
147impl JsonSchema for AnalyzeImageInput {
148    fn schema_name() -> Cow<'static, str> {
149        "AnalyzeImageInput".into()
150    }
151
152    fn schema_id() -> Cow<'static, str> {
153        concat!(module_path!(), "::AnalyzeImageInput").into()
154    }
155
156    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
157        let candidate_schema = generator.subschema_for::<CandidateInput>();
158        let goal_schema = generator.subschema_for::<GoalVectorInput>();
159        json_schema!({
160            "type": "object",
161            "additionalProperties": false,
162            "required": ["image_path", "candidates"],
163            "properties": {
164                "image_path": { "type": "string", "minLength": 1, "maxLength": MAX_PATH_BYTES, "pattern": ".*\\S.*" },
165                "goal_vector": goal_schema,
166                "limit": { "type": "integer", "minimum": 1, "maximum": MAX_RANKING_LIMIT, "default": DEFAULT_RANKING_LIMIT },
167                "candidates": { "type": "array", "minItems": 1, "maxItems": MAX_CANDIDATE_COUNT, "items": candidate_schema }
168            }
169        })
170    }
171}
172
173#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
174#[serde(deny_unknown_fields)]
175pub struct LabInput {
176    pub l: f32,
177    pub a: f32,
178    pub b: f32,
179}
180
181impl From<LabInput> for Lab {
182    fn from(value: LabInput) -> Self {
183        Self {
184            l: value.l,
185            a: value.a,
186            b: value.b,
187        }
188    }
189}
190
191impl From<Lab> for LabInput {
192    fn from(value: Lab) -> Self {
193        Self {
194            l: value.l,
195            a: value.a,
196            b: value.b,
197        }
198    }
199}
200
201#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
202#[serde(deny_unknown_fields)]
203pub struct GoalVectorInput {
204    #[serde(default = "default_contrast_target")]
205    pub contrast_target: f32,
206    #[serde(default)]
207    pub warmth_target: f32,
208    #[serde(default = "default_chroma_target")]
209    pub chroma_target: f32,
210    #[serde(default = "default_feature_readability_target")]
211    pub feature_readability_target: f32,
212    #[serde(default = "default_artificiality_tolerance")]
213    pub artificiality_tolerance: f32,
214}
215
216impl JsonSchema for GoalVectorInput {
217    fn schema_name() -> Cow<'static, str> {
218        "GoalVectorInput".into()
219    }
220
221    fn schema_id() -> Cow<'static, str> {
222        concat!(module_path!(), "::GoalVectorInput").into()
223    }
224
225    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
226        json_schema!({
227            "type": "object",
228            "additionalProperties": false,
229            "properties": {
230                "contrast_target": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": default_contrast_target() },
231                "warmth_target": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
232                "chroma_target": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": default_chroma_target() },
233                "feature_readability_target": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": default_feature_readability_target() },
234                "artificiality_tolerance": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": default_artificiality_tolerance() }
235            }
236        })
237    }
238}
239
240impl Default for GoalVectorInput {
241    fn default() -> Self {
242        let goal = GoalVector::default();
243        Self {
244            contrast_target: goal.contrast_target,
245            warmth_target: goal.warmth_target,
246            chroma_target: goal.chroma_target,
247            feature_readability_target: goal.feature_readability_target,
248            artificiality_tolerance: goal.artificiality_tolerance,
249        }
250    }
251}
252
253impl From<GoalVectorInput> for GoalVector {
254    fn from(value: GoalVectorInput) -> Self {
255        Self {
256            contrast_target: value.contrast_target,
257            warmth_target: value.warmth_target,
258            chroma_target: value.chroma_target,
259            feature_readability_target: value.feature_readability_target,
260            artificiality_tolerance: value.artificiality_tolerance,
261        }
262    }
263}
264
265#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
266#[serde(deny_unknown_fields)]
267pub struct CandidateInput {
268    pub name: String,
269    pub srgb: [u8; 3],
270}
271
272impl<'de> Deserialize<'de> for CandidateInput {
273    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
274    where
275        D: Deserializer<'de>,
276    {
277        #[derive(Deserialize)]
278        #[serde(deny_unknown_fields)]
279        struct RawCandidateInput {
280            name: String,
281            srgb: [u8; 3],
282        }
283
284        let raw = RawCandidateInput::deserialize(deserializer)?;
285        let name = parse_candidate_name::<D::Error>(raw.name)?;
286        Ok(Self {
287            name,
288            srgb: raw.srgb,
289        })
290    }
291}
292
293impl JsonSchema for CandidateInput {
294    fn schema_name() -> Cow<'static, str> {
295        "CandidateInput".into()
296    }
297
298    fn schema_id() -> Cow<'static, str> {
299        concat!(module_path!(), "::CandidateInput").into()
300    }
301
302    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
303        json_schema!({
304            "type": "object",
305            "additionalProperties": false,
306            "required": ["name", "srgb"],
307            "properties": {
308                "name": { "type": "string", "minLength": 1, "maxLength": MAX_CANDIDATE_NAME_BYTES, "pattern": ".*\\S.*" },
309                "srgb": {
310                    "type": "array",
311                    "minItems": 3,
312                    "maxItems": 3,
313                    "items": { "type": "integer", "minimum": 0, "maximum": 255 }
314                }
315            }
316        })
317    }
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
321#[serde(deny_unknown_fields)]
322pub struct ReadinessOutput {
323    pub sdk_available: bool,
324    pub vision_helper_available: bool,
325    pub python_version: Option<String>,
326    pub missing_packages: Vec<String>,
327    pub missing_models: Vec<String>,
328    pub warnings: Vec<String>,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
332#[serde(deny_unknown_fields)]
333pub struct ManualRankOutput {
334    pub status: String,
335    pub subject: SubjectSummary,
336    pub rankings: Vec<CandidateRankingSummary>,
337    pub uncertainty: String,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
341#[serde(deny_unknown_fields)]
342pub struct AnalyzeImageOutput {
343    pub image: ImageSummary,
344    pub extraction: ExtractionSummary,
345    pub measurement: MeasurementSummary,
346    pub rankings: Vec<CandidateRankingSummary>,
347    pub evidence_cards: Vec<EvidenceCardSummary>,
348    pub warnings: Vec<String>,
349    pub limitations: Vec<String>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
353#[serde(deny_unknown_fields)]
354pub struct ImageSummary {
355    pub dimensions: [u32; 2],
356    pub measurement_mode: String,
357    pub icc_status: String,
358    pub metadata_retained: bool,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
362#[serde(deny_unknown_fields)]
363pub struct ExtractionSummary {
364    pub backend: String,
365    pub faces_detected: u32,
366    pub selected_face_index: Option<u32>,
367    pub regions: Vec<RegionSummary>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
371#[serde(deny_unknown_fields)]
372pub struct RegionSummary {
373    pub kind: String,
374    pub status: String,
375    pub source: String,
376    pub confidence: f32,
377    pub sample_hint: Option<usize>,
378    pub reason: Option<String>,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
382#[serde(deny_unknown_fields)]
383pub struct MeasurementSummary {
384    pub status: String,
385    pub confidence: Option<f32>,
386    pub subject: Option<SubjectSummary>,
387    pub contrasts: Vec<ContrastSummary>,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
391#[serde(deny_unknown_fields)]
392pub struct SubjectSummary {
393    pub skin_lab: LabInput,
394    pub skin_ita: f32,
395    pub skin_depth_proxy: f32,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
399#[serde(deny_unknown_fields)]
400pub struct ContrastSummary {
401    pub from: String,
402    pub to: String,
403    pub delta_l: f32,
404    pub delta_a: f32,
405    pub delta_b: f32,
406    pub delta_e00: f32,
407    pub michelson_lightness_contrast: f32,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
411#[serde(deny_unknown_fields)]
412pub struct CandidateRankingSummary {
413    pub name: String,
414    pub score: f32,
415    pub confidence: f32,
416    pub harshness: f32,
417    pub label: String,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
421#[serde(deny_unknown_fields)]
422pub struct EvidenceCardSummary {
423    pub kind: String,
424    pub summary: String,
425}
426
427fn parse_non_blank_path<E>(raw_path: String, field: &'static str) -> Result<PathBuf, E>
428where
429    E: serde::de::Error,
430{
431    let trimmed = raw_path.trim();
432    if trimmed.is_empty() {
433        return Err(E::custom(format!("`{field}` cannot be blank")));
434    }
435    if trimmed.len() > MAX_PATH_BYTES {
436        return Err(E::custom(format!(
437            "`{field}` exceeds {MAX_PATH_BYTES} UTF-8 bytes"
438        )));
439    }
440
441    Ok(PathBuf::from(trimmed))
442}
443
444fn parse_candidate_name<E>(raw_name: String) -> Result<String, E>
445where
446    E: serde::de::Error,
447{
448    let trimmed = raw_name.trim();
449    if trimmed.is_empty() {
450        return Err(E::custom("candidate `name` cannot be blank"));
451    }
452    if trimmed.len() > MAX_CANDIDATE_NAME_BYTES {
453        return Err(E::custom(format!(
454            "candidate `name` exceeds {MAX_CANDIDATE_NAME_BYTES} UTF-8 bytes"
455        )));
456    }
457
458    Ok(trimmed.to_string())
459}
460
461const fn default_confidence() -> f32 {
462    DEFAULT_CONFIDENCE
463}
464
465const fn default_ranking_limit() -> usize {
466    DEFAULT_RANKING_LIMIT
467}
468
469fn default_contrast_target() -> f32 {
470    GoalVector::default().contrast_target
471}
472
473fn default_chroma_target() -> f32 {
474    GoalVector::default().chroma_target
475}
476
477fn default_feature_readability_target() -> f32 {
478    GoalVector::default().feature_readability_target
479}
480
481fn default_artificiality_tolerance() -> f32 {
482    GoalVector::default().artificiality_tolerance
483}
484
485pub fn validate_limit(limit: usize) -> Result<usize, String> {
486    if !(1..=MAX_RANKING_LIMIT).contains(&limit) {
487        return Err(format!("limit must be between 1 and {MAX_RANKING_LIMIT}"));
488    }
489
490    Ok(limit)
491}
492
493pub fn validate_candidate_count(count: usize) -> Result<usize, String> {
494    if !(1..=MAX_CANDIDATE_COUNT).contains(&count) {
495        return Err(format!(
496            "candidate count must be between 1 and {MAX_CANDIDATE_COUNT}"
497        ));
498    }
499
500    Ok(count)
501}