Files
ds-galo/backend/internal/photos/pipeline.go
T
iu e1592549b2 feat(photos): full EXIF metadata extraction, backfill and info panel
- backend: switch EXIF parser to dsoprea/go-exif/v3 (all recognised tags, numeric normalization); store in photos.exif jsonb (migration 000003); add storage.Get and cmd/backfill-exif to backfill existing photos from S3 originals
- frontend: info panel renders all EXIF attributes with RU labels, enum translation and units (1/N, f/N, mm, EV); service tags hidden
- viewer: remove make-cover action (cover managed from panel only), icon-only download
- openspec: sync lightroom-workspace-ui artifacts (EXIF, cover-from-panel) and add plan for configurable info attributes/labels stored in user profile
2026-10-10 20:31:34 +03:00

141 lines
3.2 KiB
Go

package photos
import (
"bytes"
"fmt"
"image"
"strconv"
"strings"
"time"
exif "github.com/dsoprea/go-exif/v3"
exifcommon "github.com/dsoprea/go-exif/v3/common"
"github.com/disintegration/imaging"
"github.com/gen2brain/webp"
)
type ImageMeta struct {
Width int
Height int
MimeType string
TakenAt *time.Time
Latitude *float64
Longitude *float64
Thumb []byte
Preview []byte
}
func inspect(data []byte) (width, height int, ok bool) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return 0, 0, false
}
return cfg.Width, cfg.Height, true
}
type exifInfo struct {
TakenAt *time.Time
Latitude *float64
Longitude *float64
Attributes map[string]any
}
func collectTags(ifd *exif.Ifd, dst map[string]any) {
if ifd == nil {
return
}
for _, ite := range ifd.Entries() {
name := ite.TagName()
if name == "" || strings.HasPrefix(name, "UnknownTag") {
continue
}
if _, exists := dst[name]; exists {
continue
}
s, err := ite.FormatFirst()
if err != nil {
continue
}
s = strings.TrimSpace(strings.TrimRight(s, "\x00"))
if s == "" {
continue
}
dst[name] = normalizeExifValue(s)
}
for _, child := range ifd.Children() {
collectTags(child, dst)
}
}
func normalizeExifValue(s string) any {
base := strings.TrimSuffix(s, "...")
if i := strings.IndexByte(base, '/'); i > 0 {
num, err1 := strconv.ParseInt(strings.TrimSpace(base[:i]), 10, 64)
den, err2 := strconv.ParseInt(strings.TrimSpace(base[i+1:]), 10, 64)
if err1 == nil && err2 == nil && den != 0 {
return float64(num) / float64(den)
}
}
if n, err := strconv.ParseInt(base, 10, 64); err == nil {
return n
}
if f, err := strconv.ParseFloat(base, 64); err == nil {
return f
}
return s
}
func extractEXIF(data []byte) exifInfo {
out := exifInfo{Attributes: map[string]any{}}
raw, err := exif.SearchAndExtractExif(data)
if err != nil {
return out
}
im, err := exifcommon.NewIfdMappingWithStandard()
if err != nil {
return out
}
ti := exif.NewTagIndex()
_, index, err := exif.Collect(im, ti, raw)
if err != nil || index.RootIfd == nil {
return out
}
collectTags(index.RootIfd, out.Attributes)
if v, ok := out.Attributes["DateTimeOriginal"]; ok {
if t, err := time.Parse("2006:01:02 15:04:05", fmt.Sprintf("%v", v)); err == nil {
utc := t.UTC()
out.TakenAt = &utc
}
}
if gi, err := index.RootIfd.GpsInfo(); err == nil {
lat := gi.Latitude.Decimal()
lon := gi.Longitude.Decimal()
out.Latitude, out.Longitude = &lat, &lon
}
return out
}
func ExtractEXIFAttributes(data []byte) map[string]any {
return extractEXIF(data).Attributes
}
func buildVariants(data []byte) (thumb, preview []byte, err error) {
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil {
return nil, nil, err
}
thumbImg := imaging.Fit(img, 200, 200, imaging.Lanczos)
previewImg := imaging.Fit(img, 1200, 1200, imaging.Lanczos)
opts := webp.Options{Quality: 82}
var thumbBuf bytes.Buffer
if err := webp.Encode(&thumbBuf, thumbImg, opts); err != nil {
return nil, nil, err
}
var previewBuf bytes.Buffer
if err := webp.Encode(&previewBuf, previewImg, opts); err != nil {
return nil, nil, err
}
return thumbBuf.Bytes(), previewBuf.Bytes(), nil
}