1use serde::Deserialize;
36use serde_json::{Value, json};
37use smol_str::SmolStr;
38
39use llmtask::{JsonParseError, Task};
40
41pub use llmtask::ImageAnalysis;
42
43const IMAGE_ANALYSIS_PROMPT: &str = r#"Analyze the following video keyframes (in chronological order) from a single scene.
58
59Return ONLY a valid JSON object with exactly these fields:
60scene: a single short scene-category label in lowercase English, 1-3 words, no full sentence.
61description: 1-2 concise sentences in English describing the stable visual facts across the scene. Cover who is present, what they are doing, the setting, and the overall mood or visual style. If readable on-screen text appears, quote that text first, then continue the description.
62subjects: array of distinct people or animals as short noun phrases (each 2-6 words) with visible distinguishing features.
63objects: array of notable, search-relevant objects as short noun phrases (each 2-6 words).
64actions: array of visible actions as short verb phrases (each 1-4 words).
65mood: array of single-word or two-word adjectives describing the scene's overall emotional tone.
66shot_type: a single short camera-shot label in lowercase English, 1-2 words (a cinematography term).
67lighting: array of single-word or two-word lighting descriptors.
68tags: array of 8-12 short English search tags in lowercase. Prefer high-confidence search terms, complementary synonyms, style words, and culture-specific terms only when visually supported.
69
70Rules:
71- Use only information supported by the keyframes.
72- Prefer concrete visual facts over speculation.
73- Keep arrays deduplicated.
74- Use empty arrays or empty strings when a field is unknown.
75- Do not return markdown or any text outside the JSON object."#;
76
77const REQUIRED_FIELDS: &[&str] = &[
78 "scene",
79 "description",
80 "subjects",
81 "objects",
82 "actions",
83 "mood",
84 "shot_type",
85 "lighting",
86 "tags",
87];
88
89#[derive(Clone)]
91pub struct ImageAnalysisTask {
92 schema: Value,
93 accept_empty: bool,
94}
95
96impl ImageAnalysisTask {
97 pub fn new() -> Self {
104 Self {
105 schema: build_schema(),
106 accept_empty: false,
107 }
108 }
109
110 #[cfg_attr(not(tarpaulin), inline(always))]
114 pub const fn accept_empty(&self) -> bool {
115 self.accept_empty
116 }
117
118 #[cfg_attr(not(tarpaulin), inline(always))]
159 pub const fn with_accept_empty(mut self, val: bool) -> Self {
160 self.accept_empty = val;
161 self
162 }
163
164 #[cfg_attr(not(tarpaulin), inline(always))]
167 pub const fn set_accept_empty(&mut self, val: bool) -> &mut Self {
168 self.accept_empty = val;
169 self
170 }
171}
172
173impl Default for ImageAnalysisTask {
174 fn default() -> Self {
175 Self::new()
176 }
177}
178
179impl Task for ImageAnalysisTask {
180 type Output = ImageAnalysis;
181 type Value = serde_json::Value;
182 type ParseError = llmtask::JsonParseError;
183
184 fn prompt(&self) -> &str {
185 IMAGE_ANALYSIS_PROMPT
186 }
187
188 fn schema(&self) -> &serde_json::Value {
189 &self.schema
190 }
191
192 fn grammar(&self) -> llmtask::Grammar {
193 llmtask::Grammar::JsonSchema(self.schema.clone())
196 }
197
198 fn parse(&self, raw: &str) -> Result<Self::Output, JsonParseError> {
199 let value: Value = serde_json::from_str(raw.trim())?;
200 let object = value
201 .as_object()
202 .ok_or_else(|| JsonParseError::Json(serde::de::Error::custom("expected top-level object")))?;
203 let missing = missing_required_fields(object);
204 if !missing.is_empty() {
205 return Err(JsonParseError::MissingFields(missing));
206 }
207 let payload: QwenScenePayload = serde_json::from_value(value)?;
208 if !self.accept_empty && payload.lacks_indexable_content() {
236 return Err(JsonParseError::NoUsableFields);
237 }
238 Ok(payload.into_scene_analysis())
239 }
240}
241
242fn build_schema() -> Value {
243 json!({
244 "type": "object",
245 "properties": {
246 "scene": { "type": "string" },
247 "description": { "type": "string" },
248 "subjects": { "type": "array", "items": { "type": "string" } },
249 "objects": { "type": "array", "items": { "type": "string" } },
250 "actions": { "type": "array", "items": { "type": "string" } },
251 "mood": { "type": "array", "items": { "type": "string" } },
252 "shot_type": { "type": "string" },
253 "lighting": { "type": "array", "items": { "type": "string" } },
254 "tags": { "type": "array", "items": { "type": "string" } }
255 },
256 "required": REQUIRED_FIELDS,
257 "additionalProperties": false
258 })
259}
260
261fn missing_required_fields(object: &serde_json::Map<String, Value>) -> Vec<&'static str> {
274 REQUIRED_FIELDS
275 .iter()
276 .copied()
277 .filter(|field| match object.get(*field) {
278 None => true,
279 Some(value) => value.is_null(),
280 })
281 .collect()
282}
283
284#[derive(Debug, Default, Deserialize)]
285#[serde(deny_unknown_fields)]
286struct QwenScenePayload {
287 #[serde(default, deserialize_with = "deserialize_optional_trimmed_string")]
288 scene: Option<String>,
289 #[serde(default, deserialize_with = "deserialize_optional_trimmed_string")]
290 description: Option<String>,
291 #[serde(default)]
292 subjects: DetectionLabels,
293 #[serde(default)]
294 objects: DetectionLabels,
295 #[serde(default)]
296 actions: DetectionLabels,
297 #[serde(default)]
298 mood: DetectionLabels,
299 #[serde(default, deserialize_with = "deserialize_optional_single_label")]
300 shot_type: Option<String>,
301 #[serde(default)]
302 lighting: DetectionLabels,
303 #[serde(default)]
304 tags: TagList,
305}
306
307impl QwenScenePayload {
308 fn lacks_indexable_content(&self) -> bool {
339 let has_prose_and_keywords = self.description.is_some() && !self.tags.0.is_empty();
343 let has_substantive_detection =
344 !self.subjects.0.is_empty() || !self.objects.0.is_empty() || !self.actions.0.is_empty();
345 !has_prose_and_keywords && !has_substantive_detection
346 }
347
348 fn into_scene_analysis(self) -> ImageAnalysis {
349 let to_labels =
354 |list: DetectionLabels| -> Vec<SmolStr> { list.0.into_iter().map(SmolStr::from).collect() };
355 ImageAnalysis::new()
356 .with_scene(self.scene.map(SmolStr::from).unwrap_or_default())
357 .with_description(self.description.map(SmolStr::from).unwrap_or_default())
358 .with_subjects(to_labels(self.subjects))
359 .with_objects(to_labels(self.objects))
360 .with_actions(to_labels(self.actions))
361 .with_mood(to_labels(self.mood))
362 .with_shot_type(self.shot_type.map(SmolStr::from).unwrap_or_default())
363 .with_lighting(to_labels(self.lighting))
364 .with_tags(self.tags.0.into_iter().map(SmolStr::from).collect())
365 }
366}
367
368#[derive(Debug, Default)]
373struct TagList(Vec<String>);
374
375impl<'de> Deserialize<'de> for TagList {
376 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
377 where
378 D: serde::Deserializer<'de>,
379 {
380 #[derive(Deserialize)]
381 #[serde(untagged)]
382 enum Repr {
383 String(String),
384 List(Vec<String>),
385 }
386
387 let raw = Option::<Repr>::deserialize(deserializer)?;
388 let mut values = Vec::new();
389 match raw {
390 Some(Repr::String(value)) => push_string_list_items(&mut values, &value),
394 Some(Repr::List(items)) => {
397 for item in items {
398 push_array_item(&mut values, item);
399 }
400 }
401 None => {}
402 }
403 Ok(Self(values))
404 }
405}
406
407#[derive(Debug, Default)]
423struct DetectionLabels(Vec<String>);
424
425impl<'de> Deserialize<'de> for DetectionLabels {
426 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
427 where
428 D: serde::Deserializer<'de>,
429 {
430 #[derive(Deserialize)]
431 #[serde(untagged)]
432 enum Repr {
433 String(String),
434 List(Vec<String>),
435 }
436
437 let raw = Option::<Repr>::deserialize(deserializer)?;
438 let mut values = Vec::new();
439 match raw {
440 Some(Repr::String(value)) => push_array_item(&mut values, value),
442 Some(Repr::List(items)) => {
443 for item in items {
444 push_array_item(&mut values, item);
445 }
446 }
447 None => {}
448 }
449 Ok(Self(values))
450 }
451}
452
453fn push_array_item(values: &mut Vec<String>, raw: String) {
454 let trimmed = raw.trim();
455 if !trimmed.is_empty() && !values.iter().any(|existing| existing == trimmed) {
456 values.push(trimmed.to_owned());
457 }
458}
459
460fn push_string_list_items(values: &mut Vec<String>, raw: &str) {
461 for part in raw.split([',', ';', '\n']) {
462 let part = part.trim();
463 if !part.is_empty() && !values.iter().any(|existing| existing == part) {
464 values.push(part.to_owned());
465 }
466 }
467}
468
469fn deserialize_optional_trimmed_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
470where
471 D: serde::Deserializer<'de>,
472{
473 Ok(Option::<String>::deserialize(deserializer)?.and_then(normalize_string))
474}
475
476fn deserialize_optional_single_label<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
477where
478 D: serde::Deserializer<'de>,
479{
480 #[derive(Deserialize)]
481 #[serde(untagged)]
482 enum Repr {
483 String(String),
484 List(Vec<String>),
485 }
486
487 match Option::<Repr>::deserialize(deserializer)? {
488 Some(Repr::String(value)) => Ok(normalize_string(value)),
489 Some(Repr::List(values)) => {
490 let mut normalized = values.into_iter().filter_map(normalize_string);
491 let first = normalized.next();
492 if normalized.next().is_some() {
493 return Err(serde::de::Error::custom(
494 "expected a single shot_type label, got multiple values",
495 ));
496 }
497 Ok(first)
498 }
499 None => Ok(None),
500 }
501}
502
503fn normalize_string(value: String) -> Option<String> {
504 let trimmed = value.trim();
505 (!trimmed.is_empty()).then(|| trimmed.to_owned())
506}
507
508#[cfg(test)]
509mod tests {
510 use smol_str::SmolStr;
511
512 use super::*;
513
514 #[test]
529 fn scene_prompt_does_not_enumerate_value_tokens() {
530 let prompt_lower = IMAGE_ANALYSIS_PROMPT.to_lowercase();
531 let banned_tokens = [
535 "stage performance",
536 "middle-aged man",
537 "golden retriever",
538 "birthday cake",
539 "vintage red sports car",
540 "cutting cake",
541 "taking photos",
542 "wide shot",
543 "close-up",
544 "medium shot",
545 "over-the-shoulder",
546 "celebratory",
547 "natural light",
548 "low light",
549 "backlit",
550 ];
551 for token in banned_tokens {
552 assert!(
553 !prompt_lower.contains(&token.to_lowercase()),
554 "IMAGE_ANALYSIS_PROMPT must not enumerate value token {token:?} \
555 (prompt-vocabulary tokens get \
556 -presence_penalty logit shift in deterministic mode); \
557 use descriptive format guidance (word counts, lowercase) \
558 instead of `e.g. \"...\"` examples"
559 );
560 }
561 }
562
563 #[test]
566 fn parse_valid_json() {
567 let json = r#"{"scene":"beach","description":"Sunset over the ocean","subjects":["person"],"objects":["sun"],"actions":["watching"],"mood":["calm"],"shot_type":"wide shot","lighting":["golden hour"],"tags":["sunset","ocean"]}"#;
568 let task = ImageAnalysisTask::new();
569 let result = task.parse(json).expect("parse should succeed");
570 assert_eq!(result.scene(), "beach");
571 assert_eq!(result.description(), "Sunset over the ocean");
572 assert_eq!(result.mood().len(), 1);
573 assert_eq!(result.subjects().len(), 1);
574 }
575
576 #[test]
577 fn reject_json_with_wrapper_text() {
578 let text =
579 "Here is the analysis:\n{\"scene\":\"office\",\"description\":\"People working\"}\nDone.";
580 let task = ImageAnalysisTask::new();
581 assert!(task.parse(text).is_err());
582 }
583
584 #[test]
585 fn reject_plain_text_output() {
586 let text = "A beautiful sunset over the ocean.";
587 let task = ImageAnalysisTask::new();
588 assert!(task.parse(text).is_err());
589 }
590
591 #[test]
592 fn parse_comma_separated_tag_string() {
593 let json = r#"{"scene":"stage performance","description":"A singer on stage","subjects":[],"objects":["microphone"],"actions":["singing"],"mood":["energetic"],"shot_type":"medium shot","lighting":["spotlight"],"tags":"concert, live music, spotlight"}"#;
594 let task = ImageAnalysisTask::new();
595 let result = task.parse(json).expect("parse should succeed");
596 assert_eq!(
597 result.tags(),
598 &[
599 SmolStr::from("concert"),
600 SmolStr::from("live music"),
601 SmolStr::from("spotlight"),
602 ][..]
603 );
604 }
605
606 #[test]
607 fn reject_empty_json_payload() {
608 let task = ImageAnalysisTask::new();
609 assert!(task.parse("{}").is_err());
610 }
611
612 #[test]
613 fn reject_unknown_json_fields() {
614 let json = r#"{"description":"A singer on stage","extra":"unexpected"}"#;
615 let task = ImageAnalysisTask::new();
616 assert!(task.parse(json).is_err());
617 }
618
619 #[test]
620 fn reject_missing_required_fields() {
621 let json = r#"{"description":"A singer on stage","tags":["concert"]}"#;
622 let task = ImageAnalysisTask::new();
623 assert!(task.parse(json).is_err());
624 }
625
626 #[test]
627 fn parse_array_form_subjects() {
628 let json_list = r#"{"scene":"x","description":"y","subjects":["a","b"],"objects":[],"actions":[],"mood":[],"shot_type":"x","lighting":[],"tags":["t"]}"#;
630 let task = ImageAnalysisTask::new();
631 let result = task.parse(json_list).expect("list-form parse");
632 assert_eq!(result.subjects().len(), 2);
633 assert_eq!(result.subjects()[0], "a");
634 assert_eq!(result.subjects()[1], "b");
635 }
636
637 #[test]
638 fn subjects_string_form_treated_as_single_label() {
639 let json = r#"{"scene":"x","description":"y","subjects":"middle-aged man, in red jacket","objects":[],"actions":[],"mood":[],"shot_type":"x","lighting":[],"tags":["t"]}"#;
649 let task = ImageAnalysisTask::new();
650 let result = task.parse(json).expect("string-form parse");
651 assert_eq!(
652 result.subjects().len(),
653 1,
654 "string-form must wrap as a single label, not comma-split"
655 );
656 assert_eq!(result.subjects()[0], "middle-aged man, in red jacket");
657 }
658
659 #[test]
678 fn reject_all_required_fields_empty_payload_by_default() {
679 let json = r#"{
680 "scene": "",
681 "description": "",
682 "subjects": [],
683 "objects": [],
684 "actions": [],
685 "mood": [],
686 "shot_type": "",
687 "lighting": [],
688 "tags": []
689 }"#;
690 let task = ImageAnalysisTask::new();
691 let err = task
692 .parse(json)
693 .expect_err("default ImageAnalysisTask must reject all-empty payload");
694 assert!(
695 matches!(err, JsonParseError::NoUsableFields),
696 "expected NoUsableFields, got {err:?}"
697 );
698 }
699
700 #[test]
701 fn accept_all_required_fields_empty_payload_when_opted_in() {
702 let json = r#"{
703 "scene": "",
704 "description": "",
705 "subjects": [],
706 "objects": [],
707 "actions": [],
708 "mood": [],
709 "shot_type": "",
710 "lighting": [],
711 "tags": []
712 }"#;
713 let task = ImageAnalysisTask::new().with_accept_empty(true);
714 let result = task
715 .parse(json)
716 .expect("opt-in must accept the all-empty payload");
717 assert!(result.scene().is_empty());
718 assert!(result.description().is_empty());
719 assert!(result.subjects().is_empty());
720 assert!(result.objects().is_empty());
721 assert!(result.actions().is_empty());
722 assert!(result.mood().is_empty());
723 assert!(result.shot_type().is_empty());
724 assert!(result.lighting().is_empty());
725 assert!(result.tags().is_empty());
726 }
727
728 #[test]
735 fn reject_tags_only_payload_by_default() {
736 let json = r#"{
737 "scene": "",
738 "description": "",
739 "subjects": [],
740 "objects": [],
741 "actions": [],
742 "mood": [],
743 "shot_type": "",
744 "lighting": [],
745 "tags": ["concert", "live music"]
746 }"#;
747 let task = ImageAnalysisTask::new();
748 let err = task
749 .parse(json)
750 .expect_err("default ImageAnalysisTask must reject tags-only payload");
751 assert!(
752 matches!(err, JsonParseError::NoUsableFields),
753 "expected NoUsableFields, got {err:?}"
754 );
755 }
756
757 #[test]
762 fn reject_scene_only_payload_by_default() {
763 let json = r#"{
764 "scene": "office",
765 "description": "",
766 "subjects": [],
767 "objects": [],
768 "actions": [],
769 "mood": [],
770 "shot_type": "",
771 "lighting": [],
772 "tags": []
773 }"#;
774 let task = ImageAnalysisTask::new();
775 let err = task
776 .parse(json)
777 .expect_err("default ImageAnalysisTask must reject scene-only payload");
778 assert!(
779 matches!(err, JsonParseError::NoUsableFields),
780 "expected NoUsableFields, got {err:?}"
781 );
782 }
783
784 #[test]
789 fn reject_description_only_payload_by_default() {
790 let json = r#"{
791 "scene": "",
792 "description": "People working in an office",
793 "subjects": [],
794 "objects": [],
795 "actions": [],
796 "mood": [],
797 "shot_type": "",
798 "lighting": [],
799 "tags": []
800 }"#;
801 let task = ImageAnalysisTask::new();
802 let err = task
803 .parse(json)
804 .expect_err("default ImageAnalysisTask must reject description-only payload");
805 assert!(
806 matches!(err, JsonParseError::NoUsableFields),
807 "expected NoUsableFields, got {err:?}"
808 );
809 }
810
811 #[test]
816 fn accept_minimal_indexable_payload() {
817 let json = r#"{
818 "scene": "",
819 "description": "Two people talking",
820 "subjects": [],
821 "objects": [],
822 "actions": [],
823 "mood": [],
824 "shot_type": "",
825 "lighting": [],
826 "tags": ["conversation"]
827 }"#;
828 let task = ImageAnalysisTask::new();
829 let result = task
830 .parse(json)
831 .expect("description+tags must clear the indexable threshold");
832 assert_eq!(result.description(), "Two people talking");
833 assert_eq!(result.tags(), &[SmolStr::from("conversation")][..]);
834 assert!(result.subjects().is_empty());
836 assert!(result.objects().is_empty());
837 assert!(result.scene().is_empty());
838 }
839
840 #[test]
848 fn accept_detection_rich_payload_with_empty_description_and_tags() {
849 let json = r#"{
850 "scene": "",
851 "description": "",
852 "subjects": ["middle-aged woman in red dress"],
853 "objects": ["wedding cake"],
854 "actions": ["cutting cake"],
855 "mood": [],
856 "shot_type": "",
857 "lighting": [],
858 "tags": []
859 }"#;
860 let task = ImageAnalysisTask::new();
861 let result = task.parse(json).expect(
862 "detection-rich payload must clear the indexable threshold via \
863 the detection-bucket path even when description+tags are empty",
864 );
865 assert_eq!(result.subjects().len(), 1);
866 assert_eq!(result.objects().len(), 1);
867 assert_eq!(result.actions().len(), 1);
868 assert!(result.description().is_empty());
869 assert!(result.tags().is_empty());
870 }
871
872 #[test]
880 fn accept_subjects_only_payload() {
881 let json = r#"{
882 "scene": "",
883 "description": "",
884 "subjects": ["a single subject label"],
885 "objects": [],
886 "actions": [],
887 "mood": [],
888 "shot_type": "",
889 "lighting": [],
890 "tags": []
891 }"#;
892 let task = ImageAnalysisTask::new();
893 let result = task
894 .parse(json)
895 .expect("subjects-only must clear the indexable threshold");
896 assert_eq!(result.subjects().len(), 1);
897 }
898
899 #[test]
900 fn accept_objects_only_payload() {
901 let json = r#"{
902 "scene": "",
903 "description": "",
904 "subjects": [],
905 "objects": ["a single object label"],
906 "actions": [],
907 "mood": [],
908 "shot_type": "",
909 "lighting": [],
910 "tags": []
911 }"#;
912 let task = ImageAnalysisTask::new();
913 let result = task
914 .parse(json)
915 .expect("objects-only must clear the indexable threshold");
916 assert_eq!(result.objects().len(), 1);
917 }
918
919 #[test]
920 fn accept_actions_only_payload() {
921 let json = r#"{
922 "scene": "",
923 "description": "",
924 "subjects": [],
925 "objects": [],
926 "actions": ["a single action label"],
927 "mood": [],
928 "shot_type": "",
929 "lighting": [],
930 "tags": []
931 }"#;
932 let task = ImageAnalysisTask::new();
933 let result = task
934 .parse(json)
935 .expect("actions-only must clear the indexable threshold");
936 assert_eq!(result.actions().len(), 1);
937 }
938
939 #[test]
947 fn reject_mood_only_payload_by_default() {
948 let json = r#"{
949 "scene": "",
950 "description": "",
951 "subjects": [],
952 "objects": [],
953 "actions": [],
954 "mood": ["calm"],
955 "shot_type": "",
956 "lighting": [],
957 "tags": []
958 }"#;
959 let task = ImageAnalysisTask::new();
960 let err = task
961 .parse(json)
962 .expect_err("default ImageAnalysisTask must reject mood-only payload");
963 assert!(
964 matches!(err, JsonParseError::NoUsableFields),
965 "expected NoUsableFields, got {err:?}"
966 );
967 }
968
969 #[test]
972 fn reject_lighting_only_payload_by_default() {
973 let json = r#"{
974 "scene": "",
975 "description": "",
976 "subjects": [],
977 "objects": [],
978 "actions": [],
979 "mood": [],
980 "shot_type": "",
981 "lighting": ["natural light"],
982 "tags": []
983 }"#;
984 let task = ImageAnalysisTask::new();
985 let err = task
986 .parse(json)
987 .expect_err("default ImageAnalysisTask must reject lighting-only payload");
988 assert!(
989 matches!(err, JsonParseError::NoUsableFields),
990 "expected NoUsableFields, got {err:?}"
991 );
992 }
993
994 #[test]
1002 fn reject_attribute_only_payload_by_default() {
1003 let json = r#"{
1004 "scene": "",
1005 "description": "",
1006 "subjects": [],
1007 "objects": [],
1008 "actions": [],
1009 "mood": ["tense"],
1010 "shot_type": "",
1011 "lighting": ["low light"],
1012 "tags": []
1013 }"#;
1014 let task = ImageAnalysisTask::new();
1015 let err = task
1016 .parse(json)
1017 .expect_err("style-attribute-only payload must reject regardless of bucket count");
1018 assert!(
1019 matches!(err, JsonParseError::NoUsableFields),
1020 "expected NoUsableFields, got {err:?}"
1021 );
1022 }
1023
1024 #[test]
1025 fn reject_null_required_array() {
1026 let json = r#"{
1037 "scene": "office",
1038 "description": "people working",
1039 "subjects": null,
1040 "objects": [],
1041 "actions": [],
1042 "mood": [],
1043 "shot_type": "wide",
1044 "lighting": [],
1045 "tags": ["work"]
1046 }"#;
1047 let task = ImageAnalysisTask::new();
1048 let err = task
1049 .parse(json)
1050 .expect_err("null required field must be rejected");
1051 match err {
1052 JsonParseError::MissingFields(fields) => {
1053 assert!(
1054 fields.contains(&"subjects"),
1055 "expected 'subjects' in MissingFields, got {fields:?}"
1056 );
1057 }
1058 other => panic!("expected MissingFields, got {other:?}"),
1059 }
1060 }
1061
1062 #[test]
1063 fn reject_null_required_string() {
1064 let json = r#"{
1069 "scene": null,
1070 "description": "people working",
1071 "subjects": ["person"],
1072 "objects": [],
1073 "actions": [],
1074 "mood": [],
1075 "shot_type": "wide",
1076 "lighting": [],
1077 "tags": ["work"]
1078 }"#;
1079 let task = ImageAnalysisTask::new();
1080 let err = task
1081 .parse(json)
1082 .expect_err("null required field must be rejected");
1083 match err {
1084 JsonParseError::MissingFields(fields) => {
1085 assert!(
1086 fields.contains(&"scene"),
1087 "expected 'scene' in MissingFields, got {fields:?}"
1088 );
1089 }
1090 other => panic!("expected MissingFields, got {other:?}"),
1091 }
1092 }
1093
1094 #[test]
1095 fn reject_multiple_null_required_fields() {
1096 let json = r#"{
1098 "scene": null,
1099 "description": null,
1100 "subjects": null,
1101 "objects": [],
1102 "actions": [],
1103 "mood": [],
1104 "shot_type": "wide",
1105 "lighting": [],
1106 "tags": ["work"]
1107 }"#;
1108 let task = ImageAnalysisTask::new();
1109 let err = task
1110 .parse(json)
1111 .expect_err("null required fields must be rejected");
1112 match err {
1113 JsonParseError::MissingFields(fields) => {
1114 assert!(fields.contains(&"scene"), "missing 'scene' in {fields:?}");
1115 assert!(
1116 fields.contains(&"description"),
1117 "missing 'description' in {fields:?}"
1118 );
1119 assert!(
1120 fields.contains(&"subjects"),
1121 "missing 'subjects' in {fields:?}"
1122 );
1123 }
1124 other => panic!("expected MissingFields, got {other:?}"),
1125 }
1126 }
1127
1128 #[test]
1129 fn array_elements_are_not_comma_split() {
1130 let json = r#"{
1139 "scene": "patriotic event",
1140 "description": "Flag display",
1141 "subjects": ["middle-aged man, in red jacket"],
1142 "objects": ["red, white, and blue flag", "birthday cake with candles, balloons"],
1143 "actions": ["waving"],
1144 "mood": ["festive"],
1145 "shot_type": "wide shot",
1146 "lighting": ["natural, dramatic backlight"],
1147 "tags": ["july 4, 2026"]
1148 }"#;
1149 let task = ImageAnalysisTask::new();
1150 let result = task.parse(json).expect("parse should succeed");
1151 assert_eq!(result.subjects().len(), 1);
1152 assert_eq!(result.subjects()[0], "middle-aged man, in red jacket");
1153 assert_eq!(result.objects().len(), 2);
1154 assert_eq!(result.objects()[0], "red, white, and blue flag");
1155 assert_eq!(result.objects()[1], "birthday cake with candles, balloons");
1156 assert_eq!(result.lighting().len(), 1);
1157 assert_eq!(result.lighting()[0], "natural, dramatic backlight");
1158 assert_eq!(result.tags().len(), 1);
1159 assert_eq!(result.tags()[0].as_str(), "july 4, 2026");
1160 }
1161
1162 #[test]
1163 fn parse_shot_type_list_form() {
1164 let json_one = r#"{"scene":"x","description":"y","subjects":[],"objects":[],"actions":[],"mood":[],"shot_type":["wide shot"],"lighting":[],"tags":["t"]}"#;
1167 let task = ImageAnalysisTask::new();
1168 let result = task.parse(json_one).expect("single-element list parse");
1169 assert_eq!(result.shot_type(), "wide shot");
1170
1171 let json_many = r#"{"scene":"x","description":"y","subjects":[],"objects":[],"actions":[],"mood":[],"shot_type":["wide","close-up"],"lighting":[],"tags":["t"]}"#;
1173 assert!(task.parse(json_many).is_err());
1174 }
1175}