From c819e9159c1604209c4b1503e83bb3f229b9b760 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Tue, 13 Jul 2021 17:56:26 +0200 Subject: [PATCH] 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. --- internal/meta/json_exiftool.go | 43 +++++++++++++++-- internal/meta/json_test.go | 29 ++++++++++-- internal/meta/testdata/pxl-mp4.json | 71 +++++++++++++++++++++++++++++ internal/photoprism/convert.go | 2 +- 4 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 internal/meta/testdata/pxl-mp4.json diff --git a/internal/meta/json_exiftool.go b/internal/meta/json_exiftool.go index 4fd6a8cc9..b420d640c 100644 --- a/internal/meta/json_exiftool.go +++ b/internal/meta/json_exiftool.go @@ -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)": diff --git a/internal/meta/json_test.go b/internal/meta/json_test.go index 3320cc55a..b28613c8d 100644 --- a/internal/meta/json_test.go +++ b/internal/meta/json_test.go @@ -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) + }) } diff --git a/internal/meta/testdata/pxl-mp4.json b/internal/meta/testdata/pxl-mp4.json new file mode 100644 index 000000000..a9ee7bde9 --- /dev/null +++ b/internal/meta/testdata/pxl-mp4.json @@ -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 +}] \ No newline at end of file diff --git a/internal/photoprism/convert.go b/internal/photoprism/convert.go index 1002a93ce..237dcba06 100644 --- a/internal/photoprism/convert.go +++ b/internal/photoprism/convert.go @@ -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