* Save mp4 files generated from samsung motion photos * Parse exiftool payload to identify videos * Detect embedded video file type * Extract embedded video in RelatedFiles
This commit is contained in:
parent
1507525ba4
commit
44759d6673
9 changed files with 324 additions and 3 deletions
70
assets/examples/beach_sand.json
Normal file
70
assets/examples/beach_sand.json
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
[{
|
||||||
|
"SourceFile": "beach_sand.jpg",
|
||||||
|
"ExifToolVersion": 12.56,
|
||||||
|
"FileName": "beach_sand.jpg",
|
||||||
|
"Directory": ".",
|
||||||
|
"FileSize": 105321,
|
||||||
|
"FileModifyDate": "2023:06:20 04:43:41+00:00",
|
||||||
|
"FileAccessDate": "2023:08:06 16:38:50+00:00",
|
||||||
|
"FileInodeChangeDate": "2023:08:04 05:10:37+00:00",
|
||||||
|
"FilePermissions": 100644,
|
||||||
|
"FileType": "JPEG",
|
||||||
|
"FileTypeExtension": "JPG",
|
||||||
|
"MIMEType": "image/jpeg",
|
||||||
|
"JFIFVersion": "1 2",
|
||||||
|
"ExifByteOrder": "MM",
|
||||||
|
"Make": "Apple",
|
||||||
|
"Model": "iPhone SE",
|
||||||
|
"XResolution": 72,
|
||||||
|
"YResolution": 72,
|
||||||
|
"ResolutionUnit": 2,
|
||||||
|
"Software": "10.2.1",
|
||||||
|
"ModifyDate": "2017:02:15 14:13:40",
|
||||||
|
"YCbCrPositioning": 1,
|
||||||
|
"ExposureTime": 0.0004940711462,
|
||||||
|
"ExposureProgram": 2,
|
||||||
|
"ISO": 25,
|
||||||
|
"ExifVersion": "0231",
|
||||||
|
"DateTimeOriginal": "2017:02:15 14:13:40",
|
||||||
|
"CreateDate": "2017:02:15 14:13:40",
|
||||||
|
"ComponentsConfiguration": "1 2 3 0",
|
||||||
|
"ApertureValue": 2.19999999733148,
|
||||||
|
"MeteringMode": 5,
|
||||||
|
"Flash": 16,
|
||||||
|
"FocalLength": 4.2,
|
||||||
|
"SubSecTimeOriginal": 249,
|
||||||
|
"SubSecTimeDigitized": 249,
|
||||||
|
"FlashpixVersion": "0100",
|
||||||
|
"ColorSpace": 65535,
|
||||||
|
"SensingMethod": 2,
|
||||||
|
"ExposureMode": 0,
|
||||||
|
"WhiteBalance": 0,
|
||||||
|
"SceneCaptureType": 0,
|
||||||
|
"LensModel": "iPhone SE back camera 4.15mm f/2.2",
|
||||||
|
"GPSVersionID": "2 3 0 0",
|
||||||
|
"GPSLatitudeRef": "S",
|
||||||
|
"GPSLongitudeRef": "E",
|
||||||
|
"GPSAltitudeRef": 1,
|
||||||
|
"CurrentIPTCDigest": "804bedc723e0e6cd3a41d0a44b074d19",
|
||||||
|
"DocumentNotes": "https://flickr.com/e/Rl7qi7oH%2BSEGDuwWBbZYaBQMEB5oNfPvQ6m3aMrPQ64%3D",
|
||||||
|
"ApplicationRecordVersion": 4,
|
||||||
|
"ImageWidth": 640,
|
||||||
|
"ImageHeight": 480,
|
||||||
|
"EncodingProcess": 2,
|
||||||
|
"BitsPerSample": 8,
|
||||||
|
"ColorComponents": 3,
|
||||||
|
"YCbCrSubSampling": "2 2",
|
||||||
|
"Aperture": 2.19999999733148,
|
||||||
|
"ImageSize": "640 480",
|
||||||
|
"Megapixels": 0.3072,
|
||||||
|
"ShutterSpeed": 0.0004940711462,
|
||||||
|
"SubSecCreateDate": "2017:02:15 14:13:40.249",
|
||||||
|
"SubSecDateTimeOriginal": "2017:02:15 14:13:40.249",
|
||||||
|
"GPSAltitude": -1.990417522,
|
||||||
|
"GPSLatitude": -29.2824777777778,
|
||||||
|
"GPSLongitude": 31.4436361111111,
|
||||||
|
"FocalLength35efl": 4.2,
|
||||||
|
"GPSPosition": "-29.2824777777778 31.4436361111111",
|
||||||
|
"LightValue": 15.2580006188259,
|
||||||
|
"LensID": "iPhone SE back camera 4.15mm f/2.2"
|
||||||
|
}]
|
BIN
assets/examples/samsung-motion-photo.jpg
Normal file
BIN
assets/examples/samsung-motion-photo.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.9 MiB |
82
assets/examples/samsung-motion-photo.json
Normal file
82
assets/examples/samsung-motion-photo.json
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
[{
|
||||||
|
"SourceFile": "/go/src/github.com/photoprism/photoprism/storage/import/samsung-motion-photo.jpg",
|
||||||
|
"ExifToolVersion": 12.56,
|
||||||
|
"FileName": "samsung-motion-photo.jpg",
|
||||||
|
"Directory": "/go/src/github.com/photoprism/photoprism/storage/import",
|
||||||
|
"FileSize": 7221645,
|
||||||
|
"FileModifyDate": "2023:07:27 02:03:13+00:00",
|
||||||
|
"FileAccessDate": "2023:08:13 17:11:04+00:00",
|
||||||
|
"FileInodeChangeDate": "2023:08:13 17:11:02+00:00",
|
||||||
|
"FilePermissions": 100644,
|
||||||
|
"FileType": "JPEG",
|
||||||
|
"FileTypeExtension": "JPG",
|
||||||
|
"MIMEType": "image/jpeg",
|
||||||
|
"ExifByteOrder": "II",
|
||||||
|
"Make": "samsung",
|
||||||
|
"Model": "SM-G973F",
|
||||||
|
"Orientation": 1,
|
||||||
|
"XResolution": 72,
|
||||||
|
"YResolution": 72,
|
||||||
|
"ResolutionUnit": 2,
|
||||||
|
"Software": "G973FXXU4CTC9",
|
||||||
|
"ModifyDate": "2020:04:23 18:33:41",
|
||||||
|
"YCbCrPositioning": 1,
|
||||||
|
"ExposureTime": 0.0007575757576,
|
||||||
|
"FNumber": 2.4,
|
||||||
|
"ExposureProgram": 2,
|
||||||
|
"ISO": 50,
|
||||||
|
"ExifVersion": "0220",
|
||||||
|
"DateTimeOriginal": "2020:04:23 18:33:41",
|
||||||
|
"CreateDate": "2020:04:23 18:33:41",
|
||||||
|
"ShutterSpeedValue": 0.999475026346474,
|
||||||
|
"ApertureValue": 2.39495740923786,
|
||||||
|
"BrightnessValue": 22.58,
|
||||||
|
"ExposureCompensation": 0,
|
||||||
|
"MaxApertureValue": 2.39495740923786,
|
||||||
|
"MeteringMode": 2,
|
||||||
|
"Flash": 0,
|
||||||
|
"FocalLength": 4.32,
|
||||||
|
"ColorSpace": 1,
|
||||||
|
"ExifImageWidth": 4032,
|
||||||
|
"ExifImageHeight": 3024,
|
||||||
|
"ExposureMode": 0,
|
||||||
|
"WhiteBalance": 0,
|
||||||
|
"DigitalZoomRatio": 1,
|
||||||
|
"FocalLengthIn35mmFormat": 26,
|
||||||
|
"SceneCaptureType": 0,
|
||||||
|
"ImageUniqueID": "L12XLLD01VM",
|
||||||
|
"GPSLatitudeRef": "N",
|
||||||
|
"GPSLongitudeRef": "W",
|
||||||
|
"Compression": 6,
|
||||||
|
"ThumbnailOffset": 888,
|
||||||
|
"ThumbnailLength": 50555,
|
||||||
|
"XMPToolkit": "Adobe XMP Core 5.1.0-jc003",
|
||||||
|
"MicroVideo": 1,
|
||||||
|
"MicroVideoVersion": 1,
|
||||||
|
"MicroVideoOffset": 4535831,
|
||||||
|
"MicroVideoPresentationTimestampUs": -1,
|
||||||
|
"ImageWidth": 4032,
|
||||||
|
"ImageHeight": 3024,
|
||||||
|
"EncodingProcess": 0,
|
||||||
|
"BitsPerSample": 8,
|
||||||
|
"ColorComponents": 3,
|
||||||
|
"YCbCrSubSampling": "2 2",
|
||||||
|
"TimeStamp": "2020:04:23 17:33:41.809+00:00",
|
||||||
|
"MCCData": 234,
|
||||||
|
"EmbeddedVideoType": "MotionPhoto_Data",
|
||||||
|
"EmbeddedVideoFile": "(Binary data 4535775 bytes, use -b option to extract)",
|
||||||
|
"Aperture": 2.4,
|
||||||
|
"ImageSize": "4032 3024",
|
||||||
|
"Megapixels": 12.192768,
|
||||||
|
"ScaleFactor35efl": 6.01851851851852,
|
||||||
|
"ShutterSpeed": 0.0007575757576,
|
||||||
|
"ThumbnailImage": "(Binary data 50555 bytes, use -b option to extract)",
|
||||||
|
"GPSLatitude": 51.5049828,
|
||||||
|
"GPSLongitude": -0.0787347997222222,
|
||||||
|
"CircleOfConfusion": "0.00499230176602706",
|
||||||
|
"FOV": 69.3903656740024,
|
||||||
|
"FocalLength35efl": 26,
|
||||||
|
"GPSPosition": "51.5049828 -0.0787347997222222",
|
||||||
|
"HyperfocalDistance": 1.55759815100044,
|
||||||
|
"LightValue": 13.8923910258672
|
||||||
|
}]
|
|
@ -65,6 +65,7 @@ type Data struct {
|
||||||
Rotation int `meta:"Rotation"`
|
Rotation int `meta:"Rotation"`
|
||||||
Views int `meta:"-"`
|
Views int `meta:"-"`
|
||||||
Albums []string `meta:"-"`
|
Albums []string `meta:"-"`
|
||||||
|
EmbeddedVideo string `meta:"EmbeddedVideo"`
|
||||||
Error error `meta:"-"`
|
Error error `meta:"-"`
|
||||||
json map[string]string
|
json map[string]string
|
||||||
exif map[string]string
|
exif map[string]string
|
||||||
|
|
|
@ -365,5 +365,12 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
|
||||||
data.Subject = SanitizeMeta(data.Subject)
|
data.Subject = SanitizeMeta(data.Subject)
|
||||||
data.Artist = SanitizeMeta(data.Artist)
|
data.Artist = SanitizeMeta(data.Artist)
|
||||||
|
|
||||||
|
// set the embedded video file name, if it exists
|
||||||
|
if embeddedVideo, ok := data.json["EmbeddedVideoFile"]; ok && embeddedVideo != "" {
|
||||||
|
data.EmbeddedVideo = "EmbeddedVideoFile"
|
||||||
|
} else if embeddedVideo, ok := data.json["MotionPhotoVideo"]; ok && embeddedVideo != "" {
|
||||||
|
data.EmbeddedVideo = "MotionPhotoVideo"
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package photoprism
|
package photoprism
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
|
@ -9,6 +11,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
@ -314,6 +317,82 @@ func (m *MediaFile) EditedName() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MediaFile) ExtractEmbeddedVideo() (string, error) {
|
||||||
|
if m == nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"mediafile: file is nil - you may have found a" +
|
||||||
|
" bug",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort if the source media file does not exist.
|
||||||
|
if !m.Exists() {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"mediafile: %s not found", clean.Log(m.RootRelName()),
|
||||||
|
)
|
||||||
|
} else if m.Empty() {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"mediafile: %s is empty", clean.Log(m.RootRelName()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the embedded video field name from the file metadata
|
||||||
|
if metaData := m.MetaData(); metaData.Error == nil && metaData.
|
||||||
|
EmbeddedVideo != "" {
|
||||||
|
outputPath := filepath.Join(Config().TempPath(), m.RootRelPath(), "%f")
|
||||||
|
cmd := exec.Command(
|
||||||
|
Config().ExifToolBin(), fmt.Sprintf("-%s", metaData.EmbeddedVideo),
|
||||||
|
"-b", "-w",
|
||||||
|
outputPath, m.FileName(),
|
||||||
|
)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
cmd.Env = []string{fmt.Sprintf("HOME=%s", Config().TempPath())}
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
log.Debugf("Error running exiftool on video file: ", err)
|
||||||
|
if stderr.String() != "" {
|
||||||
|
return "", errors.New(stderr.String())
|
||||||
|
} else {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// find the extracted video path
|
||||||
|
outputPath = strings.Replace(outputPath, "%f", m.BasePrefix(false), 1)
|
||||||
|
|
||||||
|
// detect mime type of the extracted video
|
||||||
|
mimeType := fs.MimeType(outputPath)
|
||||||
|
|
||||||
|
if len := len(strings.Split(mimeType, "/")); len <= 1 {
|
||||||
|
log.Debugf(
|
||||||
|
"Error detecting the mime type of video file at %s",
|
||||||
|
outputPath,
|
||||||
|
)
|
||||||
|
return "", nil
|
||||||
|
} else if extension := strings.Split(
|
||||||
|
mimeType, "/",
|
||||||
|
)[len-1]; extension != "" {
|
||||||
|
// rename the extracted video file with the correct extension and
|
||||||
|
// move it to the sidecar path
|
||||||
|
_, file := filepath.Split(outputPath)
|
||||||
|
newFileName := fmt.Sprintf("%s.%s", file, extension)
|
||||||
|
dstPath := filepath.Join(
|
||||||
|
Config().SidecarPath(), m.RootRelPath(), newFileName,
|
||||||
|
)
|
||||||
|
if err := fs.Move(outputPath, dstPath); err != nil {
|
||||||
|
log.Debugf("Error moving the video file at %s", outputPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dstPath, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// PathNameInfo returns file name infos for indexing.
|
// PathNameInfo returns file name infos for indexing.
|
||||||
func (m *MediaFile) PathNameInfo(stripSequence bool) (fileRoot, fileBase, relativePath, relativeName string) {
|
func (m *MediaFile) PathNameInfo(stripSequence bool) (fileRoot, fileBase, relativePath, relativeName string) {
|
||||||
fileRoot = m.Root()
|
fileRoot = m.Root()
|
||||||
|
@ -946,13 +1025,25 @@ func (m *MediaFile) HasPreviewImage() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
jpegName := fs.ImageJPEG.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false)
|
jpegName := fs.ImageJPEG.FindFirst(
|
||||||
|
m.FileName(),
|
||||||
|
[]string{
|
||||||
|
Config().SidecarPath(),
|
||||||
|
fs.HiddenPath,
|
||||||
|
},
|
||||||
|
Config().OriginalsPath(), false,
|
||||||
|
)
|
||||||
|
|
||||||
if m.hasPreviewImage = fs.MimeType(jpegName) == fs.MimeTypeJPEG; m.hasPreviewImage {
|
if m.hasPreviewImage = fs.MimeType(jpegName) == fs.MimeTypeJPEG; m.hasPreviewImage {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
pngName := fs.ImagePNG.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false)
|
pngName := fs.ImagePNG.FindFirst(
|
||||||
|
m.FileName(),
|
||||||
|
[]string{
|
||||||
|
Config().SidecarPath(), fs.HiddenPath,
|
||||||
|
}, Config().OriginalsPath(), false,
|
||||||
|
)
|
||||||
|
|
||||||
if m.hasPreviewImage = fs.MimeType(pngName) == fs.MimeTypePNG; m.hasPreviewImage {
|
if m.hasPreviewImage = fs.MimeType(pngName) == fs.MimeTypePNG; m.hasPreviewImage {
|
||||||
return true
|
return true
|
||||||
|
|
|
@ -18,7 +18,7 @@ func TestMediaFile_HasSidecarJson(t *testing.T) {
|
||||||
t.Run("false", func(t *testing.T) {
|
t.Run("false", func(t *testing.T) {
|
||||||
conf := config.TestConfig()
|
conf := config.TestConfig()
|
||||||
|
|
||||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_sand.jpg")
|
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg")
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
|
@ -55,6 +55,13 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e
|
||||||
matches = append(matches, name)
|
matches = append(matches, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check for an embedded video in the media file
|
||||||
|
if embeddedVideoName, err := m.ExtractEmbeddedVideo(); err != nil {
|
||||||
|
return result, err
|
||||||
|
} else if embeddedVideoName != "" {
|
||||||
|
matches = append(matches, embeddedVideoName)
|
||||||
|
}
|
||||||
|
|
||||||
isHEIC := false
|
isHEIC := false
|
||||||
|
|
||||||
for _, fileName := range matches {
|
for _, fileName := range matches {
|
||||||
|
|
|
@ -2610,3 +2610,66 @@ func TestMediaFile_Duration(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMediaFile_ExtractEmbeddedVideo(t *testing.T) {
|
||||||
|
conf := config.TestConfig()
|
||||||
|
t.Run(
|
||||||
|
"samsung-motion-photo.jpg", func(t *testing.T) {
|
||||||
|
// input image file
|
||||||
|
fileName := filepath.Join(
|
||||||
|
conf.ExamplesPath(),
|
||||||
|
"samsung-motion-photo.jpg",
|
||||||
|
)
|
||||||
|
// expected output video file
|
||||||
|
outputName := filepath.Join(
|
||||||
|
conf.SidecarPath(), "samsung-motion-photo.mp4",
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = os.Remove(outputName)
|
||||||
|
|
||||||
|
mf, err := NewMediaFile(fileName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract the video
|
||||||
|
if embeddedVideoName, err := mf.ExtractEmbeddedVideo(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if embeddedVideoName == "" {
|
||||||
|
t.Errorf("embeddedVideoName should not be empty")
|
||||||
|
} else {
|
||||||
|
t.Logf(embeddedVideoName)
|
||||||
|
assert.Equal(t, embeddedVideoName, outputName)
|
||||||
|
assert.Truef(
|
||||||
|
t, fs.FileExists(embeddedVideoName),
|
||||||
|
"output file does not exist: %s", embeddedVideoName,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = os.Remove(outputName)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"beach_sand.jpg", func(t *testing.T) {
|
||||||
|
// input image file
|
||||||
|
fileName := filepath.Join(
|
||||||
|
conf.ExamplesPath(),
|
||||||
|
"beach_sand.jpg",
|
||||||
|
)
|
||||||
|
|
||||||
|
mf, err := NewMediaFile(fileName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if embeddedVideoName, err := mf.ExtractEmbeddedVideo(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if embeddedVideoName != "" {
|
||||||
|
t.Errorf("expected embeddedVideoName to be empty")
|
||||||
|
} else {
|
||||||
|
t.Logf(embeddedVideoName)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue