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:
parent
2a11d5fcac
commit
c819e9159c
4 changed files with 134 additions and 11 deletions
|
@ -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)":
|
||||
|
|
|
@ -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
71
internal/meta/testdata/pxl-mp4.json
vendored
Normal 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
|
||||
}]
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue