Commit 93c1933342

93c1933342e5172fdd36366a4a1691fa4daf94f6

parent: ec5a5f9093

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-25 23:18 UTC

Parse exiftool metadata

Layout: unified · split

internal/format/exif.go added +92
@@ -0,0 +1,92 @@
1package format
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "math"
8 "strconv"
9 "strings"
10 "time"
11)
12
13type 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.
28func 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.
57func 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
68func 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
83func 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 @@
1package format
2
3import (
4 "testing"
5 "time"
6)
7
8func 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
34func 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
46func 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
67func 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
77func 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}