Commit 93c1933342
Verified · cmc
Layout: unified · split
internal/format/exif.go added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | package format | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/json" | |
| 5 | "errors" | |
| 6 | "fmt" | |
| 7 | "math" | |
| 8 | "strconv" | |
| 9 | "strings" | |
| 10 | "time" | |
| 11 | ) | |
| 12 | ||
| 13 | type exifRecord struct { | |
| 14 | ImageWidth int | |
| 15 | ImageHeight int | |
| 16 | Orientation int | |
| 17 | DateTimeOriginal string | |
| 18 | CreateDate string | |
| 19 | Make string | |
| 20 | Model string | |
| 21 | LensModel string | |
| 22 | FNumber float64 | |
| 23 | ExposureTime float64 | |
| 24 | ISO int | |
| 25 | } | |
| 26 | ||
| 27 | // parseExif reads `exiftool -json -n` output for a single file. | |
| 28 | func parseExif(data []byte) (Meta, error) { | |
| 29 | var recs []exifRecord | |
| 30 | if err := json.Unmarshal(data, &recs); err != nil { | |
| 31 | return Meta{}, fmt.Errorf("exiftool output: %w", err) | |
| 32 | } | |
| 33 | if len(recs) != 1 { | |
| 34 | return Meta{}, fmt.Errorf("exiftool output: %d records, want 1", len(recs)) | |
| 35 | } | |
| 36 | e := recs[0] | |
| 37 | if e.ImageWidth == 0 || e.ImageHeight == 0 { | |
| 38 | return Meta{}, errors.New("exiftool output: no image dimensions") | |
| 39 | } | |
| 40 | m := Meta{Width: e.ImageWidth, Height: e.ImageHeight, Lens: e.LensModel, ISO: e.ISO} | |
| 41 | if e.Orientation >= 5 && e.Orientation <= 8 { | |
| 42 | m.Width, m.Height = m.Height, m.Width | |
| 43 | } | |
| 44 | m.Taken = exifTime(e.DateTimeOriginal) | |
| 45 | if m.Taken.IsZero() { | |
| 46 | m.Taken = exifTime(e.CreateDate) | |
| 47 | } | |
| 48 | m.Camera = camera(e.Make, e.Model) | |
| 49 | if e.FNumber > 0 { | |
| 50 | m.Aperture = "f/" + strconv.FormatFloat(e.FNumber, 'f', -1, 64) | |
| 51 | } | |
| 52 | m.Shutter = shutter(e.ExposureTime) | |
| 53 | return m, nil | |
| 54 | } | |
| 55 | ||
| 56 | // exifTime parses "YYYY:MM:DD HH:MM:SS", ignoring any trailing subseconds or zone. | |
| 57 | func exifTime(s string) time.Time { | |
| 58 | if len(s) < 19 { | |
| 59 | return time.Time{} | |
| 60 | } | |
| 61 | t, err := time.Parse("2006:01:02 15:04:05", s[:19]) | |
| 62 | if err != nil { | |
| 63 | return time.Time{} | |
| 64 | } | |
| 65 | return t | |
| 66 | } | |
| 67 | ||
| 68 | func camera(maker, model string) string { | |
| 69 | maker, model = strings.TrimSpace(maker), strings.TrimSpace(model) | |
| 70 | if maker == "" { | |
| 71 | return model | |
| 72 | } | |
| 73 | if model == "" { | |
| 74 | return maker | |
| 75 | } | |
| 76 | brand := strings.ToLower(strings.Fields(maker)[0]) | |
| 77 | if strings.Contains(strings.ToLower(model), brand) { | |
| 78 | return model | |
| 79 | } | |
| 80 | return maker + " " + model | |
| 81 | } | |
| 82 | ||
| 83 | func shutter(sec float64) string { | |
| 84 | switch { | |
| 85 | case sec <= 0: | |
| 86 | return "" | |
| 87 | case sec >= 1: | |
| 88 | return strconv.FormatFloat(sec, 'f', -1, 64) + "s" | |
| 89 | default: | |
| 90 | return "1/" + strconv.Itoa(int(math.Round(1/sec))) | |
| 91 | } | |
| 92 | } | |
internal/format/exif_test.go added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | package format | |
| 2 | ||
| 3 | import ( | |
| 4 | "testing" | |
| 5 | "time" | |
| 6 | ) | |
| 7 | ||
| 8 | func TestParseExifFull(t *testing.T) { | |
| 9 | data := []byte(`[{ | |
| 10 | "SourceFile": "a.jpg", | |
| 11 | "ImageWidth": 6240, "ImageHeight": 4160, "Orientation": 1, | |
| 12 | "DateTimeOriginal": "2025:03:14 23:10:05", | |
| 13 | "Make": "FUJIFILM", "Model": "X-T5", "LensModel": "XF23mmF2 R WR", | |
| 14 | "FNumber": 2, "ExposureTime": 0.008, "ISO": 1600 | |
| 15 | }]`) | |
| 16 | m, err := parseExif(data) | |
| 17 | if err != nil { | |
| 18 | t.Fatal(err) | |
| 19 | } | |
| 20 | want := Meta{ | |
| 21 | Width: 6240, Height: 4160, | |
| 22 | Taken: time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC), | |
| 23 | Camera: "FUJIFILM X-T5", | |
| 24 | Lens: "XF23mmF2 R WR", | |
| 25 | Aperture: "f/2", | |
| 26 | Shutter: "1/125", | |
| 27 | ISO: 1600, | |
| 28 | } | |
| 29 | if m != want { | |
| 30 | t.Fatalf("got %+v\nwant %+v", m, want) | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | func TestParseExifRotatedSwapsDimensions(t *testing.T) { | |
| 35 | for _, o := range []string{"5", "6", "7", "8"} { | |
| 36 | m, err := parseExif([]byte(`[{"ImageWidth": 300, "ImageHeight": 200, "Orientation": ` + o + `}]`)) | |
| 37 | if err != nil { | |
| 38 | t.Fatal(err) | |
| 39 | } | |
| 40 | if m.Width != 200 || m.Height != 300 { | |
| 41 | t.Errorf("orientation %s: got %dx%d, want 200x300", o, m.Width, m.Height) | |
| 42 | } | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | func TestParseExifFallbacks(t *testing.T) { | |
| 47 | m, err := parseExif([]byte(`[{ | |
| 48 | "ImageWidth": 10, "ImageHeight": 10, | |
| 49 | "CreateDate": "2024:01:02 03:04:05+01:00", | |
| 50 | "Make": "Canon", "Model": "Canon EOS R6", | |
| 51 | "FNumber": 5.6, "ExposureTime": 2 | |
| 52 | }]`)) | |
| 53 | if err != nil { | |
| 54 | t.Fatal(err) | |
| 55 | } | |
| 56 | if want := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC); !m.Taken.Equal(want) { | |
| 57 | t.Errorf("Taken = %v, want %v", m.Taken, want) | |
| 58 | } | |
| 59 | if m.Camera != "Canon EOS R6" { | |
| 60 | t.Errorf("Camera = %q", m.Camera) | |
| 61 | } | |
| 62 | if m.Aperture != "f/5.6" || m.Shutter != "2s" { | |
| 63 | t.Errorf("Aperture %q Shutter %q", m.Aperture, m.Shutter) | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | func TestParseExifMinimal(t *testing.T) { | |
| 68 | m, err := parseExif([]byte(`[{"ImageWidth": 120, "ImageHeight": 80}]`)) | |
| 69 | if err != nil { | |
| 70 | t.Fatal(err) | |
| 71 | } | |
| 72 | if (m != Meta{Width: 120, Height: 80}) { | |
| 73 | t.Fatalf("got %+v", m) | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | func TestParseExifErrors(t *testing.T) { | |
| 78 | for _, in := range []string{`not json`, `[]`, `[{}, {}]`, `[{"ImageWidth": 10}]`} { | |
| 79 | if _, err := parseExif([]byte(in)); err == nil { | |
| 80 | t.Errorf("%s: want error", in) | |
| 81 | } | |
| 82 | } | |
| 83 | } | |