Metadata: Fix time zone for MP4 videos #1388

As it turned out, the exiftool -api QuickTimeUTC parameter converts
CreateDate to local time using the server's time zone. This doesn't
help as it's technically still a local time and not UTC. Had to
implement this manually in our Exiftool JSON parser for MP4 videos only.
This commit is contained in:
Michael Mayer 2021-07-13 17:56:26 +02:00
parent 2a11d5fcac
commit c819e9159c
4 changed files with 134 additions and 11 deletions

View file

@ -15,6 +15,8 @@ import (
"gopkg.in/ugjka/go-tz.v2/tz"
)
const MimeVideoMP4 = "video/mp4"
// Exiftool parses JSON sidecar data as created by Exiftool.
func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
defer func() {
@ -150,6 +152,18 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
}
}
hasTimeOffset := false
if _, offset := data.TakenAtLocal.Zone(); offset != 0 && !data.TakenAtLocal.IsZero() {
hasTimeOffset = true
} else if mt, ok := jsonStrings["MIMEType"]; ok && mt == MimeVideoMP4 {
// Assume default time zone for MP4 videos is UTC.
// see https://exiftool.org/TagNames/QuickTime.html
data.TimeZone = time.UTC.String()
data.TakenAt = data.TakenAt.UTC()
data.TakenAtLocal = time.Time{}
}
// Set time zone and calculate UTC time.
if data.Lat != 0 && data.Lng != 0 {
zones, err := tz.GetZone(tz.Point{
@ -161,10 +175,10 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
data.TimeZone = zones[0]
}
if !data.TakenAtLocal.IsZero() {
if loc, err := time.LoadLocation(data.TimeZone); err != nil {
log.Warnf("metadata: unknown time zone %s (exiftool)", 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 {
if loc, err := time.LoadLocation(data.TimeZone); err != nil {
log.Warnf("metadata: unknown time zone %s (exiftool)", data.TimeZone)
} else if !data.TakenAtLocal.IsZero() {
if tl, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), loc); err == nil {
if localUtc, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), time.UTC); err == nil {
data.TakenAtLocal = localUtc
}
@ -173,8 +187,15 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
} else {
log.Errorf("metadata: %s (exiftool)", err.Error()) // this should never happen
}
} else if !data.TakenAt.IsZero() {
if localUtc, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); err == nil {
data.TakenAtLocal = localUtc
data.TakenAt = data.TakenAt.UTC()
} else {
log.Errorf("metadata: %s (exiftool)", err.Error()) // this should never happen
}
}
} else if _, offset := data.TakenAtLocal.Zone(); offset != 0 && !data.TakenAtLocal.IsZero() {
} else if hasTimeOffset {
if localUtc, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), time.UTC); err == nil {
data.TakenAtLocal = localUtc
}
@ -182,6 +203,18 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
data.TakenAt = data.TakenAt.Round(time.Second).UTC()
}
// Set local time if still empty.
if data.TakenAtLocal.IsZero() && !data.TakenAt.IsZero() {
if loc, err := time.LoadLocation(data.TimeZone); data.TimeZone == "" || err != nil {
data.TakenAtLocal = data.TakenAt
} else if localUtc, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); err == nil {
data.TakenAtLocal = localUtc
data.TakenAt = data.TakenAt.UTC()
} else {
log.Errorf("metadata: %s (exiftool)", err.Error()) // this should never happen
}
}
if orientation, ok := jsonStrings["Orientation"]; ok && orientation != "" {
switch orientation {
case "1", "Horizontal (normal)":

View file

@ -2,6 +2,7 @@ package meta
import (
"testing"
"time"
"github.com/photoprism/photoprism/pkg/fs"
@ -48,7 +49,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "2s", data.Duration.String())
assert.Equal(t, "2020-05-11 14:18:35 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2020-05-11 14:18:35 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "", data.TimeZone)
assert.Equal(t, time.UTC.String(), data.TimeZone)
assert.Equal(t, 270, data.Width)
assert.Equal(t, 480, data.Height)
assert.Equal(t, 270, data.ActualWidth())
@ -72,8 +73,8 @@ func TestJSON(t *testing.T) {
assert.Equal(t, CodecAvc1, data.Codec)
assert.Equal(t, "2s", data.Duration.String())
assert.Equal(t, "2020-05-11 14:16:48 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2020-05-11 12:16:48 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "2020-05-11 16:16:48 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2020-05-11 14:16:48 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, 1920, data.Width)
assert.Equal(t, 1080, data.Height)
@ -99,8 +100,8 @@ func TestJSON(t *testing.T) {
assert.Equal(t, CodecAvc1, data.Codec)
assert.Equal(t, "4s", data.Duration.String())
assert.Equal(t, "2020-05-14 11:34:41 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2020-05-14 09:34:41 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "2020-05-14 13:34:41 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2020-05-14 11:34:41 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, 1920, data.Width)
assert.Equal(t, 1080, data.Height)
@ -870,4 +871,22 @@ func TestJSON(t *testing.T) {
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, "", data.Projection)
})
t.Run("pxl-mp4.json", func(t *testing.T) {
data, err := JSON("testdata/pxl-mp4.json", "")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "2021-07-12T22:56:37Z", data.TakenAt.Format("2006-01-02T15:04:05Z"))
assert.Equal(t, "2021-07-12T22:56:37Z", data.TakenAtLocal.Format("2006-01-02T15:04:05Z"))
assert.Equal(t, time.UTC.String(), data.TimeZone)
assert.Equal(t, 1080, data.Height)
assert.Equal(t, 1920, data.Width)
assert.Equal(t, float32(0), data.Lat)
assert.Equal(t, float32(0), data.Lng)
assert.Equal(t, 0, data.Altitude)
assert.Equal(t, 1, data.Orientation)
})
}

71
internal/meta/testdata/pxl-mp4.json vendored Normal file
View file

@ -0,0 +1,71 @@
[{
"SourceFile": "PXL_20210712_225625784.mp4",
"ExifToolVersion": 12.16,
"FileName": "PXL_20210712_225625784.mp4",
"Directory": ".",
"FileSize": "30 MiB",
"FileModifyDate": "2021:07:13 00:56:37+02:00",
"FileAccessDate": "2021:07:13 14:15:29+02:00",
"FileInodeChangeDate": "2021:07:13 14:12:26+02:00",
"FilePermissions": "rw-r-----",
"FileType": "MP4",
"FileTypeExtension": "mp4",
"MIMEType": "video/mp4",
"MajorBrand": "MP4 v2 [ISO 14496-14]",
"MinorVersion": "0.0.0",
"CompatibleBrands": ["isom","mp42"],
"MediaDataSize": 30925775,
"MediaDataOffset": 40,
"MovieHeaderVersion": 0,
"CreateDate": "2021:07:12 22:56:37",
"ModifyDate": "2021:07:12 22:56:37",
"TimeScale": 10000,
"Duration": "11.15 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,
"AndroidCaptureFps": 30,
"TrackHeaderVersion": 0,
"TrackCreateDate": "2021:07:12 22:56:37",
"TrackModifyDate": "2021:07:12 22:56:37",
"TrackID": 1,
"TrackDuration": "11.11 s",
"TrackLayer": 0,
"TrackVolume": "100.00%",
"Balance": 0,
"AudioFormat": "mp4a",
"AudioChannels": 1,
"AudioBitsPerSample": 16,
"AudioSampleRate": 48000,
"MatrixStructure": "1 0 0 0 1 0 0 0 1",
"ImageWidth": 1920,
"ImageHeight": 1080,
"MediaHeaderVersion": 0,
"MediaCreateDate": "2021:07:12 22:56:37",
"MediaModifyDate": "2021:07:12 22:56:37",
"MediaTimeScale": 90000,
"MediaDuration": "11.15 s",
"HandlerType": "Video Track",
"HandlerDescription": "VideoHandle",
"GraphicsMode": "srcCopy",
"OpColor": "0 0 0",
"CompressorID": "avc1",
"SourceImageWidth": 1920,
"SourceImageHeight": 1080,
"XResolution": 72,
"YResolution": 72,
"BitDepth": 24,
"PixelAspectRatio": "65536:65536",
"ColorRepresentation": "nclx 5 1 6",
"VideoFrameRate": 30.051,
"ImageSize": "1920x1080",
"Megapixels": 2.1,
"AvgBitrate": "22.2 Mbps",
"Rotation": 0
}]

View file

@ -144,7 +144,7 @@ func (c *Convert) ToJson(f *MediaFile) (jsonName string, err error) {
log.Debugf("exiftool: extracting metadata from %s", relName)
cmd := exec.Command(c.conf.ExifToolBin(), "-m", "-api", "QuickTimeUTC", "-api", "LargeFileSupport", "-j", f.FileName())
cmd := exec.Command(c.conf.ExifToolBin(), "-m", "-api", "LargeFileSupport", "-j", f.FileName())
// Fetch command output.
var out bytes.Buffer