Skip to main content

smriti/services/
camera_names.rs

1//! Friendly camera-name resolution.
2//!
3//! EXIF Make/Model tags often hold internal codenames (`M2007J20CI`,
4//! `ILCE-7M3`, `iPhone15,2`) — useless to a human reading the photo
5//! info panel. This module turns them into the marketing names users
6//! actually recognise.
7//!
8//! Strategy, in priority order:
9//! 1. **Pattern-based transforms** for prefixes we can decode without
10//!    a lookup (Sony ILCE → α-series, Canon EOS → keep, Apple iPhone
11//!    DeviceCode → marketing name from a small static map).
12//! 2. **Curated codename table** for the long tail of Android codenames
13//!    (Xiaomi, OnePlus, Realme, etc.) where the EXIF Model is an
14//!    unreadable internal ID.
15//! 3. **Optional CSV lookup** at `data/device_codenames.csv`, loaded
16//!    lazily once. Lets users (or `setup_assets.sh`) drop in a richer
17//!    list — same shape as Google Play's public supported-devices CSV
18//!    (`retail_branding,marketing_name,device,model`) — without us
19//!    bundling a 700 KB file.
20//! 4. **Smart fallback**: combine `make` + `model`, dedup if model
21//!    already starts with the make, title-case where it looks like a
22//!    codename, leave alone where it doesn't.
23//!
24//! Everything is pure-Rust, offline, and never makes a network call.
25
26use std::sync::OnceLock;
27
28/// Resolve `make` + `model` (raw EXIF) into a friendly display name.
29/// Returns `None` only when both are empty.
30pub fn friendly_camera_name(make: Option<&str>, model: Option<&str>) -> Option<String> {
31    let make_clean = make.and_then(clean_component);
32    let model_clean = model.and_then(clean_component);
33    let make = make_clean.as_deref();
34    let model = model_clean.as_deref();
35
36    if make.is_none() && model.is_none() {
37        return None;
38    }
39
40    // Try pattern-based transforms first — they're free and cover
41    // nearly every consumer camera.
42    if let Some(name) = pattern_transform(make, model) {
43        return Some(name);
44    }
45
46    // Then curated codename table.
47    if let Some(model) = model {
48        if let Some(name) = lookup_curated(model) {
49            return Some(name.to_string());
50        }
51        // Optional CSV — only loaded once, on first miss.
52        if let Some(name) = lookup_csv(model) {
53            return Some(name);
54        }
55    }
56
57    // Smart fallback.
58    Some(combine_make_model(make, model))
59}
60
61fn clean_component(raw: &str) -> Option<String> {
62    let parts: Vec<&str> = if raw.contains("\",") || raw.contains(", \"") {
63        raw.split(',').collect()
64    } else {
65        vec![raw]
66    };
67    for part in parts {
68        let clean = part
69            .replace('\0', "")
70            .trim()
71            .trim_matches('"')
72            .trim()
73            .to_string();
74        if !clean.is_empty() {
75            return Some(clean);
76        }
77    }
78    None
79}
80
81fn pattern_transform(make: Option<&str>, model: Option<&str>) -> Option<String> {
82    let model = model?;
83
84    // Apple iPhone DeviceCode (e.g. "iPhone15,2") → marketing name.
85    if let Some(name) = iphone_marketing_name(model) {
86        return Some(name.to_string());
87    }
88
89    // Sony ILCE-7M3 → "Sony α7 III"; ILCE-7RM4 → "Sony α7R IV"; ILCE-9M2 → "Sony α9 II".
90    if let Some(rest) = model.strip_prefix("ILCE-") {
91        if let Some(pretty) = sony_alpha_pretty(rest) {
92            return Some(format!("Sony α{}", pretty));
93        }
94    }
95    // Sony DSC-RX100M7 → "Sony RX100 VII"
96    if let Some(rest) = model.strip_prefix("DSC-") {
97        if let Some(pretty) = sony_rx_pretty(rest) {
98            return Some(format!("Sony {}", pretty));
99        }
100        return Some(format!("Sony {}", rest));
101    }
102
103    // Canon EOS already friendly — just prepend "Canon" if missing.
104    if model.starts_with("EOS ") || model.starts_with("EOS-") || model.starts_with("Canon EOS") {
105        if model.starts_with("Canon ") {
106            return Some(model.to_string());
107        }
108        return Some(format!("Canon {}", model));
109    }
110
111    // Nikon Z / D / Coolpix already friendly.
112    let make_lower = make.unwrap_or("").to_ascii_lowercase();
113    if make_lower.contains("nikon") {
114        if model.starts_with("Nikon ") {
115            return Some(model.to_string());
116        }
117        if model.starts_with('Z')
118            || model.starts_with('D')
119            || model.to_ascii_lowercase().starts_with("coolpix")
120        {
121            return Some(format!("Nikon {}", model));
122        }
123    }
124
125    // Fujifilm X-T4 etc. already friendly.
126    if (make_lower.contains("fujifilm") || make_lower.contains("fuji"))
127        && (model.starts_with("X-")
128            || model.starts_with("GFX")
129            || model.starts_with("FinePix")
130            || model.starts_with("Fujifilm"))
131    {
132        return Some(format!(
133            "Fujifilm {}",
134            model.trim_start_matches("Fujifilm ")
135        ));
136    }
137
138    // Pixel: "Pixel 7 Pro", already friendly. Prepend Google.
139    if model.starts_with("Pixel ") {
140        return Some(format!("Google {}", model));
141    }
142
143    None
144}
145
146/// Sony ILCE codename → α-series suffix. e.g. "7M3" → "7 III",
147/// "7RM4" → "7R IV", "6400" → "6400", "9M2" → "9 II".
148fn sony_alpha_pretty(suffix: &str) -> Option<&'static str> {
149    Some(match suffix {
150        "7" => "7",
151        "7M2" => "7 II",
152        "7M3" => "7 III",
153        "7M4" => "7 IV",
154        "7M5" => "7 V",
155        "7R" => "7R",
156        "7RM2" => "7R II",
157        "7RM3" => "7R III",
158        "7RM4" => "7R IV",
159        "7RM5" => "7R V",
160        "7S" => "7S",
161        "7SM2" => "7S II",
162        "7SM3" => "7S III",
163        "7C" => "7C",
164        "7CM2" => "7C II",
165        "9" => "9",
166        "9M2" => "9 II",
167        "9M3" => "9 III",
168        "1" => "1",
169        "5100" => "5100",
170        "6000" => "6000",
171        "6100" => "6100",
172        "6300" => "6300",
173        "6400" => "6400",
174        "6500" => "6500",
175        "6600" => "6600",
176        "6700" => "6700",
177        _ => return None,
178    })
179}
180
181fn sony_rx_pretty(suffix: &str) -> Option<&'static str> {
182    Some(match suffix {
183        "RX100" => "RX100",
184        "RX100M2" => "RX100 II",
185        "RX100M3" => "RX100 III",
186        "RX100M4" => "RX100 IV",
187        "RX100M5" => "RX100 V",
188        "RX100M5A" => "RX100 VA",
189        "RX100M6" => "RX100 VI",
190        "RX100M7" => "RX100 VII",
191        "RX10" => "RX10",
192        "RX10M2" => "RX10 II",
193        "RX10M3" => "RX10 III",
194        "RX10M4" => "RX10 IV",
195        "RX1" => "RX1",
196        "RX1RM2" => "RX1R II",
197        _ => return None,
198    })
199}
200
201/// Translate Apple's `iPhoneN,M` device identifier into the marketing name.
202fn iphone_marketing_name(model: &str) -> Option<&'static str> {
203    Some(match model {
204        // iPhone (1st)
205        "iPhone1,1" => "iPhone",
206        "iPhone1,2" => "iPhone 3G",
207        "iPhone2,1" => "iPhone 3GS",
208        "iPhone3,1" | "iPhone3,2" | "iPhone3,3" => "iPhone 4",
209        "iPhone4,1" => "iPhone 4S",
210        "iPhone5,1" | "iPhone5,2" => "iPhone 5",
211        "iPhone5,3" | "iPhone5,4" => "iPhone 5c",
212        "iPhone6,1" | "iPhone6,2" => "iPhone 5s",
213        "iPhone7,1" => "iPhone 6 Plus",
214        "iPhone7,2" => "iPhone 6",
215        "iPhone8,1" => "iPhone 6s",
216        "iPhone8,2" => "iPhone 6s Plus",
217        "iPhone8,4" => "iPhone SE",
218        "iPhone9,1" | "iPhone9,3" => "iPhone 7",
219        "iPhone9,2" | "iPhone9,4" => "iPhone 7 Plus",
220        "iPhone10,1" | "iPhone10,4" => "iPhone 8",
221        "iPhone10,2" | "iPhone10,5" => "iPhone 8 Plus",
222        "iPhone10,3" | "iPhone10,6" => "iPhone X",
223        "iPhone11,2" => "iPhone XS",
224        "iPhone11,4" | "iPhone11,6" => "iPhone XS Max",
225        "iPhone11,8" => "iPhone XR",
226        "iPhone12,1" => "iPhone 11",
227        "iPhone12,3" => "iPhone 11 Pro",
228        "iPhone12,5" => "iPhone 11 Pro Max",
229        "iPhone12,8" => "iPhone SE (2nd gen)",
230        "iPhone13,1" => "iPhone 12 mini",
231        "iPhone13,2" => "iPhone 12",
232        "iPhone13,3" => "iPhone 12 Pro",
233        "iPhone13,4" => "iPhone 12 Pro Max",
234        "iPhone14,2" => "iPhone 13 Pro",
235        "iPhone14,3" => "iPhone 13 Pro Max",
236        "iPhone14,4" => "iPhone 13 mini",
237        "iPhone14,5" => "iPhone 13",
238        "iPhone14,6" => "iPhone SE (3rd gen)",
239        "iPhone14,7" => "iPhone 14",
240        "iPhone14,8" => "iPhone 14 Plus",
241        "iPhone15,2" => "iPhone 14 Pro",
242        "iPhone15,3" => "iPhone 14 Pro Max",
243        "iPhone15,4" => "iPhone 15",
244        "iPhone15,5" => "iPhone 15 Plus",
245        "iPhone16,1" => "iPhone 15 Pro",
246        "iPhone16,2" => "iPhone 15 Pro Max",
247        "iPhone17,1" => "iPhone 16 Pro",
248        "iPhone17,2" => "iPhone 16 Pro Max",
249        "iPhone17,3" => "iPhone 16",
250        "iPhone17,4" => "iPhone 16 Plus",
251        _ => return None,
252    })
253}
254
255/// Curated codenames that aren't already mapped by patterns.
256/// Covers the most common Xiaomi / Realme / OnePlus / Oppo / Samsung
257/// internal IDs photographers see in their EXIF.
258fn lookup_curated(model: &str) -> Option<&'static str> {
259    // Case-insensitive match to forgive `m2007j20ci` / `M2007J20CI`.
260    let upper = model.to_ascii_uppercase();
261    Some(match upper.as_str() {
262        // Xiaomi / Poco / Redmi (most common offenders)
263        "M2007J20CI" | "M2007J20CG" | "M2007J20CT" => "Xiaomi Poco X3 NFC",
264        "M2007J17G" | "M2007J17I" => "Xiaomi Mi 10T Lite",
265        "M2007J3SY" | "M2007J3SI" => "Xiaomi Mi 10T Pro",
266        "M2007J3SG" => "Xiaomi Mi 10T",
267        "M2102J20SG" | "M2102J20SI" => "Xiaomi Poco X3 Pro",
268        "M2102J2SG" | "M2102J2SI" => "Xiaomi Mi 11i",
269        "M2102K1G" | "M2102K1AC" => "Xiaomi Mi 11",
270        "M2103K19PG" | "M2103K19PI" => "Xiaomi Redmi Note 10",
271        "M2101K6G" | "M2101K6P" | "M2101K6R" => "Xiaomi Redmi Note 10 Pro",
272        "M2010J19SG" | "M2010J19SI" | "M2010J19CI" => "Xiaomi Redmi 9T",
273        "21091116UG" | "21091116UI" => "Xiaomi Redmi Note 11 Pro",
274        "2201116TG" | "2201116TI" => "Xiaomi Redmi Note 11",
275        "2201117TG" | "2201117TI" => "Xiaomi Redmi Note 11S",
276        "2203121C" | "2203129G" => "Xiaomi 12",
277        "2201123G" | "2201122G" => "Xiaomi 12 Pro",
278        "2210132C" | "2210132G" => "Xiaomi 13",
279        "2211133G" | "2211133C" => "Xiaomi 13 Pro",
280        "23049PCD8G" | "23049PCD8I" => "Xiaomi 13 Lite",
281        "23116PN5BC" | "23116PN5BG" => "Xiaomi 14",
282        "23117RA68G" | "23127PN0CG" => "Xiaomi 14 Pro",
283
284        // OnePlus
285        "GM1900" | "GM1903" | "GM1905" | "GM1911" | "GM1913" | "GM1915" | "GM1917" => "OnePlus 7",
286        "GM1910" | "GM1911A" | "GM1913A" | "GM1917A" => "OnePlus 7 Pro",
287        "HD1900" | "HD1901" | "HD1903" | "HD1905" | "HD1907" => "OnePlus 7T",
288        "HD1910" | "HD1911" | "HD1913" => "OnePlus 7T Pro",
289        "IN2010" | "IN2013" | "IN2015" | "IN2017" | "IN2019" => "OnePlus 8",
290        "IN2020" | "IN2023" | "IN2025" => "OnePlus 8 Pro",
291        "KB2000" | "KB2001" | "KB2003" | "KB2005" | "KB2007" => "OnePlus 8T",
292        "ONEPLUS A6010" | "ONEPLUS A6013" => "OnePlus 6T",
293        "LE2110" | "LE2111" | "LE2113" | "LE2115" | "LE2117" | "LE2119" | "LE2121" => "OnePlus 9",
294        "LE2120" | "LE2123" | "LE2125" | "LE2127" => "OnePlus 9 Pro",
295        "NE2210" | "NE2211" | "NE2213" | "NE2215" => "OnePlus 10 Pro",
296        "PHB110" | "PHB120" => "OnePlus 11",
297        "AC2001" | "AC2003" => "OnePlus Nord",
298        "DN2101" | "DN2103" => "OnePlus Nord 2",
299
300        // Realme
301        "RMX2202" | "RMX2200" => "Realme 7 Pro",
302        "RMX3081" | "RMX3085" => "Realme 8 Pro",
303        "RMX3151" => "Realme 8",
304        "RMX3370" | "RMX3371" => "Realme GT",
305        "RMX3711" | "RMX3712" => "Realme GT Neo 3",
306
307        // Oppo
308        "CPH2173" | "CPH2179" => "Oppo Find X3 Pro",
309        "PEXM00" => "Oppo Find X5 Pro",
310
311        // Samsung Galaxy (when EXIF carries the model code rather than name)
312        "SM-G991B" | "SM-G991U" | "SM-G991N" => "Samsung Galaxy S21",
313        "SM-G996B" | "SM-G996U" | "SM-G996N" => "Samsung Galaxy S21+",
314        "SM-G998B" | "SM-G998U" | "SM-G998N" => "Samsung Galaxy S21 Ultra",
315        "SM-S901B" | "SM-S901U" | "SM-S901N" => "Samsung Galaxy S22",
316        "SM-S906B" | "SM-S906U" | "SM-S906N" => "Samsung Galaxy S22+",
317        "SM-S908B" | "SM-S908U" | "SM-S908N" => "Samsung Galaxy S22 Ultra",
318        "SM-S911B" | "SM-S911U" | "SM-S911N" => "Samsung Galaxy S23",
319        "SM-S916B" | "SM-S916U" | "SM-S916N" => "Samsung Galaxy S23+",
320        "SM-S918B" | "SM-S918U" | "SM-S918N" => "Samsung Galaxy S23 Ultra",
321        "SM-S921B" | "SM-S921U" | "SM-S921N" => "Samsung Galaxy S24",
322        "SM-S926B" | "SM-S926U" | "SM-S926N" => "Samsung Galaxy S24+",
323        "SM-S928B" | "SM-S928U" | "SM-S928N" => "Samsung Galaxy S24 Ultra",
324
325        // Asus
326        "ASUS_AI2202" => "Asus ROG Phone 6",
327        _ => return None,
328    })
329}
330
331/// Optional CSV lookup. Only loaded on first miss; CSV must live at
332/// `data/device_codenames.csv` next to the binary or working dir.
333/// Same column shape as Google Play's public list:
334/// `retail_branding,marketing_name,device,model`.
335fn lookup_csv(model: &str) -> Option<String> {
336    static MAP: OnceLock<Option<std::collections::HashMap<String, String>>> = OnceLock::new();
337    let map = MAP.get_or_init(load_csv);
338    map.as_ref()?.get(&model.to_ascii_uppercase()).cloned()
339}
340
341fn load_csv() -> Option<std::collections::HashMap<String, String>> {
342    let candidates = [
343        std::path::PathBuf::from("data/device_codenames.csv"),
344        std::env::current_exe()
345            .ok()
346            .and_then(|p| p.parent().map(|d| d.join("data/device_codenames.csv")))
347            .unwrap_or_default(),
348    ];
349    for path in candidates.iter() {
350        if !path.is_file() {
351            continue;
352        }
353        let Ok(content) = std::fs::read_to_string(path) else {
354            continue;
355        };
356        let mut map = std::collections::HashMap::new();
357        for (i, line) in content.lines().enumerate() {
358            if i == 0 && line.to_ascii_lowercase().contains("retail_branding") {
359                continue; // header
360            }
361            let cells: Vec<&str> = line.split(',').collect();
362            if cells.len() < 4 {
363                continue;
364            }
365            let brand = cells[0].trim();
366            let marketing = cells[1].trim();
367            let model_code = cells[3].trim();
368            if model_code.is_empty() || marketing.is_empty() {
369                continue;
370            }
371            let display = if brand.is_empty()
372                || marketing
373                    .to_ascii_lowercase()
374                    .starts_with(&brand.to_ascii_lowercase())
375            {
376                marketing.to_string()
377            } else {
378                format!("{} {}", brand, marketing)
379            };
380            map.insert(model_code.to_ascii_uppercase(), display);
381        }
382        return Some(map);
383    }
384    Some(std::collections::HashMap::new())
385}
386
387/// Last-resort: `make` and `model` joined, with cleanup.
388fn combine_make_model(make: Option<&str>, model: Option<&str>) -> String {
389    match (make, model) {
390        (Some(make), Some(model)) => {
391            // If model already starts with the make ("Canon Canon EOS R5"),
392            // drop the duplicate.
393            let model_lower = model.to_ascii_lowercase();
394            let make_lower = make.to_ascii_lowercase();
395            if model_lower.starts_with(&make_lower) {
396                pretty_case(model)
397            } else {
398                format!("{} {}", make, pretty_case(model))
399            }
400        }
401        (None, Some(model)) => pretty_case(model),
402        (Some(make), None) => make.to_string(),
403        (None, None) => String::new(),
404    }
405}
406
407/// Title-case suspicious-looking codename strings; pass through anything
408/// that already looks like prose (mixed case + spaces).
409fn pretty_case(model: &str) -> String {
410    let has_space = model.contains(' ');
411    let all_upper = model
412        .chars()
413        .all(|c| !c.is_alphabetic() || c.is_uppercase());
414    let all_lower = model
415        .chars()
416        .all(|c| !c.is_alphabetic() || c.is_lowercase());
417
418    // Prose-y string already (mixed case + spaces) — keep it.
419    if has_space && !all_upper && !all_lower {
420        return model.to_string();
421    }
422
423    // Pure-codename: leave it but uppercase, since these are usually
424    // marketing-side IDs (e.g. "ILCE-7M3" was already handled; we
425    // only fall through here for ones not in the curated tables).
426    if !has_space {
427        return model.to_uppercase();
428    }
429
430    // Mixed: title-case each word.
431    model
432        .split_whitespace()
433        .map(|w| {
434            let mut chars = w.chars();
435            match chars.next() {
436                Some(c) => c.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
437                None => String::new(),
438            }
439        })
440        .collect::<Vec<_>>()
441        .join(" ")
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn xiaomi_codename() {
450        assert_eq!(
451            friendly_camera_name(Some("Xiaomi"), Some("M2007J20CI")).unwrap(),
452            "Xiaomi Poco X3 NFC"
453        );
454        // case-insensitive
455        assert_eq!(
456            friendly_camera_name(Some("Xiaomi"), Some("m2007j20ci")).unwrap(),
457            "Xiaomi Poco X3 NFC"
458        );
459    }
460
461    #[test]
462    fn sony_alpha() {
463        assert_eq!(
464            friendly_camera_name(Some("SONY"), Some("ILCE-7M3")).unwrap(),
465            "Sony α7 III"
466        );
467        assert_eq!(
468            friendly_camera_name(Some("SONY"), Some("ILCE-7RM4")).unwrap(),
469            "Sony α7R IV"
470        );
471        assert_eq!(
472            friendly_camera_name(Some("SONY"), Some("ILCE-6400")).unwrap(),
473            "Sony α6400"
474        );
475    }
476
477    #[test]
478    fn iphone_devicecode() {
479        assert_eq!(
480            friendly_camera_name(Some("Apple"), Some("iPhone15,2")).unwrap(),
481            "iPhone 14 Pro"
482        );
483        assert_eq!(
484            friendly_camera_name(Some("Apple"), Some("iPhone16,2")).unwrap(),
485            "iPhone 15 Pro Max"
486        );
487    }
488
489    #[test]
490    fn canon_eos_passes_through() {
491        assert_eq!(
492            friendly_camera_name(Some("Canon"), Some("EOS R5")).unwrap(),
493            "Canon EOS R5"
494        );
495    }
496
497    #[test]
498    fn fallback_dedup() {
499        // Make appears in model — don't duplicate.
500        assert_eq!(
501            friendly_camera_name(Some("Canon"), Some("Canon PowerShot S110")).unwrap(),
502            "Canon PowerShot S110"
503        );
504    }
505
506    #[test]
507    fn unknown_codename_uppercase() {
508        // Not in any table — uppercase the codename so it at least
509        // looks consistent.
510        assert_eq!(
511            friendly_camera_name(Some("UnknownMaker"), Some("xyz123abc")).unwrap(),
512            "UnknownMaker XYZ123ABC"
513        );
514    }
515
516    #[test]
517    fn comma_quoted_empty_exif_noise_is_ignored() {
518        assert_eq!(
519            friendly_camera_name(
520                Some(r#""OnePlus", "", "", "", """#),
521                Some(r#""ONEPLUS A6010", "", """#)
522            )
523            .unwrap(),
524            "OnePlus 6T"
525        );
526    }
527
528    #[test]
529    fn empty_inputs() {
530        assert!(friendly_camera_name(None, None).is_none());
531        assert!(friendly_camera_name(Some(""), Some("")).is_none());
532    }
533}