Index and show video metadata #17
Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
parent
22bd546c70
commit
2045e3d770
21 changed files with 488 additions and 47 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
9
internal/classify/const.go
Normal file
9
internal/classify/const.go
Normal file
|
@ -0,0 +1,9 @@
|
|||
package classify
|
||||
|
||||
const (
|
||||
// data sources
|
||||
SrcAuto = ""
|
||||
SrcManual = "manual"
|
||||
SrcLocation = "location"
|
||||
SrcImage = "image"
|
||||
)
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
35
internal/meta/duration.go
Normal 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
|
||||
}
|
50
internal/meta/duration_test.go
Normal file
50
internal/meta/duration_test.go
Normal 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
80
internal/meta/gps.go
Normal 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
34
internal/meta/gps_test.go
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
69
internal/meta/testdata/gopher-video.json
vendored
Normal file
69
internal/meta/testdata/gopher-video.json
vendored
Normal 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
|
||||
}]
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`).
|
||||
|
|
|
@ -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?
|
||||
|
|
Loading…
Reference in a new issue