Index and show video metadata #17

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-05-14 11:57:26 +02:00
parent 22bd546c70
commit 2045e3d770
21 changed files with 488 additions and 47 deletions

View file

@ -1,4 +1,53 @@
const Nanosecond = 1
const Microsecond = 1000 * Nanosecond
const Millisecond = 1000 * Microsecond
const Second = 1000 * Millisecond
const Minute = 60 * Second
const Hour = 60 * Minute
export default class Util {
static duration(d) {
let u = d;
let neg = d < 0;
if (neg) {
u = -u
}
if (u < Second) {
// Special case: if duration is smaller than a second,
// use smaller units, like 1.2ms
if (!u) {
return "0s";
}
if (u < Microsecond) {
return u + "ns";
}
if (u < Millisecond) {
return Math.round(u / Microsecond) + "µs";
}
return Math.round(u / Millisecond) + "ms";
}
let result = []
let h = Math.floor(u / Hour)
let min = Math.floor(u / Minute)%60
let sec = Math.ceil(u / Second)%60
result.push(h.toString().padStart(2, '0'))
result.push(min.toString().padStart(2, '0'))
result.push(sec.toString().padStart(2, '0'))
// return `${h}h${min}m${sec}s`
return result.join(":");
}
static arabicToRoman(number) {
let roman = "";
const romanNumList = {
@ -34,7 +83,7 @@ export default class Util {
return roman;
}
static truncate (str, length, ending) {
static truncate(str, length, ending) {
if (length == null) {
length = 100;
}

View file

@ -94,7 +94,11 @@
{{ photo.getDateString() }}
</button>
<br/>
<button @click.exact="editPhoto(index)" title="Camera">
<button v-if="photo.PhotoVideo" @click.exact="openPhoto(index, true)" title="Video">
<v-icon size="14">movie</v-icon>
{{ photo.getVideoInfo() }}
</button>
<button v-else @click.exact="editPhoto(index)" title="Camera">
<v-icon size="14">photo_camera</v-icon>
{{ photo.getCamera() }}
</button>
@ -105,13 +109,13 @@
{{ photo.getLocation() }}
</button>
</template>
<template v-if="debug">
<!-- template v-if="debug">
<br/>
<button @click.exact="openUUID(index)" title="Unique ID">
<v-icon size="14">fingerprint</v-icon>
{{ photo.PhotoUUID }}
</button>
</template>
</template -->
</div>
</div>
</v-card-title>

View file

@ -79,9 +79,9 @@
return "";
}
const kb = Number.parseFloat(file.FileSize) / 1048576;
const size = Number.parseFloat(file.FileSize) / 1048576;
return kb.toFixed(1) + " MB";
return size.toFixed(1) + " MB";
},
fileType(file) {
if (file.FileVideo) {

View file

@ -1,15 +1,9 @@
import RestModel from "model/rest";
import Api from "common/api";
import {DateTime} from "luxon";
import Util from "common/util";
const SrcAuto = "";
const SrcManual = "manual";
const SrcLocation = "location";
const SrcImage = "image";
const SrcExif = "exif";
const SrcXmp = "xmp";
const SrcYml = "yml";
const SrcJson = "json";
class Photo extends RestModel {
getDefaults() {
@ -245,6 +239,35 @@ class Photo extends RestModel {
return "Unknown";
}
getVideoInfo() {
let result = [];
let file = this.videoFile();
if(!file) {
file = this.mainFile();
}
if(!file) {
return "Video";
}
if (file.FileLength > 0) {
result.push(Util.duration(file.FileLength))
}
if (file.FileWidth && file.FileHeight) {
result.push(file.FileWidth + " × " + file.FileHeight);
}
if(file.FileSize) {
const size = Number.parseFloat(file.FileSize) / 1048576;
result.push(size.toFixed(1) + " MB");
}
return result.join(", ");
}
getCamera() {
if (this.Camera) {
return this.Camera.CameraMake + " " + this.Camera.CameraModel;

View file

@ -0,0 +1,9 @@
package classify
const (
// data sources
SrcAuto = ""
SrcManual = "manual"
SrcLocation = "location"
SrcImage = "image"
)

View file

@ -25,7 +25,7 @@ func LocationLabel(name string, uncertainty int, priority int) Label {
name = name[:index]
}
label := Label{Name: name, Source: "location", Uncertainty: uncertainty, Priority: priority}
label := Label{Name: name, Source: SrcLocation, Uncertainty: uncertainty, Priority: priority}
return label
}

View file

@ -190,7 +190,7 @@ func (t *TensorFlow) bestLabels(probabilities []float32) Labels {
uncertainty := 100 - int(math.Round(float64(p*100)))
result = append(result, Label{Name: labelText, Source: "image", Uncertainty: uncertainty, Priority: rule.Priority, Categories: rule.Categories})
result = append(result, Label{Name: labelText, Source: SrcImage, Uncertainty: uncertainty, Priority: rule.Priority, Categories: rule.Categories})
}
// Sort by probability

View file

@ -174,7 +174,7 @@ var GlobalFlags = []cli.Flag{
EnvVar: "PHOTOPRISM_EXIFTOOL_BIN",
},
cli.BoolFlag{
Name: "sidecar-json",
Name: "sidecar-json, j",
Usage: "sync metadata with json sidecar files as used by exiftool",
EnvVar: "PHOTOPRISM_SIDECAR_JSON",
},

View file

@ -1,15 +1,15 @@
package entity
import "github.com/photoprism/photoprism/internal/classify"
const (
// data sources
SrcAuto = ""
SrcManual = "manual"
SrcLocation = "location"
SrcImage = "image"
SrcExif = "exif"
SrcMeta = "meta"
SrcXmp = "xmp"
SrcYml = "yml"
SrcJson = "json"
SrcLocation = classify.SrcLocation
SrcImage = classify.SrcImage
// sort orders
SortOrderRelevance = "relevance"

View file

@ -1,6 +1,7 @@
package meta
import (
"math"
"time"
)
@ -9,10 +10,10 @@ type Data struct {
UniqueID string `meta:"ImageUniqueID"`
TakenAt time.Time `meta:"DateTimeOriginal,CreateDate,MediaCreateDate"`
TakenAtLocal time.Time `meta:"DateTimeOriginal,CreateDate,MediaCreateDate"`
Duration time.Duration `meta:"Duration,MediaDuration"`
Duration time.Duration `meta:"Duration,MediaDuration,TrackDuration"`
TimeZone string `meta:"-"`
Title string `meta:"Title"`
Subject string `meta:"Subject"`
Subject string `meta:"Subject,PersonInImage"`
Keywords string `meta:"Keywords"`
Comment string `meta:"-"`
Artist string `meta:"Artist,Creator"`
@ -31,11 +32,37 @@ type Data struct {
FNumber float32 `meta:"FNumber"`
Iso int `meta:"ISO"`
GPSPosition string `meta:"GPSPosition"`
Lat float32 `meta:"-"` // TODO
Lng float32 `meta:"-"` // TODO
Altitude int `meta:"-"`
GPSLatitude string `meta:"GPSLatitude"`
GPSLongitude string `meta:"GPSLongitude"`
Lat float32 `meta:"-"`
Lng float32 `meta:"-"`
Altitude int `meta:"GlobalAltitude"`
Width int `meta:"ImageWidth"`
Height int `meta:"ImageHeight"`
Orientation int `meta:"-"`
All map[string]string
}
// AspectRatio returns the aspect ratio based on width and height.
func (data Data) AspectRatio() float32 {
width := float64(data.Width)
height := float64(data.Height)
if width <= 0 || height <= 0 {
return 0
}
aspectRatio := float32(width / height)
return aspectRatio
}
// Portrait returns true if it's a portrait picture or video based on width and height.
func (data Data) Portrait() bool {
return data.Width < data.Height
}
// Megapixels returns the resolution in megapixels.
func (data Data) Megapixels() int {
return int(math.Round(float64(data.Width*data.Height) / 1000000))
}

35
internal/meta/duration.go Normal file
View file

@ -0,0 +1,35 @@
package meta
import (
"regexp"
"strconv"
"strings"
"time"
)
var DurationSecondsRegexp = regexp.MustCompile("[0-9\\.]+")
// StringToDuration converts a metadata string to a valid duration.
func StringToDuration(s string) (d time.Duration) {
if s == "" {
return d
}
s = strings.TrimSpace(s)
sec := DurationSecondsRegexp.FindAllString(s, -1)
if len(sec) == 1 {
secFloat, _ := strconv.ParseFloat(sec[0], 64)
d = time.Duration(secFloat) * time.Second
} else if n := strings.Split(s, ":"); len(n) == 3 {
h, _ := strconv.Atoi(n[0])
m, _ := strconv.Atoi(n[1])
s, _ := strconv.Atoi(n[2])
d = time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(s)*time.Second
} else if pd, err := time.ParseDuration(s); err != nil {
d = pd
}
return d
}

View file

@ -0,0 +1,50 @@
package meta
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestStringToDuration(t *testing.T) {
t.Run("empty", func(t *testing.T) {
d := StringToDuration("")
assert.Equal(t, "0s", d.String())
})
t.Run("0", func(t *testing.T) {
d := StringToDuration("0")
assert.Equal(t, "0s", d.String())
})
t.Run("2.41 s", func(t *testing.T) {
d := StringToDuration("2.41 s")
assert.Equal(t, "2s", d.String())
})
t.Run("0.41 s", func(t *testing.T) {
d := StringToDuration("0.41 s")
assert.Equal(t, "0s", d.String())
})
t.Run("41 s", func(t *testing.T) {
d := StringToDuration("41 s")
assert.Equal(t, "41s", d.String())
})
t.Run("0:0:1", func(t *testing.T) {
d := StringToDuration("0:0:1")
assert.Equal(t, "1s", d.String())
})
t.Run("0:04:25", func(t *testing.T) {
d := StringToDuration("0:04:25")
assert.Equal(t, "4m25s", d.String())
})
t.Run("0001:04:25", func(t *testing.T) {
d := StringToDuration("0001:04:25")
assert.Equal(t, "1h4m25s", d.String())
})
}

80
internal/meta/gps.go Normal file
View file

@ -0,0 +1,80 @@
package meta
import (
"regexp"
"strconv"
"github.com/dsoprea/go-exif/v2"
)
// var GpsCoordsRegexp = regexp.MustCompile("(-?\\d+(\\.\\d+)?),\\s*(-?\\d+(\\.\\d+)?)")
var GpsCoordsRegexp = regexp.MustCompile("[0-9\\.]+")
var GpsRefRegexp = regexp.MustCompile("[NSEW]+")
// GpsToLatLng returns the GPS latitude and longitude as float point number.
func GpsToLatLng(s string) (lat, lng float32) {
if s == "" {
return 0, 0
}
co := GpsCoordsRegexp.FindAllString(s, -1)
re := GpsRefRegexp.FindAllString(s, -1)
if len(co) != 6 || len(re) != 2 {
return 0, 0
}
latDeg := exif.GpsDegrees{
Orientation: re[0][0],
Degrees: GpsCoord(co[0]),
Minutes: GpsCoord(co[1]),
Seconds: GpsCoord(co[2]),
}
lngDeg := exif.GpsDegrees{
Orientation: re[1][0],
Degrees: GpsCoord(co[3]),
Minutes: GpsCoord(co[4]),
Seconds: GpsCoord(co[5]),
}
return float32(latDeg.Decimal()), float32(lngDeg.Decimal())
}
// GpsToDecimal returns the GPS latitude or longitude as decimal float point number.
func GpsToDecimal(s string) float32 {
if s == "" {
return 0
}
co := GpsCoordsRegexp.FindAllString(s, -1)
re := GpsRefRegexp.FindAllString(s, -1)
if len(co) != 3 || len(re) != 1 {
return 0
}
latDeg := exif.GpsDegrees{
Orientation: re[0][0],
Degrees: GpsCoord(co[0]),
Minutes: GpsCoord(co[1]),
Seconds: GpsCoord(co[2]),
}
return float32(latDeg.Decimal())
}
// GpsToLng returns a single GPS coordinate value as float point number (degree, minute or second).
func GpsCoord(s string) float64 {
if s == "" {
return 0
}
result, err := strconv.ParseFloat(s, 64)
if err != nil {
log.Debugf("meta: %s", err)
}
return result
}

34
internal/meta/gps_test.go Normal file
View file

@ -0,0 +1,34 @@
package meta
import "testing"
func TestGpsToLat(t *testing.T) {
lat := GpsToDecimal("51 deg 15' 17.47\" N")
exp := float32(51.254852)
if lat-exp > 0 {
t.Fatalf("lat is %f, should be %f", lat, exp)
}
}
func TestGpsToLng(t *testing.T) {
lng := GpsToDecimal("7 deg 23' 22.09\" E")
exp := float32(7.389470)
if lng-exp > 0 {
t.Fatalf("lng is %f, should be %f", lng, exp)
}
}
func TestGpsToLatLng(t *testing.T) {
lat, lng := GpsToLatLng("51 deg 15' 17.47\" N, 7 deg 23' 22.09\" E")
expLat, expLng := float32(51.254852), float32(7.389470)
if lat-expLat > 0 {
t.Fatalf("lat is %f, should be %f", lat, expLat)
}
if lng-expLng > 0 {
t.Fatalf("lng is %f, should be %f", lng, expLng)
}
}

View file

@ -5,12 +5,12 @@ import (
"io/ioutil"
"path/filepath"
"reflect"
"strconv"
"strings"
"time"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/tidwall/gjson"
"gopkg.in/ugjka/go-tz.v2/tz"
)
// JSON parses a json sidecar file (as used by Exiftool) and returns a Data struct.
@ -81,18 +81,10 @@ func (data *Data) JSON(filename string) (err error) {
switch t := fieldValue.Interface().(type) {
case time.Time:
if tv, err := time.Parse("2006:01:02 15:04:05", strings.TrimSpace(jsonValue.String())); err == nil {
fieldValue.Set(reflect.ValueOf(tv))
fieldValue.Set(reflect.ValueOf(tv.Round(time.Second).UTC()))
}
case time.Duration:
if n := strings.Split(strings.TrimSpace(jsonValue.String()), ":"); len(n) == 3 {
h, _ := strconv.Atoi(n[0])
m, _ := strconv.Atoi(n[1])
s, _ := strconv.Atoi(n[2])
dv := time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(s)*time.Second
fieldValue.Set(reflect.ValueOf(dv))
}
fieldValue.Set(reflect.ValueOf(StringToDuration(jsonValue.String())))
case int, int64:
fieldValue.SetInt(jsonValue.Int())
case float32, float64:
@ -109,5 +101,35 @@ func (data *Data) JSON(filename string) (err error) {
}
}
// Calculate latitude and longitude if exists.
if data.GPSPosition != "" {
data.Lat, data.Lng = GpsToLatLng(data.GPSPosition)
} else if data.GPSLatitude != "" && data.GPSLongitude != "" {
data.Lat = GpsToDecimal(data.GPSLatitude)
data.Lng = GpsToDecimal(data.GPSLongitude)
}
// Set time zone and calculate UTC time.
if data.Lat != 0 && data.Lng != 0 {
zones, err := tz.GetZone(tz.Point{
Lat: float64(data.Lat),
Lon: float64(data.Lng),
})
if err == nil && len(zones) > 0 {
data.TimeZone = zones[0]
}
if !data.TakenAtLocal.IsZero() {
if loc, err := time.LoadLocation(data.TimeZone); err != nil {
log.Warnf("meta: unknown time zone %s", data.TimeZone)
} else if tl, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), loc); err == nil {
data.TakenAt = tl.Round(time.Second).UTC()
} else {
log.Errorf("meta: %s", err.Error()) // this should never happen
}
}
}
return nil
}

View file

@ -26,6 +26,25 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "", data.LensModel)
})
t.Run("gopher-video.json", func(t *testing.T) {
data, err := JSON("testdata/gopher-video.json")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %+v", data)
assert.Equal(t, "2s", data.Duration.String())
assert.Equal(t, "2020-05-11 14:18:35 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, 270, data.Width)
assert.Equal(t, 480, data.Height)
assert.Equal(t, "", data.Copyright)
assert.Equal(t, "", data.CameraMake)
assert.Equal(t, "", data.CameraModel)
assert.Equal(t, "", data.LensModel)
})
t.Run("photoshop.json", func(t *testing.T) {
data, err := JSON("testdata/photoshop.json")
@ -35,6 +54,12 @@ func TestJSON(t *testing.T) {
// t.Logf("DATA: %+v", data)
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, float32(52.45969), data.Lat)
assert.Equal(t, float32(13.321831), data.Lng)
assert.Equal(t, "2020-01-01 16:28:23 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "2020-01-01 17:28:23 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, "Night Shift / Berlin / 2020", data.Title)
assert.Equal(t, "Michael Mayer", data.Artist)
assert.Equal(t, "Example file for development", data.Description)

View file

@ -0,0 +1,69 @@
[{
"SourceFile": "/go/src/github.com/photoprism/photoprism/assets/photos/originals/video/gopher-video.mp4",
"ExifToolVersion": 10.80,
"FileName": "gopher-video.mp4",
"Directory": "/go/src/github.com/photoprism/photoprism/assets/photos/originals/video",
"FileSize": "331 kB",
"FileModifyDate": "2020:05:11 14:19:45+00:00",
"FileAccessDate": "2020:05:11 15:40:29+00:00",
"FileInodeChangeDate": "2020:05:11 15:40:29+00:00",
"FilePermissions": "rw-r--r--",
"FileType": "MP4",
"FileTypeExtension": "mp4",
"MIMEType": "video/mp4",
"MajorBrand": "MP4 Base Media v1 [IS0 14496-12:2003]",
"MinorVersion": "0.2.0",
"CompatibleBrands": ["isom","iso2","avc1","mp41"],
"MovieDataSize": 336136,
"MovieDataOffset": 40,
"MovieHeaderVersion": 0,
"CreateDate": "2020:05:11 14:18:35",
"ModifyDate": "2020:05:11 14:18:35",
"TimeScale": 6000,
"Duration": "2.41 s",
"PreferredRate": 1,
"PreferredVolume": "100.00%",
"PreviewTime": "0 s",
"PreviewDuration": "0 s",
"PosterTime": "0 s",
"SelectionTime": "0 s",
"SelectionDuration": "0 s",
"CurrentTime": "0 s",
"NextTrackID": 3,
"TrackHeaderVersion": 0,
"TrackCreateDate": "2020:05:11 14:18:33",
"TrackModifyDate": "2020:05:11 14:18:35",
"TrackID": 1,
"TrackDuration": "2.41 s",
"TrackLayer": 0,
"TrackVolume": "100.00%",
"Balance": 0,
"AudioFormat": "mp4a",
"AudioChannels": 2,
"AudioBitsPerSample": 16,
"AudioSampleRate": 48000,
"MatrixStructure": "1 0 0 0 1 0 0 0 1",
"ImageWidth": 270,
"ImageHeight": 480,
"MediaHeaderVersion": 0,
"MediaCreateDate": "2020:05:11 14:18:33",
"MediaModifyDate": "2020:05:11 14:18:35",
"MediaTimeScale": 90000,
"MediaDuration": "2.41 s",
"MediaLanguageCode": "eng",
"HandlerType": "Video Track",
"HandlerDescription": "VideoHandle",
"GraphicsMode": "srcCopy",
"OpColor": "0 0 0",
"CompressorID": "avc1",
"SourceImageWidth": 270,
"SourceImageHeight": 480,
"XResolution": 72,
"YResolution": 72,
"BitDepth": 24,
"VideoFrameRate": 28.216,
"AvgBitrate": "1.12 Mbps",
"ImageSize": "270x480",
"Megapixels": 0.130,
"Rotation": 0
}]

View file

@ -137,6 +137,17 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if m.IsVideo() {
photo.PhotoVideo = true
metaData, _ = m.MetaData()
file.FileWidth = metaData.Width
file.FileHeight = metaData.Height
file.FileLength = metaData.Duration
file.FileAspectRatio = metaData.AspectRatio()
file.FilePortrait = metaData.Portrait()
if res := metaData.Megapixels(); res > photo.PhotoResolution {
photo.PhotoResolution = res
}
}
if !file.FilePrimary {
@ -176,12 +187,12 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
}
if fileChanged || o.Rescan {
// Read UpdateExif data
// read metadata from embedded Exif and JSON sidecar file (if exists)
if metaData, err := m.MetaData(); err == nil {
photo.SetTitle(metaData.Title, entity.SrcExif)
photo.SetDescription(metaData.Description, entity.SrcExif)
photo.SetTakenAt(metaData.TakenAt, metaData.TakenAtLocal, metaData.TimeZone, entity.SrcExif)
photo.SetCoordinates(metaData.Lat, metaData.Lng, metaData.Altitude, entity.SrcExif)
photo.SetTitle(metaData.Title, entity.SrcMeta)
photo.SetDescription(metaData.Description, entity.SrcMeta)
photo.SetTakenAt(metaData.TakenAt, metaData.TakenAtLocal, metaData.TimeZone, entity.SrcMeta)
photo.SetCoordinates(metaData.Lat, metaData.Lng, metaData.Altitude, entity.SrcMeta)
if photo.Description.NoNotes() {
photo.Description.PhotoNotes = metaData.Comment

View file

@ -11,13 +11,15 @@ import (
// MetaData returns exif meta data of a media file.
func (m *MediaFile) MetaData() (result meta.Data, err error) {
m.metaDataOnce.Do(func() {
err = m.metaData.Exif(m.FileName())
if jsonFile := fs.TypeJson.Find(m.FileName(), false); jsonFile == "" {
log.Debugf("mediafile: no json sidecar file found for %s", txt.Quote(filepath.Base(m.FileName())))
} else if err := m.metaData.JSON(jsonFile); err != nil {
log.Warn(err)
} else if jsonErr := m.metaData.JSON(jsonFile); jsonErr != nil {
log.Warn(jsonErr)
} else {
err = nil
}
err = m.metaData.Exif(m.FileName())
})
return m.metaData, err

View file

@ -29,7 +29,7 @@ func Photos(f form.PhotoSearch) (results PhotosResults, count int, err error) {
files.id AS file_id, files.file_uuid, files.file_primary, files.file_missing, files.file_name, files.file_hash,
files.file_type, files.file_mime, files.file_width, files.file_height, files.file_aspect_ratio,
files.file_orientation, files.file_main_color, files.file_colors, files.file_luminance, files.file_chroma,
files.file_diff, files.file_video, files.file_length,
files.file_diff, files.file_video, files.file_length, files.file_size,
cameras.camera_make, cameras.camera_model,
lenses.lens_make, lenses.lens_model,
places.loc_label, places.loc_city, places.loc_state, places.loc_country`).

View file

@ -74,6 +74,7 @@ type PhotosResult struct {
FileMime string
FileWidth int
FileHeight int
FileSize int64
FileOrientation int
FileAspectRatio float32
FileColors string // todo: remove from result?