ollama_vision/
captioner.rs1use crate::parser;
2use crate::types::{CaptionOptions, OllamaVisionConfig};
3use reqwest::Client;
4use serde_json::json;
5use std::path::Path;
6
7const DEFAULT_CAPTION_PROMPT: &str = r#"Describe this image in 1-2 sentences. Focus on the main subject, art style, composition, lighting, and mood. Be specific and concise. Do not start with "This image shows" or "The image depicts". Just describe what you see directly."#;
8
9pub async fn caption_image(
20 client: &Client,
21 config: &OllamaVisionConfig,
22 image_path: &Path,
23 options: &CaptionOptions,
24) -> Result<String, CaptionError> {
25 let image_b64 = read_image_base64(image_path)?;
26
27 let prompt = options.prompt.as_deref().unwrap_or(DEFAULT_CAPTION_PROMPT);
28
29 let body = json!({
30 "model": config.model,
31 "prompt": prompt,
32 "images": [image_b64],
33 "stream": false,
34 "options": config.options,
35 });
36
37 let url = format!("{}/api/generate", config.endpoint);
38 let resp = client
39 .post(&url)
40 .timeout(config.timeout)
41 .json(&body)
42 .send()
43 .await
44 .map_err(|e| CaptionError::Connection(config.endpoint.clone(), e.to_string()))?;
45
46 if !resp.status().is_success() {
47 let status = resp.status().as_u16();
48 let text = resp.text().await.unwrap_or_default();
49 return Err(CaptionError::OllamaError(status, text));
50 }
51
52 let json: serde_json::Value = resp
53 .json()
54 .await
55 .map_err(|e| CaptionError::InvalidResponse(e.to_string()))?;
56
57 let raw = json.get("response").and_then(|v| v.as_str()).unwrap_or("");
58
59 let caption = parser::strip_think_tags(raw).trim().to_string();
60
61 if caption.is_empty() {
62 return Err(CaptionError::EmptyCaption);
63 }
64
65 Ok(truncate_caption(&caption, options.max_caption_length))
66}
67
68pub async fn caption_image_base64(
70 client: &Client,
71 config: &OllamaVisionConfig,
72 image_b64: &str,
73 options: &CaptionOptions,
74) -> Result<String, CaptionError> {
75 let prompt = options.prompt.as_deref().unwrap_or(DEFAULT_CAPTION_PROMPT);
76
77 let body = json!({
78 "model": config.model,
79 "prompt": prompt,
80 "images": [image_b64],
81 "stream": false,
82 "options": config.options,
83 });
84
85 let url = format!("{}/api/generate", config.endpoint);
86 let resp = client
87 .post(&url)
88 .timeout(config.timeout)
89 .json(&body)
90 .send()
91 .await
92 .map_err(|e| CaptionError::Connection(config.endpoint.clone(), e.to_string()))?;
93
94 if !resp.status().is_success() {
95 let status = resp.status().as_u16();
96 let text = resp.text().await.unwrap_or_default();
97 return Err(CaptionError::OllamaError(status, text));
98 }
99
100 let json: serde_json::Value = resp
101 .json()
102 .await
103 .map_err(|e| CaptionError::InvalidResponse(e.to_string()))?;
104
105 let raw = json.get("response").and_then(|v| v.as_str()).unwrap_or("");
106
107 let caption = parser::strip_think_tags(raw).trim().to_string();
108
109 if caption.is_empty() {
110 return Err(CaptionError::EmptyCaption);
111 }
112
113 Ok(truncate_caption(&caption, options.max_caption_length))
114}
115
116fn truncate_caption(caption: &str, max_len: usize) -> String {
118 if caption.chars().count() <= max_len {
119 return caption.to_string();
120 }
121
122 let mut truncated = String::new();
123 for (idx, ch) in caption.chars().enumerate() {
124 if idx >= max_len {
125 break;
126 }
127 truncated.push(ch);
128 }
129
130 match truncated.rfind(' ') {
131 Some(pos) => truncated[..pos].to_string(),
132 None => truncated,
133 }
134}
135
136#[derive(Debug, thiserror::Error)]
138pub enum CaptionError {
139 #[error("Cannot connect to Ollama at {0}: {1}")]
140 Connection(String, String),
141
142 #[error("Ollama returned HTTP {0}: {1}")]
143 OllamaError(u16, String),
144
145 #[error("Invalid response from Ollama: {0}")]
146 InvalidResponse(String),
147
148 #[error("Failed to read image: {0}")]
149 ImageRead(String),
150
151 #[error("Ollama returned empty caption")]
152 EmptyCaption,
153}
154
155impl CaptionError {
156 pub fn kind(&self) -> &'static str {
158 match self {
159 Self::Connection(..) => "connection",
160 Self::OllamaError(..) => "ollama_error",
161 Self::InvalidResponse(_) => "invalid_response",
162 Self::ImageRead(_) => "image_read",
163 Self::EmptyCaption => "empty_caption",
164 }
165 }
166}
167
168fn read_image_base64(path: &Path) -> Result<String, CaptionError> {
169 let bytes = std::fs::read(path)
170 .map_err(|e| CaptionError::ImageRead(format!("{}: {}", path.display(), e)))?;
171 Ok(base64::Engine::encode(
172 &base64::engine::general_purpose::STANDARD,
173 &bytes,
174 ))
175}
176
177#[cfg(test)]
178mod tests {
179 use super::truncate_caption;
180
181 #[test]
182 fn truncate_caption_preserves_utf8_boundaries() {
183 assert_eq!(truncate_caption("hello world", 7), "hello");
184 assert_eq!(truncate_caption("こんにちは 世界", 5), "こんにちは");
185 assert_eq!(truncate_caption("emoji 😀 test", 8), "emoji 😀");
186 }
187}