From 3403c50c48de7c1cecadaf6b822d9f35d99e4b3e Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 24 Aug 2022 17:50:22 +0200 Subject: [PATCH] Videos: Extract local time from DateTimeOriginal if possible #2640 Signed-off-by: Michael Mayer --- internal/meta/data.go | 5 +- internal/meta/json_exiftool.go | 30 +++- internal/meta/json_test.go | 25 ++- internal/meta/testdata/MVI_1724.MOV.json | 215 +++++++++++++++++++++++ 4 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 internal/meta/testdata/MVI_1724.MOV.json diff --git a/internal/meta/data.go b/internal/meta/data.go index 842f6ba15..21af5d0db 100644 --- a/internal/meta/data.go +++ b/internal/meta/data.go @@ -17,8 +17,9 @@ type Data struct { FileName string `meta:"FileName"` DocumentID string `meta:"BurstUUID,MediaGroupUUID,ImageUniqueID,OriginalDocumentID,DocumentID,DigitalImageGUID"` InstanceID string `meta:"InstanceID,DocumentID"` - TakenAt time.Time `meta:"SubSecDateTimeOriginal,SubSecDateTimeCreated,SubSecCreateDate,DateTimeOriginal,CreationDate,CreateDate,MediaCreateDate,ContentCreateDate,DateTimeCreated,DateTime,DateTimeDigitized" xmp:"DateCreated"` - TakenAtLocal time.Time `meta:"SubSecDateTimeOriginal,SubSecDateTimeCreated,SubSecCreateDate,DateTimeOriginal,CreationDate,CreateDate,MediaCreateDate,ContentCreateDate,DateTimeCreated,DateTime,DateTimeDigitized"` + CreatedAt time.Time `meta:"SubSecCreateDate,CreationDate,CreateDate,MediaCreateDate,ContentCreateDate,TrackCreateDate,MetadataDate"` + TakenAt time.Time `meta:"SubSecDateTimeOriginal,SubSecDateTimeCreated,DateTimeOriginal,CreationDate,DateTimeCreated,DateTime,DateTimeDigitized" xmp:"DateCreated"` + TakenAtLocal time.Time `meta:"SubSecDateTimeOriginal,SubSecDateTimeCreated,DateTimeOriginal,CreationDate,DateTimeCreated,DateTime,DateTimeDigitized"` TakenGps time.Time `meta:"GPSDateTime,GPSDateStamp"` TakenNs int `meta:"-"` TimeZone string `meta:"-"` diff --git a/internal/meta/json_exiftool.go b/internal/meta/json_exiftool.go index 1d9bcce77..68aa34146 100644 --- a/internal/meta/json_exiftool.go +++ b/internal/meta/json_exiftool.go @@ -33,8 +33,14 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) { j := gjson.GetBytes(jsonData, "@flatten|@join") + logName := "json file" + + if originalName != "" { + logName = clean.Log(filepath.Base(originalName)) + } + if !j.IsObject() { - return fmt.Errorf("metadata: data is not an object in %s (exiftool)", clean.Log(filepath.Base(originalName))) + return fmt.Errorf("metadata: data is not an object in %s (exiftool)", logName) } data.json = make(map[string]string) @@ -46,6 +52,8 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) { if fileName, ok := data.json["FileName"]; ok && fileName != "" && originalName != "" && fileName != originalName { return fmt.Errorf("metadata: original name %s does not match %s (exiftool)", clean.Log(originalName), clean.Log(fileName)) + } else if fileName != "" && originalName == "" { + logName = clean.Log(filepath.Base(fileName)) } v := reflect.ValueOf(data).Elem() @@ -185,18 +193,34 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) { hasTimeOffset := false - // Fallback to GPS timestamp. + // Has Media Create Date? + if !data.CreatedAt.IsZero() { + data.TakenAt = data.CreatedAt + } + + // Fallback to GPS UTC Time? if data.TakenAt.IsZero() && data.TakenAtLocal.IsZero() && !data.TakenGps.IsZero() { data.TimeZone = time.UTC.String() data.TakenAt = data.TakenGps.UTC() data.TakenAtLocal = time.Time{} } + // Check plausibility of the local <> UTC time difference. + if !data.TakenAt.IsZero() && !data.TakenAtLocal.IsZero() { + if d := data.TakenAt.Sub(data.TakenAtLocal).Abs(); d > time.Hour*27 { + log.Warnf("metadata: invalid local time offset %.1fh in %s (exiftool)", d.Hours(), logName) + data.TakenAtLocal = data.TakenAt + data.TakenAt = data.TakenAt.UTC() + } + } + + // Has time zone offset? if _, offset := data.TakenAtLocal.Zone(); offset != 0 && !data.TakenAtLocal.IsZero() { hasTimeOffset = true - } else if mt, ok := data.json["MIMEType"]; ok && (mt == MimeVideoMP4 || mt == MimeQuicktime) { + } else if mt, ok := data.json["MIMEType"]; ok && data.TakenAtLocal.IsZero() && (mt == MimeVideoMP4 || mt == MimeQuicktime) { // Assume default time zone for MP4 & Quicktime videos is UTC. // see https://exiftool.org/TagNames/QuickTime.html + log.Debugf("metadata: using UTC as default time zone in %s (%s)", logName, clean.Log(mt)) data.TimeZone = time.UTC.String() data.TakenAt = data.TakenAt.UTC() data.TakenAtLocal = time.Time{} diff --git a/internal/meta/json_test.go b/internal/meta/json_test.go index 812151871..102d0119a 100644 --- a/internal/meta/json_test.go +++ b/internal/meta/json_test.go @@ -732,6 +732,26 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.LensModel) }) + t.Run("MVI_1724.MOV.json", func(t *testing.T) { + data, err := JSON("testdata/MVI_1724.MOV.json", "") + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, string(video.CodecAVC), data.Codec) + assert.Equal(t, "6s", data.Duration.String()) + assert.Equal(t, "2022-06-25 06:50:58 +0000 UTC", data.TakenAtLocal.String()) + assert.Equal(t, "2022-06-25 04:50:58 +0000 UTC", data.TakenAt.String()) + assert.Equal(t, "", data.TimeZone) // Local Time + assert.Equal(t, 1, data.Orientation) + assert.Equal(t, float32(0), data.Lat) + assert.Equal(t, float32(0), data.Lng) + assert.Equal(t, "Canon", data.CameraMake) + assert.Equal(t, "Canon PowerShot G15", data.CameraModel) + assert.Equal(t, "6.1", data.LensModel) + }) + t.Run("snow.json", func(t *testing.T) { data, err := JSON("testdata/snow.json", "") @@ -845,8 +865,9 @@ func TestJSON(t *testing.T) { // t.Logf("all: %+v", data.json) assert.Equal(t, "Jens\r\tMander", data.Artist) - assert.Equal(t, "2004-09-23T10:57:57Z", data.TakenAt.Format("2006-01-02T15:04:05Z")) - assert.Equal(t, "2004-09-23T10:57:57Z", data.TakenAtLocal.Format("2006-01-02T15:04:05Z")) + assert.Equal(t, "", data.TimeZone) + assert.Equal(t, "2004-10-07 20:49:16 +0000 UTC", data.TakenAt.String()) + assert.Equal(t, "2004-10-07 22:49:16 +0000 UTC", data.TakenAtLocal.String()) assert.Equal(t, "This is the title", data.Title) assert.Equal(t, "", data.Keywords.String()) assert.Equal(t, "This is a\n\ndescription!", data.Description) diff --git a/internal/meta/testdata/MVI_1724.MOV.json b/internal/meta/testdata/MVI_1724.MOV.json new file mode 100644 index 000000000..a95436066 --- /dev/null +++ b/internal/meta/testdata/MVI_1724.MOV.json @@ -0,0 +1,215 @@ +[{ + "SourceFile": "MVI_1724.MOV", + "ExifToolVersion": 12.16, + "FileName": "MVI_1724.MOV", + "Directory": ".", + "FileSize": 26861044, + "FileModifyDate": "2022:06:25 06:50:58+02:00", + "FileAccessDate": "2022:08:24 14:40:40+02:00", + "FileInodeChangeDate": "2022:08:24 14:39:39+02:00", + "FilePermissions": 664, + "FileType": "MOV", + "FileTypeExtension": "MOV", + "MIMEType": "video/quicktime", + "MajorBrand": "qt ", + "MinorVersion": "2007.9.0", + "CompatibleBrands": ["qt ","CAEP"], + "CompressorVersion": "CanonAVC0010/02.00.00/00.00.00", + "ExifByteOrder": "II", + "ImageDescription": " ", + "Orientation": 1, + "ResolutionUnit": 2, + "YCbCrPositioning": 2, + "ExposureTime": 0.01666666667, + "FNumber": 5.6, + "SensitivityType": 4, + "ExifVersion": "0230", + "DateTimeOriginal": "2022:06:25 06:50:58", + "ComponentsConfiguration": "1 2 3 0", + "CompressedBitsPerPixel": 3, + "ShutterSpeedValue": 0.0166740687605754, + "ApertureValue": 5.59591869015324, + "MaxApertureValue": 1.79470907500311, + "Flash": 16, + "FocalLength": 6.1, + "MacroMode": 2, + "SelfTimer": 0, + "Quality": 130, + "CanonFlashMode": 0, + "ContinuousDrive": 2, + "FocusMode": 4, + "RecordMode": 9, + "CanonImageSize": 142, + "EasyMode": 1, + "DigitalZoom": 0, + "Contrast": 0, + "Saturation": 0, + "Sharpness": 0, + "CameraISO": "Auto", + "MeteringMode": 3, + "FocusRange": 1, + "AFPoint": 16390, + "CanonExposureMode": 1, + "LensType": 65535, + "MaxFocalLength": 30.5, + "MinFocalLength": 6.1, + "FocalUnits": 1000, + "MaxAperture": 1.79470907500311, + "MinAperture": 8, + "FlashActivity": 0, + "FlashBits": 0, + "FocusContinuous": 1, + "AESetting": 0, + "ImageStabilization": 260, + "ZoomSourceWidth": 4000, + "ZoomTargetWidth": 4000, + "SpotMeteringMode": 0, + "ManualFlashOutput": 0, + "AutoISO": 37.7291106898356, + "BaseISO": 200, + "MeasuredEV": 11.5, + "TargetAperture": 5.59591869015324, + "TargetExposureTime": 0.0166740687605754, + "ExposureCompensation": 0, + "WhiteBalance": 0, + "SlowShutter": 0, + "SequenceNumber": 0, + "OpticalZoomCode": 0, + "FlashGuideNumber": 0, + "FlashExposureComp": 0, + "AutoExposureBracketing": 0, + "AEBBracketValue": 0, + "ControlMode": 1, + "FocusDistanceUpper": 0.65, + "FocusDistanceLower": 0, + "BulbDuration": 0, + "CameraType": 250, + "AutoRotate": 0, + "NDFilter": 0, + "SelfTimer2": 0, + "FlashOutput": 0, + "CanonImageType": "MVI:PowerShot G15 Movie", + "CanonFirmwareVersion": "Firmware Version 1.00", + "FileNumber": 1551724, + "CameraTemperature": 21, + "CanonModelID": 53673984, + "ThumbnailImageValidArea": "0 159 15 104", + "DateStampMode": 0, + "MyColorMode": 0, + "FirmwareRevision": 16778496, + "Categories": 0, + "AFAreaMode": 2, + "NumAFPoints": 9, + "ValidAFPoints": 1, + "CanonImageWidth": 1920, + "CanonImageHeight": 1080, + "AFImageWidth": 4000, + "AFImageHeight": 3000, + "AFAreaWidths": "720 18 18 18 18 2304 0 -14992 24172", + "AFAreaHeights": "540 240 240 240 240 0 19 3739 542", + "AFAreaXPositions": "0 18 -18 0 18 18 18 18 18", + "AFAreaYPositions": "0 0 240 240 240 240 240 240 240", + "AFPointsInFocus": 16, + "PrimaryAFPoint": 4, + "IntelligentContrast": 0, + "ImageUniqueID": "a5a750522f844e7b06168799cbbb9dd5", + "FacesDetected": 65535, + "TimeZone": 120, + "TimeZoneCity": 32766, + "DaylightSavings": 60, + "AspectRatio": 7, + "CroppedImageWidth": 1920, + "CroppedImageHeight": 1080, + "CroppedImageLeft": 0, + "CroppedImageTop": 0, + "VRDOffset": 0, + "UserComment": "", + "FlashpixVersion": "0100", + "ColorSpace": 1, + "ExifImageWidth": 160, + "ExifImageHeight": 120, + "InteropIndex": "THM", + "InteropVersion": "0100", + "RelatedImageWidth": 1920, + "RelatedImageHeight": 1080, + "SensingMethod": 2, + "FileSource": 3, + "CustomRendered": 0, + "ExposureMode": 0, + "DigitalZoomRatio": 1, + "SceneCaptureType": 0, + "OwnerName": "", + "GPSVersionID": "2 3 0 0", + "EncodingProcess": 0, + "BitsPerSample": 8, + "ColorComponents": 3, + "YCbCrSubSampling": "2 1", + "ThumbnailImage": "(Binary data 9850 bytes, use -b option to extract)", + "Make": "Canon", + "Model": "Canon PowerShot G15", + "UserRating": 0, + "Copyright": " ", + "Author": " ", + "MovieHeaderVersion": 0, + "CreateDate": "2022:06:25 04:50:58", + "ModifyDate": "2022:06:25 04:50:58", + "TimeScale": 24000, + "Duration": 6.08941666666667, + "PreferredRate": 1, + "PreferredVolume": 1, + "PreviewTime": 0, + "PreviewDuration": 0, + "PosterTime": 0, + "SelectionTime": 0, + "SelectionDuration": 0, + "CurrentTime": 0, + "NextTrackID": 3, + "TrackHeaderVersion": 0, + "TrackCreateDate": "2022:06:25 04:50:58", + "TrackModifyDate": "2022:06:25 04:50:58", + "TrackID": 1, + "TrackDuration": 6.08941666666667, + "TrackLayer": 0, + "TrackVolume": 0, + "ImageWidth": 1920, + "ImageHeight": 1080, + "GraphicsMode": 0, + "OpColor": "0 0 0", + "CompressorID": "avc1", + "SourceImageWidth": 1920, + "SourceImageHeight": 1080, + "XResolution": 72, + "YResolution": 72, + "BitDepth": 24, + "VideoFrameRate": 23.976023976024, + "MatrixStructure": "1 0 0 0 1 0 0 0 1", + "MediaHeaderVersion": 0, + "MediaCreateDate": "2022:06:25 04:50:58", + "MediaModifyDate": "2022:06:25 04:50:58", + "MediaTimeScale": 48000, + "MediaDuration": 6.08941666666667, + "Balance": 0, + "HandlerClass": "dhlr", + "HandlerType": "alis", + "AudioFormat": "sowt", + "AudioBitsPerSample": 16, + "AudioSampleRate": 48000, + "LayoutFlags": 101, + "AudioChannels": 2, + "MediaDataSize": 26762732, + "MediaDataOffset": 98312, + "DriveMode": 0, + "ISO": 75.4582213796711, + "Lens": 6.1, + "ShootingMode": 1, + "Aperture": 5.6, + "ImageSize": "1920 1080", + "LensID": 65535, + "Megapixels": 2.0736, + "ShutterSpeed": 0.01666666667, + "AvgBitrate": 35159666, + "Rotation": 0, + "Lens35efl": 6.1, + "FocalLength35efl": 6.1, + "LightValue": 11.2927817489393 +}]