Index: Add native support for MP4 and Samsung/Google Motion Photos #439

Related Issues:
- Samsung: Initial support for Motion Photos (#439)
- Google: Initial support for Motion Photos (#1739)
- Metadata: Flag Samsung/Google Motion Photos as Live Photos (#2788)

Related Pull Requests:
- Live Photos: Add Support for Samsung Motion Photos (#3588)
- Samsung: Improved support for Motion Photos (#3660)
- Google: Initial support for Motion Photos (#3709)
- Google: Add support for Motion Photos (#3722)

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-09-22 23:59:56 +02:00
parent 31a2cff5b6
commit 529103462c
74 changed files with 3279 additions and 446 deletions

18
go.mod
View file

@ -75,7 +75,6 @@ require github.com/go-ldap/ldap/v3 v3.4.6
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/bytedance/sonic v1.10.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
@ -84,10 +83,8 @@ require (
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
github.com/go-errors/errors v1.5.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.15.3 // indirect
github.com/go-sql-driver/mysql v1.5.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
@ -100,23 +97,32 @@ require (
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.5.0 // indirect
golang.org/x/oauth2 v0.6.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
github.com/abema/go-mp4 v0.13.0
github.com/bytedance/sonic v1.10.0 // indirect
github.com/go-errors/errors v1.5.0 // indirect
github.com/go-playground/validator/v10 v10.15.3 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/sunfish-shogi/bufseekio v0.1.0
golang.org/x/arch v0.5.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)
require (
github.com/emersion/go-webdav v0.4.0
github.com/mattn/go-runewidth v0.0.13 // indirect

12
go.sum
View file

@ -24,6 +24,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/abema/go-mp4 v0.13.0 h1:gjEZLt7g0ePpYA5sUDrI2r8X+WuI8o+USkgG5wMgmkI=
github.com/abema/go-mp4 v0.13.0/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
@ -59,6 +61,7 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -218,6 +221,7 @@ github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OI
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -270,6 +274,7 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
@ -313,6 +318,8 @@ github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/paulmach/go.geojson v1.5.0 h1:7mhpMK89SQdHFcEGomT7/LuJhwhEgfmpWYVlVmLEdQw=
@ -370,6 +377,9 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
github.com/sunfish-shogi/bufseekio v0.1.0 h1:zu38kFbv0KuuiwZQeuYeS02U9AM14j0pVA9xkHOCJ2A=
github.com/sunfish-shogi/bufseekio v0.1.0/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU=
github.com/tensorflow/tensorflow v1.15.2 h1:7/f/A664Tml/nRJg04+p3StcrsT53mkcvmxYHXI21Qo=
github.com/tensorflow/tensorflow v1.15.2/go.mod h1:itOSERT4trABok4UOoG+X4BoKds9F3rIsySdn+Lvu90=
@ -694,6 +704,8 @@ gopkg.in/photoprism/go-tz.v2 v2.1.1 h1:XdNAQRneJmJdXDFovXJbf5eewp3zsir+jJ1Bxdmbn
gopkg.in/photoprism/go-tz.v2 v2.1.1/go.mod h1:E1aQvLJs3YA4wbrPMOdX4YEx1TgRO2PLSxnO+J1Kqiw=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View file

@ -11,10 +11,6 @@ import (
"github.com/photoprism/photoprism/internal/session"
)
const (
ContentTypeAvc = `video/mp4; codecs="avc1"`
)
// AddCountHeader adds the actual result count to the response.
func AddCountHeader(c *gin.Context, count int) {
c.Header("X-Count", strconv.Itoa(count))

View file

@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
@ -64,14 +65,35 @@ func GetVideo(router *gin.RouterGroup) {
return
}
conf := get.Config()
fileName := photoprism.FileName(f.FileRoot, f.FileName)
// If file is not a video, try to find and stream embedded video data.
if f.MediaType != entity.MediaVideo {
if info, videoErr := video.ProbeFile(fileName); info.VideoOffset < 0 || !info.Compatible || videoErr != nil {
logError("video", videoErr)
log.Warnf("video: no data found in %s", clean.Log(f.FileName))
AddContentTypeHeader(c, video.ContentTypeAVC)
c.File(get.Config().StaticFile("video/404.mp4"))
} else if reader, readErr := video.NewReader(fileName, info.VideoOffset); readErr != nil {
log.Errorf("video: failed to read data from %s (%s)", clean.Log(f.FileName), readErr)
AddContentTypeHeader(c, video.ContentTypeAVC)
c.File(get.Config().StaticFile("video/404.mp4"))
} else {
defer reader.Close()
AddVideoCacheHeader(c, conf.CdnVideo())
c.DataFromReader(http.StatusOK, info.VideoSize(), info.VideoContentType(), reader, nil)
}
return
}
fileBitrate := f.Bitrate()
// File format supported by the client/browser?
supported := f.FileCodec != "" && f.FileCodec == string(format.Codec) || format.Codec == video.UnknownCodec && f.FileType == string(format.File)
supported := f.FileCodec != "" && f.FileCodec == string(format.Codec) || format.Codec == video.CodecUnknown && f.FileType == string(format.FileType)
// File bitrate too high (for streaming)?
conf := get.Config()
transcode := !supported || conf.FFmpegEnabled() && conf.FFmpegBitrateExceeded(fileBitrate)
if mf, err := photoprism.NewMediaFile(fileName); err != nil {
@ -81,7 +103,7 @@ func GetVideo(router *gin.RouterGroup) {
// Log error and default to 404.mp4
log.Errorf("video: file %s is missing", clean.Log(f.FileName))
fileName = get.Config().StaticFile("video/404.mp4")
AddContentTypeHeader(c, ContentTypeAvc)
AddContentTypeHeader(c, video.ContentTypeAVC)
} else if transcode {
if f.FileCodec != "" {
log.Debugf("video: %s is %s compressed and cannot be streamed directly, average bitrate %.1f MBit/s", clean.Log(f.FileName), clean.Log(strings.ToUpper(f.FileCodec)), fileBitrate)
@ -99,7 +121,7 @@ func GetVideo(router *gin.RouterGroup) {
fileName = get.Config().StaticFile("video/404.mp4")
}
AddContentTypeHeader(c, ContentTypeAvc)
AddContentTypeHeader(c, video.ContentTypeAVC)
} else {
if f.FileCodec != "" && f.FileCodec != f.FileType {
log.Debugf("video: %s is %s compressed and requires no transcoding, average bitrate %.1f MBit/s", clean.Log(f.FileName), clean.Log(strings.ToUpper(f.FileCodec)), fileBitrate)

View file

@ -9,11 +9,12 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/video"
)
func TestGetVideo(t *testing.T) {
t.Run("ContentTypeAvc", func(t *testing.T) {
assert.Equal(t, ContentTypeAvc, fmt.Sprintf("%s; codecs=\"%s\"", "video/mp4", clean.Codec("avc1")))
t.Run("ContentTypeAVC", func(t *testing.T) {
assert.Equal(t, video.ContentTypeAVC, fmt.Sprintf("%s; codecs=\"%s\"", "video/mp4", clean.Codec("avc1")))
})
t.Run("InvalidHash", func(t *testing.T) {

View file

@ -1,18 +1,36 @@
package entity
var CameraMakes = map[string]string{
"apple": "Apple",
"google": "Google",
"samsung": "Samsung",
"OLYMPUS": "Olympus",
"OLYMPUS CORPORATION": "Olympus",
"OLYMPUS DIGITAL CAMERA": "Olympus",
"OLYMPUS IMAGING CORP.": "Olympus",
"OLYMPUS OPTICAL CO.,LTD": "Olympus",
"samsung": "Samsung",
}
var CameraModels = map[string]string{
"iPhone SE (1st generation)": "iPhone SE",
"iPhone SE (2nd generation)": "iPhone SE",
"iPhone SE (3rd generation)": "iPhone SE",
"SM-G780F": "Galaxy S20",
"SM-G781B": "Galaxy S20 FE",
"SM-G991A": "Galaxy S21",
"SM-G991B": "Galaxy S21",
"SM-G990A": "Galaxy S21 FE",
"SM-G990B": "Galaxy S21 FE",
"SM-G996A": "Galaxy S21+",
"SM-G996B": "Galaxy S21+",
"SM-G998A": "Galaxy S21 Ultra",
"SM-G998B": "Galaxy S21 Ultra",
"SM-S911A": "Galaxy S23",
"SM-S911B": "Galaxy S23",
"SM-S916A": "Galaxy S23+",
"SM-S916B": "Galaxy S23+",
"SM-S918A": "Galaxy S23 Ultra",
"SM-S918B": "Galaxy S23 Ultra",
"WAS-LX1": "P10 lite",
"WAS-LX2": "P10 lite",
"WAS-LX3": "P10 lite",

View file

@ -4,7 +4,7 @@ import (
"github.com/photoprism/photoprism/pkg/media"
)
// Default values.
// Defaults.
const (
Unknown = ""
UnknownYear = -1
@ -15,7 +15,7 @@ const (
UnknownID = "zz"
)
// Media content types.
// Media types.
const (
MediaUnknown = ""
MediaImage = string(media.Image)
@ -27,7 +27,7 @@ const (
MediaText = string(media.Text)
)
// Storage root folders.
// Base folders.
const (
RootUnknown = ""
RootOriginals = "/"
@ -37,14 +37,14 @@ const (
RootPath = "/"
)
// Event type.
// Event types.
const (
Created = "created"
Updated = "updated"
Deleted = "deleted"
)
// Photo stack states.
// Stacking states.
const (
IsStacked int8 = 1
IsStackable int8 = 0

View file

@ -31,10 +31,10 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd,
result = exec.Command(
ffmpeg,
"-i", fileName,
"-movflags", "faststart",
"-pix_fmt", FormatYUV420P.String(),
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
"-y",
avcName,
)
@ -63,6 +63,7 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd,
"-b:v", opt.Bitrate,
"-bitrate", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
"-y",
avcName,
)
@ -82,6 +83,7 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd,
"-r", "30",
"-b:v", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart",
"-y",
avcName,
)
@ -99,6 +101,7 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd,
"-r", "30",
"-b:v", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
"-y",
avcName,
)
@ -127,6 +130,7 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd,
"-level:v", "auto",
"-coder:v", "1",
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
"-y",
avcName,
)
@ -148,6 +152,7 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd,
"-r", "30",
"-b:v", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
"-y",
avcName,
)
@ -166,6 +171,7 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd,
"-r", "30",
"-b:v", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
"-y",
avcName,
)

View file

@ -47,7 +47,7 @@ func TestAvcConvertCommand(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "/usr/bin/ffmpeg -i VID123.gif -movflags faststart -pix_fmt yuv420p -vf scale=trunc(iw/2)*2:trunc(ih/2)*2 -f mp4 -y VID123.gif.avc", r.String())
assert.Equal(t, "/usr/bin/ffmpeg -i VID123.gif -pix_fmt yuv420p -vf scale=trunc(iw/2)*2:trunc(ih/2)*2 -f mp4 -movflags +faststart -y VID123.gif.avc", r.String())
})
t.Run("libx264", func(t *testing.T) {
Options := Options{
@ -63,7 +63,7 @@ func TestAvcConvertCommand(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "/usr/bin/ffmpeg -i VID123.mov -c:v libx264 -map 0:v:0 -map 0:a:0? -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -max_muxing_queue_size 1024 -crf 23 -r 30 -b:v 50 -f mp4 -y VID123.mov.avc", r.String())
assert.Equal(t, "/usr/bin/ffmpeg -i VID123.mov -c:v libx264 -map 0:v:0 -map 0:a:0? -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -max_muxing_queue_size 1024 -crf 23 -r 30 -b:v 50 -f mp4 -movflags +faststart -y VID123.mov.avc", r.String())
})
t.Run("h264_qsv", func(t *testing.T) {
Options := Options{
@ -79,7 +79,7 @@ func TestAvcConvertCommand(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "/usr/bin/ffmpeg -qsv_device /dev/dri/renderD128 -i VID123.mov -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=rgb32 -c:v h264_qsv -map 0:v:0 -map 0:a:0? -r 30 -b:v 50 -bitrate 50 -f mp4 -y VID123.mov.avc", r.String())
assert.Equal(t, "/usr/bin/ffmpeg -qsv_device /dev/dri/renderD128 -i VID123.mov -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=rgb32 -c:v h264_qsv -map 0:v:0 -map 0:a:0? -r 30 -b:v 50 -bitrate 50 -f mp4 -movflags +faststart -y VID123.mov.avc", r.String())
})
t.Run("h264_videotoolbox", func(t *testing.T) {
Options := Options{
@ -95,7 +95,7 @@ func TestAvcConvertCommand(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "/usr/bin/ffmpeg -i VID123.mov -c:v h264_videotoolbox -map 0:v:0 -map 0:a:0? -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -profile high -level 51 -r 30 -b:v 50 -f mp4 -y VID123.mov.avc", r.String())
assert.Equal(t, "/usr/bin/ffmpeg -i VID123.mov -c:v h264_videotoolbox -map 0:v:0 -map 0:a:0? -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -profile high -level 51 -r 30 -b:v 50 -f mp4 -movflags +faststart -y VID123.mov.avc", r.String())
})
t.Run("h264_vaapi", func(t *testing.T) {
Options := Options{
@ -111,7 +111,7 @@ func TestAvcConvertCommand(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "/usr/bin/ffmpeg -hwaccel vaapi -i VID123.mov -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=nv12,hwupload -c:v h264_vaapi -map 0:v:0 -map 0:a:0? -r 30 -b:v 50 -f mp4 -y VID123.mov.avc", r.String())
assert.Equal(t, "/usr/bin/ffmpeg -hwaccel vaapi -i VID123.mov -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=nv12,hwupload -c:v h264_vaapi -map 0:v:0 -map 0:a:0? -r 30 -b:v 50 -f mp4 -movflags +faststart -y VID123.mov.avc", r.String())
})
t.Run("h264_nvenc", func(t *testing.T) {
Options := Options{
@ -127,7 +127,7 @@ func TestAvcConvertCommand(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "/usr/bin/ffmpeg -hwaccel auto -i VID123.mov -pix_fmt yuv420p -c:v h264_nvenc -map 0:v:0 -map 0:a:0? -c:a aac -preset 15 -pixel_format yuv420p -gpu any -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -rc:v constqp -cq 0 -tune 2 -r 30 -b:v 50 -profile:v 1 -level:v auto -coder:v 1 -f mp4 -y VID123.mov.avc", r.String())
assert.Equal(t, "/usr/bin/ffmpeg -hwaccel auto -i VID123.mov -pix_fmt yuv420p -c:v h264_nvenc -map 0:v:0 -map 0:a:0? -c:a aac -preset 15 -pixel_format yuv420p -gpu any -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -rc:v constqp -cq 0 -tune 2 -r 30 -b:v 50 -profile:v 1 -level:v auto -coder:v 1 -f mp4 -movflags +faststart -y VID123.mov.avc", r.String())
})
t.Run("h264_v4l2m2m", func(t *testing.T) {
Options := Options{
@ -143,6 +143,6 @@ func TestAvcConvertCommand(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "/usr/bin/ffmpeg -i VID123.mov -c:v h264_v4l2m2m -map 0:v:0 -map 0:a:0? -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -num_output_buffers 72 -num_capture_buffers 64 -max_muxing_queue_size 1024 -crf 23 -r 30 -b:v 50 -f mp4 -y VID123.mov.avc", r.String())
assert.Equal(t, "/usr/bin/ffmpeg -i VID123.mov -c:v h264_v4l2m2m -map 0:v:0 -map 0:a:0? -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -num_output_buffers 72 -num_capture_buffers 64 -max_muxing_queue_size 1024 -crf 23 -r 30 -b:v 50 -f mp4 -movflags +faststart -y VID123.mov.avc", r.String())
})
}

View file

@ -30,7 +30,7 @@ func TestImage(t *testing.T) {
_ = os.Remove(saveName)
})
t.Run("UnknownType", func(t *testing.T) {
t.Run("TypeUnknown", func(t *testing.T) {
img, err := imaging.Open("testdata/500x500.jpg")
assert.NoError(t, err)

View file

@ -4,6 +4,7 @@ import (
"math"
"time"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/s2"
)
@ -15,6 +16,7 @@ const (
// Data represents image metadata.
type Data struct {
FileName string `meta:"FileName"`
MimeType string `meta:"MIMEType"`
DocumentID string `meta:"BurstUUID,MediaGroupUUID,ImageUniqueID,OriginalDocumentID,DocumentID,DigitalImageGUID"`
InstanceID string `meta:"InstanceID,DocumentID"`
CreatedAt time.Time `meta:"SubSecCreateDate,CreationDate,CreateDate,MediaCreateDate,ContentCreateDate,TrackCreateDate"`
@ -23,6 +25,9 @@ type Data struct {
TakenGps time.Time `meta:"GPSDateTime,GPSDateStamp"`
TakenNs int `meta:"-"`
TimeZone string `meta:"-"`
MediaType media.Type `meta:"-"`
EmbeddedThumb bool `meta:"ThumbnailImage,PhotoshopThumbnail"`
EmbeddedVideo bool `meta:"EmbeddedVideoFile,MotionPhoto,MotionPhotoVideo,MicroVideo"`
Duration time.Duration `meta:"Duration,MediaDuration,TrackDuration,PreviewDuration"`
FPS float64 `meta:"VideoFrameRate,VideoAvgFrameRate"`
Frames int `meta:"FrameCount,AnimationFrames"`
@ -65,14 +70,14 @@ type Data struct {
Rotation int `meta:"Rotation"`
Views int `meta:"-"`
Albums []string `meta:"-"`
EmbeddedVideo string `meta:"-"`
Warning string `meta:"Warning"`
Error error `meta:"-"`
json map[string]string
exif map[string]string
}
// New returns a new metadata struct.
func New() Data {
// NewData returns a new Data struct with default values.
func NewData() Data {
return Data{}
}

View file

@ -13,6 +13,8 @@ import (
"gopkg.in/photoprism/go-tz.v2/tz"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/projection"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
@ -154,7 +156,20 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
continue
}
fieldValue.SetBool(jsonValue.Bool())
boolVal := false
strVal := jsonValue.String()
// Cast string to bool.
switch strVal {
case "1", "true":
boolVal = true
case "", "0", "false":
boolVal = false
default:
boolVal = txt.NotEmpty(strVal)
}
fieldValue.SetBool(boolVal)
default:
log.Warnf("metadata: cannot assign value of type %s to %s (exiftool)", t, tagValue)
}
@ -365,11 +380,9 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
data.Subject = SanitizeMeta(data.Subject)
data.Artist = SanitizeMeta(data.Artist)
// Set the name of the embedded video data field, if any.
if embeddedVideo, ok := data.json["EmbeddedVideoFile"]; ok && embeddedVideo != "" {
data.EmbeddedVideo = "EmbeddedVideoFile"
} else if embeddedVideo, ok = data.json["MotionPhotoVideo"]; ok && embeddedVideo != "" {
data.EmbeddedVideo = "MotionPhotoVideo"
// Flag Samsung/Google Motion Photos as live media.
if data.EmbeddedVideo && (data.MimeType == fs.MimeTypeJPEG || data.MimeType == fs.MimeTypeHEIC) {
data.MediaType = media.Live
}
return nil

View file

@ -0,0 +1,291 @@
package meta
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/media"
)
func TestJSON_Motion(t *testing.T) {
t.Run("GooglePixel2_JPG", func(t *testing.T) {
data, err := JSON("testdata/motion/google_pixel2.jpg.json", "")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %#v", data)
assert.Equal(t, "pixel2.jpg", data.FileName)
assert.Equal(t, media.Live, data.MediaType)
assert.Equal(t, true, data.EmbeddedThumb)
assert.Equal(t, true, data.EmbeddedVideo)
assert.Equal(t, CodecJpeg, data.Codec)
assert.Equal(t, int64(0), data.Duration.Milliseconds())
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, "2018-03-18 19:21:15 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2018-03-18 23:21:15 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 796940000, data.TakenNs)
assert.Equal(t, "America/New_York", data.TimeZone)
assert.Equal(t, 3024, data.Width)
assert.Equal(t, 4032, data.Height)
assert.Equal(t, 3024, data.ActualWidth())
assert.Equal(t, 4032, data.ActualHeight())
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, float32(35.42307), data.Lat)
assert.Equal(t, float32(-78.65212), data.Lng)
assert.Equal(t, "Google", data.CameraMake)
assert.Equal(t, "Pixel 2", data.CameraModel)
assert.Equal(t, "", data.LensModel)
})
t.Run("GooglePixel4a_JPG", func(t *testing.T) {
data, err := JSON("testdata/motion/google_pixel4a.jpg.json", "")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %#v", data)
assert.Equal(t, "pixel4a.jpg", data.FileName)
assert.Equal(t, media.Live, data.MediaType)
assert.Equal(t, false, data.EmbeddedThumb)
assert.Equal(t, true, data.EmbeddedVideo)
assert.Equal(t, CodecJpeg, data.Codec)
assert.Equal(t, int64(0), data.Duration.Milliseconds())
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, "2021-09-17 19:31:36 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2021-09-17 23:31:36 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 844000000, data.TakenNs)
assert.Equal(t, "America/New_York", data.TimeZone)
assert.Equal(t, 4032, data.Width)
assert.Equal(t, 3024, data.Height)
assert.Equal(t, 3024, data.ActualWidth())
assert.Equal(t, 4032, data.ActualHeight())
assert.Equal(t, 6, data.Orientation)
assert.Equal(t, float32(35.778152), data.Lat)
assert.Equal(t, float32(-78.63687), data.Lng)
assert.Equal(t, "Google", data.CameraMake)
assert.Equal(t, "Pixel 4a", data.CameraModel)
assert.Equal(t, "", data.LensModel)
})
t.Run("GooglePixel6_JPG", func(t *testing.T) {
data, err := JSON("testdata/motion/google_pixel6.jpg.json", "")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %#v", data)
assert.Equal(t, "PXL_20211227_151322429.MP.jpg", data.FileName)
assert.Equal(t, media.Live, data.MediaType)
assert.Equal(t, true, data.EmbeddedThumb)
assert.Equal(t, true, data.EmbeddedVideo)
assert.Equal(t, CodecJpeg, data.Codec)
assert.Equal(t, int64(0), data.Duration.Milliseconds())
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, "2021-12-27 16:13:22 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2021-12-27 15:13:22 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 429000000, data.TakenNs)
assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, 4032, data.Width)
assert.Equal(t, 2268, data.Height)
assert.Equal(t, 4032, data.ActualWidth())
assert.Equal(t, 2268, data.ActualHeight())
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, float32(48.610027), data.Lat)
assert.Equal(t, float32(8.861558), data.Lng)
assert.Equal(t, "Google", data.CameraMake)
assert.Equal(t, "Pixel 6", data.CameraModel)
assert.Equal(t, "Pixel 6 back camera 6.81mm f/1.85", data.LensModel)
})
t.Run("GooglePixel7Pro_JPG", func(t *testing.T) {
data, err := JSON("testdata/motion/google_pixel7pro.jpg.json", "")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %#v", data)
assert.Equal(t, "PXL_20230329_144843201.MP.jpg", data.FileName)
assert.Equal(t, media.Live, data.MediaType)
assert.Equal(t, true, data.EmbeddedThumb)
assert.Equal(t, true, data.EmbeddedVideo)
assert.Equal(t, CodecJpeg, data.Codec)
assert.Equal(t, int64(0), data.Duration.Milliseconds())
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, "2023-03-29 15:48:43 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2023-03-29 14:48:43 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 201000000, data.TakenNs)
assert.Equal(t, "Europe/London", data.TimeZone)
assert.Equal(t, 4080, data.Width)
assert.Equal(t, 3072, data.Height)
assert.Equal(t, 4080, data.ActualWidth())
assert.Equal(t, 3072, data.ActualHeight())
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, float32(52.70636), data.Lat)
assert.Equal(t, float32(-2.7605944), data.Lng)
assert.Equal(t, "Google", data.CameraMake)
assert.Equal(t, "Pixel 7 Pro", data.CameraModel)
assert.Equal(t, "Pixel 7 Pro back camera 19.0mm f/3.5", data.LensModel)
})
t.Run("SamsungGalaxyS20_JPG", func(t *testing.T) {
data, err := JSON("testdata/motion/samsung_galaxys20.jpg.json", "")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %#v", data)
assert.Equal(t, "20230822_143803.jpg", data.FileName)
assert.Equal(t, media.Live, data.MediaType)
assert.Equal(t, true, data.EmbeddedThumb)
assert.Equal(t, true, data.EmbeddedVideo)
assert.Equal(t, CodecJpeg, data.Codec)
assert.Equal(t, int64(0), data.Duration.Milliseconds())
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, "2023-08-22 14:38:03 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2023-08-22 14:38:03 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 583000000, data.TakenNs)
assert.Equal(t, "", data.TimeZone)
assert.Equal(t, 4032, data.Width)
assert.Equal(t, 3024, data.Height)
assert.Equal(t, 3024, data.ActualWidth())
assert.Equal(t, 4032, data.ActualHeight())
assert.Equal(t, 6, data.Orientation)
assert.Equal(t, float32(0), data.Lat)
assert.Equal(t, float32(0), data.Lng)
assert.Equal(t, "samsung", data.CameraMake)
assert.Equal(t, "SM-G780F", data.CameraModel)
assert.Equal(t, "", data.LensModel)
})
t.Run("SamsungGalaxyS20_MP4", func(t *testing.T) {
data, err := JSON("testdata/motion/samsung_galaxys20.mp4.json", "")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %#v", data)
assert.Equal(t, "20230822_143803.mp4", data.FileName)
assert.Equal(t, media.Unknown, data.MediaType)
assert.Equal(t, false, data.EmbeddedThumb)
assert.Equal(t, false, data.EmbeddedVideo)
assert.Equal(t, CodecAvc1, data.Codec)
assert.Equal(t, int64(2990), data.Duration.Milliseconds())
assert.Equal(t, "2.99s", data.Duration.String())
assert.Equal(t, "2023-08-22 14:38:06 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2023-08-22 11:38:06 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 0, data.TakenNs)
assert.Equal(t, "Europe/Kiev", data.TimeZone)
assert.Equal(t, 1440, data.Width)
assert.Equal(t, 1080, data.Height)
assert.Equal(t, 1080, data.ActualWidth())
assert.Equal(t, 1440, data.ActualHeight())
assert.Equal(t, 6, data.Orientation)
assert.Equal(t, float32(48.4565), data.Lat)
assert.Equal(t, float32(35.072), data.Lng)
assert.Equal(t, "", data.CameraMake)
assert.Equal(t, "", data.CameraModel)
assert.Equal(t, "", data.LensModel)
})
t.Run("SamsungGalaxyS20FE_HEIF", func(t *testing.T) {
data, err := JSON("testdata/motion/samsung_galaxys20fe.heif.json", "")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %#v", data)
assert.Equal(t, "20220423_085935.heif", data.FileName)
assert.Equal(t, media.Live, data.MediaType)
assert.Equal(t, false, data.EmbeddedThumb)
assert.Equal(t, true, data.EmbeddedVideo)
assert.Equal(t, CodecHeic, data.Codec)
assert.Equal(t, int64(0), data.Duration.Milliseconds())
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, "2022-04-23 08:59:35 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2022-04-23 06:59:35 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 0, data.TakenNs)
assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, 4032, data.Width)
assert.Equal(t, 3024, data.Height)
assert.Equal(t, 3024, data.ActualWidth())
assert.Equal(t, 4032, data.ActualHeight())
assert.Equal(t, 6, data.Orientation)
assert.Equal(t, float32(51.433468), data.Lat)
assert.Equal(t, float32(12.110732), data.Lng)
assert.Equal(t, "samsung", data.CameraMake)
assert.Equal(t, "SM-G781B", data.CameraModel)
assert.Equal(t, "", data.LensModel)
})
t.Run("SamsungGalaxyS21Ultra_JPG", func(t *testing.T) {
data, err := JSON("testdata/motion/samsung_galaxys21ultra.jpg.json", "")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %#v", data)
assert.Equal(t, "20211011_113427.jpg", data.FileName)
assert.Equal(t, media.Live, data.MediaType)
assert.Equal(t, true, data.EmbeddedThumb)
assert.Equal(t, true, data.EmbeddedVideo)
assert.Equal(t, CodecJpeg, data.Codec)
assert.Equal(t, int64(0), data.Duration.Milliseconds())
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, "2021-10-11 11:34:27 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2021-10-11 11:34:27 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 0, data.TakenNs)
assert.Equal(t, "", data.TimeZone)
assert.Equal(t, 4000, data.Width)
assert.Equal(t, 2252, data.Height)
assert.Equal(t, 4000, data.ActualWidth())
assert.Equal(t, 2252, data.ActualHeight())
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, float32(0), data.Lat)
assert.Equal(t, float32(0), data.Lng)
assert.Equal(t, "samsung", data.CameraMake)
assert.Equal(t, "SM-G998B", data.CameraModel)
assert.Equal(t, "", data.LensModel)
})
t.Run("SamsungGalaxyS21Ultra_MP4", func(t *testing.T) {
data, err := JSON("testdata/motion/samsung_galaxys21ultra.mp4.json", "")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %#v", data)
assert.Equal(t, "20211011_113427.mp4", data.FileName)
assert.Equal(t, media.Unknown, data.MediaType)
assert.Equal(t, false, data.EmbeddedThumb)
assert.Equal(t, false, data.EmbeddedVideo)
assert.Equal(t, CodecAvc1, data.Codec)
assert.Equal(t, int64(2670), data.Duration.Milliseconds())
assert.Equal(t, "2.67s", data.Duration.String())
assert.Equal(t, "2021-10-11 09:34:29 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2021-10-11 09:34:29 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 0, data.TakenNs)
assert.Equal(t, "UTC", data.TimeZone)
assert.Equal(t, 1920, data.Width)
assert.Equal(t, 1080, data.Height)
assert.Equal(t, 1920, data.ActualWidth())
assert.Equal(t, 1080, data.ActualHeight())
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, float32(0), data.Lat)
assert.Equal(t, float32(0), data.Lng)
assert.Equal(t, "", data.CameraMake)
assert.Equal(t, "", data.CameraModel)
assert.Equal(t, "", data.LensModel)
})
}

View file

@ -726,7 +726,7 @@ func TestJSON(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, string(video.CodecHEVC), data.Codec)
assert.Equal(t, string(video.CodecHVC), data.Codec)
assert.Equal(t, "6.83s", data.Duration.String())
assert.Equal(t, "2020-12-22 02:45:43 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2020-12-22 01:45:43 +0000 UTC", data.TakenAt.String())
@ -750,7 +750,7 @@ func TestJSON(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, string(video.CodecHEVC), data.Codec)
assert.Equal(t, string(video.CodecHVC), data.Codec)
assert.Equal(t, "2.15s", data.Duration.String())
assert.Equal(t, "2019-12-12 20:47:21 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2019-12-13 01:47:21 +0000 UTC", data.TakenAt.String())
@ -823,6 +823,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "OLYMPUS IMAGING CORP.", data.CameraMake)
assert.Equal(t, "TG-830", data.CameraModel)
assert.Equal(t, "", data.LensModel)
assert.Equal(t, "Bad PrintIM data", data.Warning)
})
t.Run("datetime-zero.json", func(t *testing.T) {

View file

@ -8,7 +8,7 @@ import (
func TestData_AddKeywords(t *testing.T) {
t.Run("success", func(t *testing.T) {
data := New()
data := NewData()
assert.Equal(t, "", data.Keywords.String())
@ -22,7 +22,7 @@ func TestData_AddKeywords(t *testing.T) {
})
t.Run("ignore", func(t *testing.T) {
data := New()
data := NewData()
assert.Equal(t, "", data.Keywords.String())
@ -34,7 +34,7 @@ func TestData_AddKeywords(t *testing.T) {
func TestData_AutoAddKeywords(t *testing.T) {
t.Run("success", func(t *testing.T) {
data := New()
data := NewData()
assert.Equal(t, "", data.Keywords.String())
@ -44,7 +44,7 @@ func TestData_AutoAddKeywords(t *testing.T) {
})
t.Run("ignore", func(t *testing.T) {
data := New()
data := NewData()
assert.Equal(t, "", data.Keywords.String())
@ -54,7 +54,7 @@ func TestData_AutoAddKeywords(t *testing.T) {
})
t.Run("ignore because too short", func(t *testing.T) {
data := New()
data := NewData()
assert.Equal(t, "", data.Keywords.String())

View file

@ -0,0 +1,133 @@
[{
"SourceFile": "/go/src/github.com/photoprism/photoprism/storage/originals/Google/pixel2.jpg",
"ExifToolVersion": 12.56,
"FileName": "pixel2.jpg",
"Directory": "/go/src/github.com/photoprism/photoprism/storage/originals/Google",
"FileSize": 6240813,
"FileModifyDate": "2023:09:21 02:46:36+00:00",
"FileAccessDate": "2023:09:21 02:46:41+00:00",
"FileInodeChangeDate": "2023:09:21 02:46:41+00:00",
"FilePermissions": 100664,
"FileType": "JPEG",
"FileTypeExtension": "JPG",
"MIMEType": "image/jpeg",
"ExifByteOrder": "II",
"ResolutionUnit": 2,
"ImageDescription": "Maker:S,Date:2017-9-20,Ver:6,Lens:Kan03,Act:Lar02,E-Y",
"Make": "Google",
"Model": "Pixel 2",
"Software": "HDR+ 1.0.177471000z",
"Orientation": 1,
"ModifyDate": "2018:03:18 19:21:15",
"YCbCrPositioning": 1,
"ISO": 225,
"ExposureProgram": 2,
"FNumber": 1.8,
"ExposureTime": 0.016671,
"SensingMethod": 2,
"SubSecTimeDigitized": 796940,
"SubSecTimeOriginal": 796940,
"SubSecTime": 796940,
"SubjectDistanceRange": 1,
"Sharpness": 0,
"FocalLength": 4.442,
"Saturation": 0,
"Flash": 24,
"Contrast": 0,
"MeteringMode": 2,
"SceneCaptureType": 0,
"SubjectDistance": 0.265,
"FocalLengthIn35mmFormat": 27,
"InteropIndex": "R98",
"InteropVersion": "0100",
"MaxApertureValue": 1.80250092522166,
"CreateDate": "2018:03:18 19:21:15",
"ExposureCompensation": 0,
"ExifImageHeight": 4032,
"WhiteBalance": 0,
"DateTimeOriginal": "2018:03:18 19:21:15",
"BrightnessValue": 1.43,
"ExposureMode": 0,
"ExifImageWidth": 3024,
"ApertureValue": 1.80250092522166,
"ComponentsConfiguration": "1 2 3 0",
"SceneType": 1,
"CustomRendered": 1,
"ColorSpace": 1,
"ShutterSpeedValue": 0.0166307841008337,
"ExifVersion": "0220",
"FlashpixVersion": "0100",
"GPSVersionID": "2 2 0 0",
"GPSLatitudeRef": "N",
"GPSLongitudeRef": "W",
"GPSAltitudeRef": 1,
"GPSTimeStamp": "23:21:13",
"GPSDOP": 149.6,
"GPSProcessingMethod": "fused",
"GPSDateStamp": "2018:03:18",
"XResolution": 72,
"YResolution": 72,
"ThumbnailOffset": 22208,
"ThumbnailLength": 10486,
"Compression": 6,
"XMPToolkit": "Image::ExifTool 11.11",
"MicroVideo": 1,
"MicroVideoOffset": 1848116,
"MicroVideoPresentationTimestampUs": 266571,
"MicroVideoVersion": 1,
"OriginalFileName": "MVIMG_20180318_192115.jpg",
"JFIFVersion": "1 2",
"ProfileCMMType": "",
"ProfileVersion": 1024,
"ProfileClass": "mntr",
"ColorSpaceData": "RGB ",
"ProfileConnectionSpace": "XYZ ",
"ProfileDateTime": "2016:12:08 09:38:28",
"ProfileFileSignature": "acsp",
"PrimaryPlatform": "",
"CMMFlags": 0,
"DeviceManufacturer": "GOOG",
"DeviceModel": "",
"DeviceAttributes": "0 0",
"RenderingIntent": 0,
"ConnectionSpaceIlluminant": "0.9642 1 0.82491",
"ProfileCreator": "GOOG",
"ProfileID": "117 225 166 177 60 52 55 99 16 200 171 102 6 50 162 138",
"ProfileDescription": "sRGB IEC61966-2.1",
"ProfileCopyright": "Copyright (c) 2016 Google Inc.",
"MediaWhitePoint": "0.95045 1 1.08905",
"MediaBlackPoint": "0 0 0",
"RedMatrixColumn": "0.43604 0.22249 0.01392",
"GreenMatrixColumn": "0.38512 0.7169 0.09706",
"BlueMatrixColumn": "0.14305 0.06061 0.71391",
"RedTRC": "(Binary data 32 bytes, use -b option to extract)",
"ChromaticAdaptation": "1.04788 0.02292 -0.05019 0.02959 0.99048 -0.01704 -0.00922 0.01508 0.75168",
"BlueTRC": "(Binary data 32 bytes, use -b option to extract)",
"GreenTRC": "(Binary data 32 bytes, use -b option to extract)",
"ImageWidth": 3024,
"ImageHeight": 4032,
"EncodingProcess": 0,
"BitsPerSample": 8,
"ColorComponents": 3,
"YCbCrSubSampling": "2 2",
"Aperture": 1.8,
"ImageSize": "3024 4032",
"Megapixels": 12.192768,
"ScaleFactor35efl": 6.07834308869878,
"ShutterSpeed": 0.016671,
"SubSecCreateDate": "2018:03:18 19:21:15.796940",
"SubSecDateTimeOriginal": "2018:03:18 19:21:15.796940",
"SubSecModifyDate": "2018:03:18 19:21:15.796940",
"ThumbnailImage": "(Binary data 10486 bytes, use -b option to extract)",
"GPSAltitude": 0,
"GPSDateTime": "2018:03:18 23:21:13Z",
"GPSLatitude": 35.4230694444444,
"GPSLongitude": -78.6521222222222,
"CircleOfConfusion": "0.00494316628568242",
"DOF": "0.237137202360868 0.300282104298363",
"FOV": 67.380191965573,
"FocalLength35efl": 27,
"GPSPosition": "35.4230694444444 -78.6521222222222",
"HyperfocalDistance": 2.21758044446922,
"LightValue": 6.43258435532012
}]

View file

@ -0,0 +1,135 @@
[{
"SourceFile": "/go/src/github.com/photoprism/photoprism/storage/originals/Google/pixel4a.jpg",
"ExifToolVersion": 12.56,
"FileName": "pixel4a.jpg",
"Directory": "/go/src/github.com/photoprism/photoprism/storage/originals/Google",
"FileSize": 6512170,
"FileModifyDate": "2023:09:21 02:46:37+00:00",
"FileAccessDate": "2023:09:21 02:46:41+00:00",
"FileInodeChangeDate": "2023:09:21 02:46:41+00:00",
"FilePermissions": 100664,
"FileType": "JPEG",
"FileTypeExtension": "JPG",
"MIMEType": "image/jpeg",
"ExifByteOrder": "II",
"Make": "Google",
"Model": "Pixel 4a",
"Orientation": 6,
"XResolution": 72,
"YResolution": 72,
"ResolutionUnit": 2,
"Software": "HDR+ 1.0.377695977zd",
"ModifyDate": "2021:09:17 19:31:36",
"YCbCrPositioning": 1,
"ExposureTime": 0.066683,
"FNumber": 1.73,
"ExposureProgram": 2,
"ISO": 1734,
"ExifVersion": "0231",
"DateTimeOriginal": "2021:09:17 19:31:36",
"CreateDate": "2021:09:17 19:31:36",
"OffsetTime": "-04:00",
"OffsetTimeOriginal": "-04:00",
"OffsetTimeDigitized": "-04:00",
"ComponentsConfiguration": "1 2 3 0",
"ShutterSpeedValue": 0.066523136403335,
"ApertureValue": 1.72907446261573,
"BrightnessValue": -3.63,
"ExposureCompensation": 0,
"MaxApertureValue": 1.72907446261573,
"SubjectDistance": 0.267,
"MeteringMode": 2,
"Flash": 16,
"FocalLength": 4.38,
"SubSecTime": 844,
"SubSecTimeOriginal": 844,
"SubSecTimeDigitized": 844,
"FlashpixVersion": "0100",
"ColorSpace": 1,
"ExifImageWidth": 4032,
"ExifImageHeight": 3024,
"InteropIndex": "R98",
"InteropVersion": "0100",
"SensingMethod": 2,
"SceneType": 1,
"CustomRendered": 1,
"ExposureMode": 0,
"WhiteBalance": 0,
"DigitalZoomRatio": 0,
"FocalLengthIn35mmFormat": 27,
"SceneCaptureType": 0,
"Contrast": 0,
"Saturation": 0,
"Sharpness": 0,
"SubjectDistanceRange": 1,
"GPSLatitudeRef": "N",
"GPSLongitudeRef": "W",
"GPSAltitudeRef": 0,
"GPSTimeStamp": "23:30:37",
"GPSImgDirectionRef": "M",
"GPSImgDirection": 22,
"GPSDateStamp": "2021:09:17",
"XMPToolkit": "Adobe XMP Core 5.1.0-jc003",
"MotionPhoto": 1,
"MotionPhotoVersion": 1,
"MotionPhotoPresentationTimestampUs": 869089,
"HasExtendedXMP": "9631085130373832F38C39094AB24AA7",
"DirectoryItemMime": "image/jpeg",
"DirectoryItemSemantic": "Primary",
"DirectoryItemLength": 0,
"DirectoryItemPadding": 0,
"JFIFVersion": "1 2",
"ProfileCMMType": "",
"ProfileVersion": 1024,
"ProfileClass": "mntr",
"ColorSpaceData": "RGB ",
"ProfileConnectionSpace": "XYZ ",
"ProfileDateTime": "2016:12:08 09:38:28",
"ProfileFileSignature": "acsp",
"PrimaryPlatform": "",
"CMMFlags": 0,
"DeviceManufacturer": "GOOG",
"DeviceModel": "",
"DeviceAttributes": "0 0",
"RenderingIntent": 0,
"ConnectionSpaceIlluminant": "0.9642 1 0.82491",
"ProfileCreator": "GOOG",
"ProfileID": "117 225 166 177 60 52 55 99 16 200 171 102 6 50 162 138",
"ProfileDescription": "sRGB IEC61966-2.1",
"ProfileCopyright": "Copyright (c) 2016 Google Inc.",
"MediaWhitePoint": "0.95045 1 1.08905",
"MediaBlackPoint": "0 0 0",
"RedMatrixColumn": "0.43604 0.22249 0.01392",
"GreenMatrixColumn": "0.38512 0.7169 0.09706",
"BlueMatrixColumn": "0.14305 0.06061 0.71391",
"RedTRC": "(Binary data 32 bytes, use -b option to extract)",
"ChromaticAdaptation": "1.04788 0.02292 -0.05019 0.02959 0.99048 -0.01704 -0.00922 0.01508 0.75168",
"BlueTRC": "(Binary data 32 bytes, use -b option to extract)",
"GreenTRC": "(Binary data 32 bytes, use -b option to extract)",
"ImageWidth": 4032,
"ImageHeight": 3024,
"EncodingProcess": 0,
"BitsPerSample": 8,
"ColorComponents": 3,
"YCbCrSubSampling": "2 2",
"HDRPMakerNote": "(Binary data 23899 bytes, use -b option to extract)",
"Aperture": 1.73,
"ImageSize": "4032 3024",
"Megapixels": 12.192768,
"ScaleFactor35efl": 6.16438356164384,
"ShutterSpeed": 0.066683,
"SubSecCreateDate": "2021:09:17 19:31:36.844-04:00",
"SubSecDateTimeOriginal": "2021:09:17 19:31:36.844-04:00",
"SubSecModifyDate": "2021:09:17 19:31:36.844-04:00",
"GPSAltitude": 68.3,
"GPSDateTime": "2021:09:17 23:30:37Z",
"GPSLatitude": 35.7781527777778,
"GPSLongitude": -78.636875,
"CircleOfConfusion": 0.0048741711686828,
"DOF": "0.239369127870041 0.301842276531055",
"FOV": 67.3801919655729,
"FocalLength35efl": 27,
"GPSPosition": "35.7781527777778 -78.636875",
"HyperfocalDistance": 2.27510445799754,
"LightValue": 1.3720492608922
}]

View file

@ -0,0 +1,143 @@
[{
"SourceFile": "/go/src/github.com/photoprism/photoprism/storage/originals/Google/PXL_20211227_151322429.MP.jpg",
"ExifToolVersion": 12.56,
"FileName": "PXL_20211227_151322429.MP.jpg",
"Directory": "/go/src/github.com/photoprism/photoprism/storage/originals/Google",
"FileSize": 5883888,
"FileModifyDate": "2021:12:27 15:13:24+00:00",
"FileAccessDate": "2023:09:21 10:04:59+00:00",
"FileInodeChangeDate": "2023:09:21 10:04:58+00:00",
"FilePermissions": 100775,
"FileType": "JPEG",
"FileTypeExtension": "JPG",
"MIMEType": "image/jpeg",
"ExifByteOrder": "II",
"Make": "Google",
"Model": "Pixel 6",
"Orientation": 1,
"XResolution": 72,
"YResolution": 72,
"ResolutionUnit": 2,
"Software": "HDR+ 1.0.414775603zd",
"ModifyDate": "2021:12:27 16:13:22",
"YCbCrPositioning": 1,
"ExposureTime": 0.00424,
"FNumber": 1.85,
"ExposureProgram": 2,
"ISO": 654,
"ExifVersion": "0231",
"DateTimeOriginal": "2021:12:27 16:13:22",
"CreateDate": "2021:12:27 16:13:22",
"OffsetTime": "+01:00",
"OffsetTimeOriginal": "+01:00",
"OffsetTimeDigitized": "+01:00",
"ComponentsConfiguration": "1 2 3 0",
"ShutterSpeedValue": "0.00424505805674241",
"ApertureValue": 1.85317612378074,
"BrightnessValue": 1.95,
"ExposureCompensation": 0,
"MaxApertureValue": 1.85317612378074,
"SubjectDistance": 3.245,
"MeteringMode": 2,
"Flash": 16,
"FocalLength": 6.81,
"SubSecTime": 429,
"SubSecTimeOriginal": 429,
"SubSecTimeDigitized": 429,
"FlashpixVersion": "0100",
"ColorSpace": 1,
"ExifImageWidth": 4032,
"ExifImageHeight": 2268,
"InteropIndex": "R98",
"InteropVersion": "0100",
"SensingMethod": 2,
"SceneType": 1,
"CustomRendered": 1,
"ExposureMode": 0,
"WhiteBalance": 0,
"DigitalZoomRatio": 0,
"FocalLengthIn35mmFormat": 24,
"SceneCaptureType": 0,
"Contrast": 0,
"Saturation": 0,
"Sharpness": 0,
"SubjectDistanceRange": 3,
"LensMake": "Google",
"LensModel": "Pixel 6 back camera 6.81mm f/1.85",
"GPSLatitudeRef": "N",
"GPSLongitudeRef": "E",
"GPSAltitudeRef": 0,
"GPSTimeStamp": "15:12:56",
"GPSImgDirectionRef": "M",
"GPSImgDirection": 159,
"GPSDateStamp": "2021:12:27",
"Compression": 6,
"ThumbnailOffset": 1331,
"ThumbnailLength": 42677,
"XMPToolkit": "Adobe XMP Core 5.1.0-jc003",
"MotionPhoto": 1,
"MotionPhotoVersion": 1,
"MotionPhotoPresentationTimestampUs": 0,
"HasExtendedXMP": "0A74827E42D8679520C0D4D10F40BF92",
"DirectoryItemMime": "image/jpeg",
"DirectoryItemSemantic": "Primary",
"DirectoryItemLength": 0,
"DirectoryItemPadding": 0,
"JFIFVersion": "1 2",
"ProfileCMMType": "",
"ProfileVersion": 1024,
"ProfileClass": "mntr",
"ColorSpaceData": "RGB ",
"ProfileConnectionSpace": "XYZ ",
"ProfileDateTime": "2016:12:08 09:38:28",
"ProfileFileSignature": "acsp",
"PrimaryPlatform": "",
"CMMFlags": 0,
"DeviceManufacturer": "GOOG",
"DeviceModel": "",
"DeviceAttributes": "0 0",
"RenderingIntent": 0,
"ConnectionSpaceIlluminant": "0.9642 1 0.82491",
"ProfileCreator": "GOOG",
"ProfileID": "117 225 166 177 60 52 55 99 16 200 171 102 6 50 162 138",
"ProfileDescription": "sRGB IEC61966-2.1",
"ProfileCopyright": "Copyright (c) 2016 Google Inc.",
"MediaWhitePoint": "0.95045 1 1.08905",
"MediaBlackPoint": "0 0 0",
"RedMatrixColumn": "0.43604 0.22249 0.01392",
"GreenMatrixColumn": "0.38512 0.7169 0.09706",
"BlueMatrixColumn": "0.14305 0.06061 0.71391",
"RedTRC": "(Binary data 32 bytes, use -b option to extract)",
"ChromaticAdaptation": "1.04788 0.02292 -0.05019 0.02959 0.99048 -0.01704 -0.00922 0.01508 0.75168",
"BlueTRC": "(Binary data 32 bytes, use -b option to extract)",
"GreenTRC": "(Binary data 32 bytes, use -b option to extract)",
"ImageWidth": 4032,
"ImageHeight": 2268,
"EncodingProcess": 0,
"BitsPerSample": 8,
"ColorComponents": 3,
"YCbCrSubSampling": "2 2",
"HDRPMakerNote": "(Binary data 54662 bytes, use -b option to extract)",
"ShotLogData": "(Binary data 373 bytes, use -b option to extract)",
"Aperture": 1.85,
"ImageSize": "4032 2268",
"Megapixels": 9.144576,
"ScaleFactor35efl": 3.52422907488987,
"ShutterSpeed": 0.00424,
"SubSecCreateDate": "2021:12:27 16:13:22.429+01:00",
"SubSecDateTimeOriginal": "2021:12:27 16:13:22.429+01:00",
"SubSecModifyDate": "2021:12:27 16:13:22.429+01:00",
"ThumbnailImage": "(Binary data 42677 bytes, use -b option to extract)",
"GPSAltitude": 520.6,
"GPSDateTime": "2021:12:27 15:12:56Z",
"GPSLatitude": 48.6100277777778,
"GPSLongitude": 8.86155833333333,
"CircleOfConfusion": "0.00852562645344089",
"DOF": "1.54428119240211 0",
"FOV": 73.7398575770811,
"FocalLength35efl": 24,
"GPSPosition": "48.6100277777778 8.86155833333333",
"HyperfocalDistance": 2.94033081311518,
"LightValue": 6.94747992563343,
"LensID": "Pixel 6 back camera 6.81mm f/1.85"
}]

View file

@ -0,0 +1,144 @@
[{
"SourceFile": "/go/src/github.com/photoprism/photoprism/storage/originals/Google/PXL_20230329_144843201.MP.jpg",
"ExifToolVersion": 12.56,
"FileName": "PXL_20230329_144843201.MP.jpg",
"Directory": "/go/src/github.com/photoprism/photoprism/storage/originals/Google",
"FileSize": 7843873,
"FileModifyDate": "2023:09:21 02:46:05+00:00",
"FileAccessDate": "2023:09:21 02:49:10+00:00",
"FileInodeChangeDate": "2023:09:21 02:46:12+00:00",
"FilePermissions": 100664,
"FileType": "JPEG",
"FileTypeExtension": "JPG",
"MIMEType": "image/jpeg",
"ExifByteOrder": "II",
"Make": "Google",
"Model": "Pixel 7 Pro",
"Orientation": 1,
"XResolution": 72,
"YResolution": 72,
"ResolutionUnit": 2,
"Software": "HDR+ 1.0.514217843zd",
"ModifyDate": "2023:03:29 15:48:43",
"YCbCrPositioning": 1,
"ExposureTime": 0.003638,
"FNumber": 3.5,
"ExposureProgram": 2,
"ISO": 59,
"ExifVersion": "0232",
"DateTimeOriginal": "2023:03:29 15:48:43",
"CreateDate": "2023:03:29 15:48:43",
"OffsetTime": "+01:00",
"OffsetTimeOriginal": "+01:00",
"OffsetTimeDigitized": "+01:00",
"ComponentsConfiguration": "1 2 3 0",
"ShutterSpeedValue": "0.00364466012319065",
"ApertureValue": 3.49429158366678,
"BrightnessValue": 7.48,
"ExposureCompensation": 0,
"MaxApertureValue": 3.49429158366678,
"SubjectDistance": 6.401,
"MeteringMode": 2,
"Flash": 16,
"FocalLength": 19,
"SubSecTime": 201,
"SubSecTimeOriginal": 201,
"SubSecTimeDigitized": 201,
"FlashpixVersion": "0100",
"ColorSpace": 1,
"ExifImageWidth": 4080,
"ExifImageHeight": 3072,
"InteropIndex": "R98",
"InteropVersion": "0100",
"SensingMethod": 2,
"SceneType": 1,
"CustomRendered": 1,
"ExposureMode": 0,
"WhiteBalance": 0,
"DigitalZoomRatio": 1.03,
"FocalLengthIn35mmFormat": 117,
"SceneCaptureType": 0,
"Contrast": 0,
"Saturation": 0,
"Sharpness": 0,
"SubjectDistanceRange": 3,
"LensMake": "Google",
"LensModel": "Pixel 7 Pro back camera 19.0mm f/3.5",
"CompositeImage": 3,
"GPSLatitudeRef": "N",
"GPSLongitudeRef": "W",
"GPSAltitudeRef": 0,
"GPSTimeStamp": "14:48:14",
"GPSImgDirectionRef": "M",
"GPSImgDirection": 210,
"GPSDateStamp": "2023:03:29",
"Compression": 6,
"ThumbnailOffset": 1350,
"ThumbnailLength": 40140,
"XMPToolkit": "Adobe XMP Core 5.1.0-jc003",
"MotionPhoto": 1,
"MotionPhotoVersion": 1,
"MotionPhotoPresentationTimestampUs": 1019083,
"HasExtendedXMP": "BC95033CA299157D4A00E4464FEFE66F",
"DirectoryItemMime": "image/jpeg",
"DirectoryItemSemantic": "Primary",
"DirectoryItemLength": 0,
"DirectoryItemPadding": 0,
"JFIFVersion": "1 2",
"ProfileCMMType": "",
"ProfileVersion": 1024,
"ProfileClass": "mntr",
"ColorSpaceData": "RGB ",
"ProfileConnectionSpace": "XYZ ",
"ProfileDateTime": "2016:12:08 09:38:28",
"ProfileFileSignature": "acsp",
"PrimaryPlatform": "",
"CMMFlags": 0,
"DeviceManufacturer": "GOOG",
"DeviceModel": "",
"DeviceAttributes": "0 0",
"RenderingIntent": 0,
"ConnectionSpaceIlluminant": "0.9642 1 0.82491",
"ProfileCreator": "GOOG",
"ProfileID": "117 225 166 177 60 52 55 99 16 200 171 102 6 50 162 138",
"ProfileDescription": "sRGB IEC61966-2.1",
"ProfileCopyright": "Copyright (c) 2016 Google Inc.",
"MediaWhitePoint": "0.95045 1 1.08905",
"MediaBlackPoint": "0 0 0",
"RedMatrixColumn": "0.43604 0.22249 0.01392",
"GreenMatrixColumn": "0.38512 0.7169 0.09706",
"BlueMatrixColumn": "0.14305 0.06061 0.71391",
"RedTRC": "(Binary data 32 bytes, use -b option to extract)",
"ChromaticAdaptation": "1.04788 0.02292 -0.05019 0.02959 0.99048 -0.01704 -0.00922 0.01508 0.75168",
"BlueTRC": "(Binary data 32 bytes, use -b option to extract)",
"GreenTRC": "(Binary data 32 bytes, use -b option to extract)",
"ImageWidth": 4080,
"ImageHeight": 3072,
"EncodingProcess": 0,
"BitsPerSample": 8,
"ColorComponents": 3,
"YCbCrSubSampling": "2 2",
"HDRPMakerNote": "(Binary data 48819 bytes, use -b option to extract)",
"ShotLogData": "(Binary data 547 bytes, use -b option to extract)",
"Aperture": 3.5,
"ImageSize": "4080 3072",
"Megapixels": 12.53376,
"ScaleFactor35efl": 6.15789473684211,
"ShutterSpeed": 0.003638,
"SubSecCreateDate": "2023:03:29 15:48:43.201+01:00",
"SubSecDateTimeOriginal": "2023:03:29 15:48:43.201+01:00",
"SubSecModifyDate": "2023:03:29 15:48:43.201+01:00",
"ThumbnailImage": "(Binary data 40140 bytes, use -b option to extract)",
"GPSAltitude": 107.19,
"GPSDateTime": "2023:03:29 14:48:14Z",
"GPSLatitude": 52.7063611111111,
"GPSLongitude": -2.76059444444444,
"CircleOfConfusion": "0.00487930728161081",
"DOF": "4.91662692847249 9.16928941764372",
"FOV": 17.4923393002573,
"FocalLength35efl": 117,
"GPSPosition": "52.7063611111111 -2.76059444444444",
"HyperfocalDistance": 21.1388320492917,
"LightValue": 12.4785617262008,
"LensID": "Pixel 7 Pro back camera 19.0mm f/3.5"
}]

View file

@ -0,0 +1,94 @@
[{
"SourceFile": "/go/src/github.com/photoprism/photoprism/storage/originals/Samsung/20230822_143803.jpg",
"ExifToolVersion": 12.56,
"FileName": "20230822_143803.jpg",
"Directory": "/go/src/github.com/photoprism/photoprism/storage/originals/Samsung",
"FileSize": 7557289,
"FileModifyDate": "2023:09:21 02:45:09+00:00",
"FileAccessDate": "2023:09:21 02:45:11+00:00",
"FileInodeChangeDate": "2023:09:21 02:45:11+00:00",
"FilePermissions": 100664,
"FileType": "JPEG",
"FileTypeExtension": "JPG",
"MIMEType": "image/jpeg",
"ExifByteOrder": "II",
"Make": "samsung",
"Orientation": 6,
"ModifyDate": "2023:08:22 14:38:03",
"GPSAltitude": "undef",
"GPSLatitudeRef": "",
"GPSAltitudeRef": 0,
"GPSLongitudeRef": "",
"YResolution": 72,
"XResolution": 72,
"Model": "SM-G780F",
"Software": "G780FXXSDFWGA",
"YCbCrPositioning": 1,
"ExifVersion": "0220",
"ApertureValue": 1.79626474576787,
"ExposureCompensation": 0,
"ExposureProgram": 2,
"ColorSpace": 1,
"ImageUniqueID": "X12LLND01AM",
"MaxApertureValue": 1.79626474576787,
"ExifImageHeight": 3024,
"DateTimeOriginal": "2023:08:22 14:38:03",
"SubSecTimeOriginal": 583,
"WhiteBalance": 0,
"ExposureMode": 0,
"ExposureTime": 0.04,
"OffsetTime": "+03:00",
"Flash": 0,
"SubSecTime": 583,
"FNumber": 1.8,
"ISO": 640,
"ExifImageWidth": 4032,
"FocalLengthIn35mmFormat": 26,
"SubSecTimeDigitized": 583,
"DigitalZoomRatio": 1,
"CreateDate": "2023:08:22 14:38:03",
"ShutterSpeedValue": 0.972654947412286,
"MeteringMode": 2,
"FocalLength": 5.4,
"OffsetTimeOriginal": "+03:00",
"SceneCaptureType": 0,
"LightSource": 0,
"ResolutionUnit": 2,
"Compression": 6,
"ThumbnailOffset": 1003,
"ThumbnailLength": 63433,
"XMPToolkit": "Adobe XMP Core 5.1.0-jc003",
"MotionPhoto": 1,
"MotionPhotoVersion": 1,
"MotionPhotoPresentationTimestampUs": 2967325,
"DirectoryItemMime": "image/jpeg",
"DirectoryItemSemantic": "Primary",
"DirectoryItemLength": 0,
"DirectoryItemPadding": 59,
"ImageWidth": 4032,
"ImageHeight": 3024,
"EncodingProcess": 0,
"BitsPerSample": 8,
"ColorComponents": 3,
"YCbCrSubSampling": "2 2",
"TimeStamp": "2023:08:22 11:38:03.703+00:00",
"MCCData": 255,
"EmbeddedVideoType": "MotionPhoto_Data",
"EmbeddedVideoFile": "(Binary data 4708861 bytes, use -b option to extract)",
"Aperture": 1.8,
"ImageSize": "4032 3024",
"Megapixels": 12.192768,
"ScaleFactor35efl": 4.81481481481481,
"ShutterSpeed": 0.04,
"SubSecCreateDate": "2023:08:22 14:38:03.583",
"SubSecDateTimeOriginal": "2023:08:22 14:38:03.583+03:00",
"SubSecModifyDate": "2023:08:22 14:38:03.583+03:00",
"ThumbnailImage": "(Binary data 63433 bytes, use -b option to extract)",
"GPSLatitude": "",
"GPSLongitude": "",
"CircleOfConfusion": "0.00624037720753383",
"FOV": 69.3903656740025,
"FocalLength35efl": 26,
"HyperfocalDistance": 2.59599691833407,
"LightValue": 3.66177809777199
}]

View file

@ -0,0 +1,78 @@
[{
"SourceFile": "/go/src/github.com/photoprism/photoprism/storage/sidecar/Samsung/20230822_143803.mp4",
"ExifToolVersion": 12.56,
"FileName": "20230822_143803.mp4",
"Directory": "/go/src/github.com/photoprism/photoprism/storage/sidecar/Samsung",
"FileSize": 4708861,
"FileModifyDate": "2023:09:21 02:52:16+00:00",
"FileAccessDate": "2023:09:21 02:52:16+00:00",
"FileInodeChangeDate": "2023:09:21 02:52:16+00:00",
"FilePermissions": 100644,
"FileType": "MP4",
"FileTypeExtension": "MP4",
"MIMEType": "video/mp4",
"MajorBrand": "mp42",
"MinorVersion": "0.0.0",
"CompatibleBrands": ["isom","mp42"],
"MediaDataSize": 4706235,
"MediaDataOffset": 32,
"MovieHeaderVersion": 0,
"CreateDate": "2023:08:22 11:38:06",
"ModifyDate": "2023:08:22 11:38:06",
"TimeScale": 10000,
"Duration": 2.9864,
"PreferredRate": 1,
"PreferredVolume": 1,
"PreviewTime": 0,
"PreviewDuration": 0,
"PosterTime": 0,
"SelectionTime": 0,
"SelectionDuration": 0,
"CurrentTime": 0,
"NextTrackID": 3,
"PlayMode": "SEQ_PLAY",
"GPSCoordinates": "48.4565 35.072",
"AndroidVersion": 13,
"TrackHeaderVersion": 0,
"TrackCreateDate": "2023:08:22 11:38:06",
"TrackModifyDate": "2023:08:22 11:38:06",
"TrackID": 1,
"TrackDuration": 2.9864,
"TrackLayer": 0,
"TrackVolume": 0,
"ImageWidth": 1440,
"ImageHeight": 1080,
"GraphicsMode": 0,
"OpColor": "0 0 0",
"CompressorID": "avc1",
"SourceImageWidth": 1440,
"SourceImageHeight": 1080,
"XResolution": 72,
"YResolution": 72,
"BitDepth": 24,
"ColorProfiles": "nclx",
"ColorPrimaries": 5,
"TransferCharacteristics": 1,
"MatrixCoefficients": 6,
"VideoFrameRate": 26.1185977810354,
"MatrixStructure": "1 0 0 0 1 0 0 0 1",
"MediaHeaderVersion": 0,
"MediaCreateDate": "2023:08:22 11:38:06",
"MediaModifyDate": "2023:08:22 11:38:06",
"MediaTimeScale": 48000,
"MediaDuration": 2.79466666666667,
"HandlerType": "soun",
"HandlerDescription": "SoundHandle",
"Balance": 0,
"AudioFormat": "mp4a",
"AudioChannels": 2,
"AudioBitsPerSample": 16,
"AudioSampleRate": 48000,
"ImageSize": "1440 1080",
"Megapixels": 1.5552,
"AvgBitrate": 12607112,
"GPSLatitude": 48.4565,
"GPSLongitude": 35.072,
"Rotation": 90,
"GPSPosition": "48.4565 35.072"
}]

View file

@ -0,0 +1,104 @@
[{
"SourceFile": "20220423_085935.heif",
"ExifToolVersion": 12.56,
"FileName": "20220423_085935.heif",
"Directory": ".",
"FileSize": 3366300,
"FileModifyDate": "2022:08:16 10:26:55+00:00",
"FileAccessDate": "2023:09:22 11:50:16+00:00",
"FileInodeChangeDate": "2023:09:21 10:00:45+00:00",
"FilePermissions": 100664,
"FileType": "HEIC",
"FileTypeExtension": "HEIC",
"MIMEType": "image/heic",
"MajorBrand": "heic",
"MinorVersion": "0.0.0",
"CompatibleBrands": ["mif1","heic"],
"MediaDataSize": 975908,
"MediaDataOffset": 32,
"HandlerType": "pict",
"PrimaryItemReference": 49,
"MetaImageSize": "0 1287 4032 3024",
"ExifByteOrder": "II",
"Make": "samsung",
"Model": "SM-G781B",
"Orientation": 6,
"XResolution": 72,
"YResolution": 72,
"ResolutionUnit": 2,
"Software": "G781BXXU4FVC4",
"ModifyDate": "2022:04:23 08:59:35",
"YCbCrPositioning": 1,
"ExposureTime": 0.004347826087,
"FNumber": 1.8,
"ExposureProgram": 2,
"ISO": 40,
"ExifVersion": "0220",
"DateTimeOriginal": "2022:04:23 08:59:35",
"CreateDate": "2022:04:23 08:59:35",
"OffsetTime": "+02:00",
"OffsetTimeOriginal": "+02:00",
"ShutterSpeedValue": 0.996990853191608,
"ApertureValue": 1.79626474576787,
"BrightnessValue": 5.86,
"ExposureCompensation": 0,
"MaxApertureValue": 1.79626474576787,
"MeteringMode": 2,
"Flash": 0,
"FocalLength": 5.4,
"ColorSpace": 1,
"ExifImageWidth": 4032,
"ExifImageHeight": 3024,
"ExposureMode": 0,
"WhiteBalance": 0,
"DigitalZoomRatio": 1,
"FocalLengthIn35mmFormat": 26,
"SceneCaptureType": 0,
"ImageUniqueID": "X12QSND02PM",
"GPSLatitudeRef": "N",
"GPSLongitudeRef": "E",
"XMPToolkit": "Adobe XMP Core 5.1.0-jc003",
"MotionPhoto": 1,
"MotionPhotoVersion": 1,
"MotionPhotoPresentationTimestampUs": 2968555,
"DirectoryItemMime": "image/heic",
"DirectoryItemSemantic": "Primary",
"DirectoryItemLength": 0,
"DirectoryItemPadding": 67,
"HEVCConfigurationVersion": 1,
"GeneralProfileSpace": 0,
"GeneralTierFlag": 0,
"GeneralProfileIDC": 1,
"GenProfileCompatibilityFlags": 1610612736,
"ConstraintIndicatorFlags": "176 0 0 0 0 0",
"GeneralLevelIDC": 90,
"MinSpatialSegmentationIDC": 0,
"ParallelismType": 0,
"ChromaFormat": 1,
"BitDepthLuma": 8,
"BitDepthChroma": 8,
"AverageFrameRate": 0,
"ConstantFrameRate": 0,
"NumTemporalLayers": 0,
"TemporalIDNested": 0,
"ImageWidth": 4032,
"ImageHeight": 3024,
"ImageSpatialExtent": "4032 3024",
"Rotation": 270,
"MotionPhotoVideo": "(Binary data 2387559 bytes, use -b option to extract)",
"Aperture": 1.8,
"ImageSize": "4032 3024",
"Megapixels": 12.192768,
"ScaleFactor35efl": 4.81481481481481,
"ShutterSpeed": 0.004347826087,
"SubSecDateTimeOriginal": "2022:04:23 08:59:35+02:00",
"SubSecModifyDate": "2022:04:23 08:59:35+02:00",
"GPSLatitude": 51.4334693997222,
"GPSLongitude": 12.1107323997222,
"CircleOfConfusion": "0.00624037720753383",
"FOV": 69.3903656740025,
"FocalLength35efl": 26,
"GPSPosition": "51.4334693997222 12.1107323997222",
"HyperfocalDistance": 2.59599691833407,
"LightValue": 10.8634119589272
}]

View file

@ -0,0 +1,84 @@
[{
"SourceFile": "/go/src/github.com/photoprism/photoprism/storage/originals/Samsung/20211011_113427.jpg",
"ExifToolVersion": 12.56,
"FileName": "20211011_113427.jpg",
"Directory": "/go/src/github.com/photoprism/photoprism/storage/originals/Samsung",
"FileSize": 6938624,
"FileModifyDate": "2023:09:21 02:42:02+00:00",
"FileAccessDate": "2023:09:21 02:46:54+00:00",
"FileInodeChangeDate": "2023:09:21 02:44:39+00:00",
"FilePermissions": 100664,
"FileType": "JPEG",
"FileTypeExtension": "JPG",
"MIMEType": "image/jpeg",
"ExifByteOrder": "II",
"Make": "samsung",
"Model": "SM-G998B",
"Orientation": 1,
"XResolution": 72,
"YResolution": 72,
"ResolutionUnit": 2,
"Software": "G998BXXU3AUIE",
"ModifyDate": "2021:10:11 11:34:27",
"YCbCrPositioning": 1,
"ExposureTime": 0.0002821670429,
"FNumber": 1.8,
"ExposureProgram": 2,
"ISO": 50,
"ExifVersion": "0220",
"DateTimeOriginal": "2021:10:11 11:34:27",
"CreateDate": "2021:10:11 11:34:27",
"OffsetTime": "+02:00",
"OffsetTimeOriginal": "+02:00",
"ShutterSpeedValue": 0.999804435834932,
"ApertureValue": 1.79626474576787,
"BrightnessValue": 28.15,
"ExposureCompensation": 0,
"MaxApertureValue": 1.79626474576787,
"MeteringMode": 2,
"Flash": 0,
"FocalLength": 6.7,
"ColorSpace": 1,
"ExifImageWidth": 4000,
"ExifImageHeight": 2252,
"ExposureMode": 0,
"WhiteBalance": 0,
"DigitalZoomRatio": 1,
"FocalLengthIn35mmFormat": 24,
"SceneCaptureType": 0,
"ImageUniqueID": "XA8XLNF00SM",
"Compression": 6,
"ThumbnailOffset": 814,
"ThumbnailLength": 42252,
"XMPToolkit": "Adobe XMP Core 5.1.0-jc003",
"MotionPhoto": 1,
"MotionPhotoVersion": 1,
"MotionPhotoPresentationTimestampUs": 2656273,
"DirectoryItemMime": "image/jpeg",
"DirectoryItemSemantic": "Primary",
"DirectoryItemLength": 0,
"DirectoryItemPadding": 59,
"ImageWidth": 4000,
"ImageHeight": 2252,
"EncodingProcess": 0,
"BitsPerSample": 8,
"ColorComponents": 3,
"YCbCrSubSampling": "2 2",
"TimeStamp": "2021:10:11 09:34:27.911+00:00",
"MCCData": 238,
"EmbeddedVideoType": "MotionPhoto_Data",
"EmbeddedVideoFile": "(Binary data 4397713 bytes, use -b option to extract)",
"Aperture": 1.8,
"ImageSize": "4000 2252",
"Megapixels": 9.008,
"ScaleFactor35efl": 3.58208955223881,
"ShutterSpeed": 0.0002821670429,
"SubSecDateTimeOriginal": "2021:10:11 11:34:27+02:00",
"SubSecModifyDate": "2021:10:11 11:34:27+02:00",
"ThumbnailImage": "(Binary data 42252 bytes, use -b option to extract)",
"CircleOfConfusion": "0.00838791442555859",
"FOV": 73.7398575770811,
"FocalLength35efl": 24,
"HyperfocalDistance": 2.97319305176723,
"LightValue": 14.4871567016107
}]

View file

@ -0,0 +1,75 @@
[{
"SourceFile": "/go/src/github.com/photoprism/photoprism/storage/sidecar/Samsung/20211011_113427.mp4",
"ExifToolVersion": 12.56,
"FileName": "20211011_113427.mp4",
"Directory": "/go/src/github.com/photoprism/photoprism/storage/sidecar/Samsung",
"FileSize": 4397713,
"FileModifyDate": "2023:09:21 02:52:13+00:00",
"FileAccessDate": "2023:09:21 02:52:13+00:00",
"FileInodeChangeDate": "2023:09:21 02:52:13+00:00",
"FilePermissions": 100644,
"FileType": "MP4",
"FileTypeExtension": "MP4",
"MIMEType": "video/mp4",
"MajorBrand": "mp42",
"MinorVersion": "0.0.0",
"CompatibleBrands": ["isom","mp42"],
"MediaDataSize": 4395073,
"MediaDataOffset": 32,
"MovieHeaderVersion": 0,
"CreateDate": "2021:10:11 09:34:29",
"ModifyDate": "2021:10:11 09:34:29",
"TimeScale": 10000,
"Duration": 2.667,
"PreferredRate": 1,
"PreferredVolume": 1,
"PreviewTime": 0,
"PreviewDuration": 0,
"PosterTime": 0,
"SelectionTime": 0,
"SelectionDuration": 0,
"CurrentTime": 0,
"NextTrackID": 3,
"PlayMode": "SEQ_PLAY",
"AndroidVersion": 11,
"TrackHeaderVersion": 0,
"TrackCreateDate": "2021:10:11 09:34:29",
"TrackModifyDate": "2021:10:11 09:34:29",
"TrackID": 1,
"TrackDuration": 2.667,
"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,
"PixelAspectRatio": "65536:65536",
"ColorProfiles": "nclx",
"ColorPrimaries": 5,
"TransferCharacteristics": 1,
"MatrixCoefficients": 6,
"VideoFrameRate": 29.9958755671095,
"MatrixStructure": "1 0 0 0 1 0 0 0 1",
"MediaHeaderVersion": 0,
"MediaCreateDate": "2021:10:11 09:34:29",
"MediaModifyDate": "2021:10:11 09:34:29",
"MediaTimeScale": 48000,
"MediaDuration": 2.65745833333333,
"HandlerType": "soun",
"HandlerDescription": "SoundHandle",
"Balance": 0,
"AudioFormat": "mp4a",
"AudioChannels": 2,
"AudioBitsPerSample": 16,
"AudioSampleRate": 48000,
"ImageSize": "1920 1080",
"Megapixels": 2.0736,
"AvgBitrate": 13183571,
"Rotation": 0
}]

View file

@ -7,7 +7,7 @@ import (
const CodecUnknown = ""
const CodecAv1 = string(video.CodecAV1)
const CodecAvc1 = string(video.CodecAVC)
const CodecHvc1 = string(video.CodecHEVC)
const CodecHvc1 = string(video.CodecHVC)
const CodecJpeg = "jpeg"
const CodecHeic = "heic"
const CodecXMP = "xmp"

View file

@ -18,6 +18,7 @@ import (
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/photoprism/photoprism/pkg/video"
)
// MediaFile indexes a single media file.
@ -52,7 +53,7 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
file, primaryFile := entity.File{}, entity.File{}
photo := entity.NewUserPhoto(o.Stack, userUID)
metaData := meta.New()
metaData := meta.NewData()
labels := classify.Labels{}
stripSequence := Config().Settings().StackSequences() && o.Stack
@ -362,141 +363,166 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
// Handle file types.
switch {
case m.IsPreviewImage():
// Color information
if p, err := m.Colors(Config().ThumbCachePath()); err != nil {
log.Debugf("%s while detecting colors", err.Error())
file.FileError = err.Error()
// Update color information, if available.
if color, colorErr := m.Colors(Config().ThumbCachePath()); colorErr != nil {
log.Debugf("%s while detecting colors", colorErr.Error())
file.FileError = colorErr.Error()
file.FilePrimary = false
} else {
file.FileMainColor = p.MainColor.Name()
file.FileColors = p.Colors.Hex()
file.FileLuminance = p.Luminance.Hex()
file.FileDiff = p.Luminance.Diff()
file.FileChroma = p.Chroma.Percent()
file.FileMainColor = color.MainColor.Name()
file.FileColors = color.Colors.Hex()
file.FileLuminance = color.Luminance.Hex()
file.FileDiff = color.Luminance.Diff()
file.FileChroma = color.Chroma.Percent()
if file.FilePrimary {
photo.PhotoColor = p.MainColor.ID()
photo.PhotoColor = color.MainColor.ID()
}
}
// Update resolution and aspect ratio.
if m.Width() > 0 && m.Height() > 0 {
file.FileWidth = m.Width()
file.FileHeight = m.Height()
file.FileAspectRatio = m.AspectRatio()
file.FilePortrait = m.Portrait()
megapixels := m.Megapixels()
if megapixels > photo.PhotoResolution {
photo.PhotoResolution = megapixels
// Set photo resolution based on the largest media file.
if res := m.Megapixels(); res > photo.PhotoResolution {
photo.PhotoResolution = res
}
}
if metaData := m.MetaData(); metaData.Error == nil {
file.FileCodec = metaData.Codec
file.SetMediaUTC(metaData.TakenAt)
file.SetDuration(metaData.Duration)
file.SetFPS(metaData.FPS)
file.SetFrames(metaData.Frames)
file.SetProjection(metaData.Projection)
file.SetHDR(metaData.IsHDR())
file.SetColorProfile(metaData.ColorProfile)
file.SetSoftware(metaData.Software)
// Update file metadata.
if data := m.MetaData(); data.Error == nil {
file.FileCodec = data.Codec
file.SetMediaUTC(data.TakenAt)
file.SetProjection(data.Projection)
file.SetHDR(data.IsHDR())
file.SetColorProfile(data.ColorProfile)
file.SetSoftware(data.Software)
if file.OriginalName == "" && filepath.Base(file.FileName) != metaData.FileName {
file.OriginalName = metaData.FileName
// Get video metadata from embedded file?
if !data.EmbeddedVideo {
file.SetDuration(data.Duration)
file.SetFPS(data.FPS)
file.SetFrames(data.Frames)
} else if info := m.VideoInfo(); info.Compatible {
file.SetDuration(info.Duration)
file.SetFPS(info.FPS)
file.SetFrames(info.Frames)
}
if file.OriginalName == "" && filepath.Base(file.FileName) != data.FileName {
file.OriginalName = data.FileName
if photo.OriginalName == "" {
photo.OriginalName = fs.StripKnownExt(metaData.FileName)
photo.OriginalName = fs.StripKnownExt(data.FileName)
}
}
if metaData.HasInstanceID() {
log.Infof("index: %s has instance_id %s", logName, clean.Log(metaData.InstanceID))
if data.HasInstanceID() {
log.Infof("index: %s has instance_id %s", logName, clean.Log(data.InstanceID))
file.InstanceID = metaData.InstanceID
file.InstanceID = data.InstanceID
}
// Change photo type to "live" if the file has a video embedded.
if photo.TypeSrc == entity.SrcAuto && data.MediaType == media.Live {
photo.PhotoType = entity.MediaLive
}
}
// Set the photo type to animated if it is an animated PNG.
// Change the photo type to animated if it is an animated PNG.
if photo.TypeSrc == entity.SrcAuto && photo.PhotoType == entity.MediaImage && m.IsAnimatedImage() {
photo.PhotoType = entity.MediaAnimated
}
case m.IsXMP():
if metaData, err := meta.XMP(m.FileName()); err == nil {
if data, dataErr := meta.XMP(m.FileName()); dataErr == nil {
// Update basic metadata.
photo.SetTitle(metaData.Title, entity.SrcXmp)
photo.SetDescription(metaData.Description, entity.SrcXmp)
photo.SetTakenAt(metaData.TakenAt, metaData.TakenAtLocal, metaData.TimeZone, entity.SrcXmp)
photo.SetCoordinates(metaData.Lat, metaData.Lng, metaData.Altitude, entity.SrcXmp)
photo.SetTitle(data.Title, entity.SrcXmp)
photo.SetDescription(data.Description, entity.SrcXmp)
photo.SetTakenAt(data.TakenAt, data.TakenAtLocal, data.TimeZone, entity.SrcXmp)
photo.SetCoordinates(data.Lat, data.Lng, data.Altitude, entity.SrcXmp)
// Update metadata details.
details.SetKeywords(metaData.Keywords.String(), entity.SrcXmp)
details.SetNotes(metaData.Notes, entity.SrcXmp)
details.SetSubject(metaData.Subject, entity.SrcXmp)
details.SetArtist(metaData.Artist, entity.SrcXmp)
details.SetCopyright(metaData.Copyright, entity.SrcXmp)
details.SetLicense(metaData.License, entity.SrcXmp)
details.SetSoftware(metaData.Software, entity.SrcXmp)
details.SetKeywords(data.Keywords.String(), entity.SrcXmp)
details.SetNotes(data.Notes, entity.SrcXmp)
details.SetSubject(data.Subject, entity.SrcXmp)
details.SetArtist(data.Artist, entity.SrcXmp)
details.SetCopyright(data.Copyright, entity.SrcXmp)
details.SetLicense(data.License, entity.SrcXmp)
details.SetSoftware(data.Software, entity.SrcXmp)
// Update externally marked as favorite.
if metaData.Favorite {
photo.SetFavorite(metaData.Favorite)
if data.Favorite {
_ = photo.SetFavorite(data.Favorite)
}
} else {
log.Warn(err.Error())
file.FileError = err.Error()
log.Warn(dataErr.Error())
file.FileError = dataErr.Error()
}
case m.IsRaw(), m.IsImage():
if metaData := m.MetaData(); metaData.Error == nil {
if data := m.MetaData(); data.Error == nil {
// Update basic metadata.
photo.SetTitle(metaData.Title, entity.SrcMeta)
photo.SetDescription(metaData.Description, entity.SrcMeta)
photo.SetTakenAt(metaData.TakenAt, metaData.TakenAtLocal, metaData.TimeZone, entity.SrcMeta)
photo.SetCoordinates(metaData.Lat, metaData.Lng, metaData.Altitude, entity.SrcMeta)
photo.SetCameraSerial(metaData.CameraSerial)
photo.SetTitle(data.Title, entity.SrcMeta)
photo.SetDescription(data.Description, entity.SrcMeta)
photo.SetTakenAt(data.TakenAt, data.TakenAtLocal, data.TimeZone, entity.SrcMeta)
photo.SetCoordinates(data.Lat, data.Lng, data.Altitude, entity.SrcMeta)
photo.SetCameraSerial(data.CameraSerial)
// Update metadata details.
details.SetKeywords(metaData.Keywords.String(), entity.SrcMeta)
details.SetNotes(metaData.Notes, entity.SrcMeta)
details.SetSubject(metaData.Subject, entity.SrcMeta)
details.SetArtist(metaData.Artist, entity.SrcMeta)
details.SetCopyright(metaData.Copyright, entity.SrcMeta)
details.SetLicense(metaData.License, entity.SrcMeta)
details.SetSoftware(metaData.Software, entity.SrcMeta)
details.SetKeywords(data.Keywords.String(), entity.SrcMeta)
details.SetNotes(data.Notes, entity.SrcMeta)
details.SetSubject(data.Subject, entity.SrcMeta)
details.SetArtist(data.Artist, entity.SrcMeta)
details.SetCopyright(data.Copyright, entity.SrcMeta)
details.SetLicense(data.License, entity.SrcMeta)
details.SetSoftware(data.Software, entity.SrcMeta)
if metaData.HasDocumentID() && photo.UUID == "" {
log.Infof("index: %s has document_id %s", logName, clean.Log(metaData.DocumentID))
if data.HasDocumentID() && photo.UUID == "" {
log.Infof("index: %s has document_id %s", logName, clean.Log(data.DocumentID))
photo.UUID = metaData.DocumentID
photo.UUID = data.DocumentID
}
if metaData.HasInstanceID() {
log.Infof("index: %s has instance_id %s", logName, clean.Log(metaData.InstanceID))
if data.HasInstanceID() {
log.Infof("index: %s has instance_id %s", logName, clean.Log(data.InstanceID))
file.InstanceID = metaData.InstanceID
file.InstanceID = data.InstanceID
}
if file.OriginalName == "" && filepath.Base(file.FileName) != metaData.FileName {
file.OriginalName = metaData.FileName
if file.OriginalName == "" && filepath.Base(file.FileName) != data.FileName {
file.OriginalName = data.FileName
if photo.OriginalName == "" {
photo.OriginalName = fs.StripKnownExt(metaData.FileName)
photo.OriginalName = fs.StripKnownExt(data.FileName)
}
}
file.FileCodec = metaData.Codec
file.FileCodec = data.Codec
file.FileWidth = m.Width()
file.FileHeight = m.Height()
file.FileAspectRatio = m.AspectRatio()
file.FilePortrait = m.Portrait()
file.SetMediaUTC(metaData.TakenAt)
file.SetDuration(metaData.Duration)
file.SetFPS(metaData.FPS)
file.SetFrames(metaData.Frames)
file.SetProjection(metaData.Projection)
file.SetHDR(metaData.IsHDR())
file.SetColorProfile(metaData.ColorProfile)
file.SetSoftware(metaData.Software)
file.SetMediaUTC(data.TakenAt)
file.SetProjection(data.Projection)
file.SetHDR(data.IsHDR())
file.SetColorProfile(data.ColorProfile)
file.SetSoftware(data.Software)
// Get video metadata from embedded file?
if !m.IsHEIC() || !data.EmbeddedVideo {
file.SetDuration(data.Duration)
file.SetFPS(data.FPS)
file.SetFrames(data.Frames)
file.FileVideo = false
} else if info := m.VideoInfo(); info.Compatible {
file.SetDuration(info.Duration)
file.SetFPS(info.FPS)
file.SetFrames(info.Frames)
file.FileVideo = true
}
// Set photo resolution based on the largest media file.
if res := m.Megapixels(); res > photo.PhotoResolution {
photo.PhotoResolution = res
}
@ -519,54 +545,55 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
}
}
case m.IsVector():
if metaData := m.MetaData(); metaData.Error == nil {
if data := m.MetaData(); data.Error == nil {
// Update basic metadata.
photo.SetTitle(metaData.Title, entity.SrcMeta)
photo.SetDescription(metaData.Description, entity.SrcMeta)
photo.SetTakenAt(metaData.TakenAt, metaData.TakenAtLocal, metaData.TimeZone, entity.SrcMeta)
photo.SetTitle(data.Title, entity.SrcMeta)
photo.SetDescription(data.Description, entity.SrcMeta)
photo.SetTakenAt(data.TakenAt, data.TakenAtLocal, data.TimeZone, entity.SrcMeta)
// Update metadata details.
details.SetKeywords(metaData.Keywords.String(), entity.SrcMeta)
details.SetNotes(metaData.Notes, entity.SrcMeta)
details.SetSubject(metaData.Subject, entity.SrcMeta)
details.SetArtist(metaData.Artist, entity.SrcMeta)
details.SetCopyright(metaData.Copyright, entity.SrcMeta)
details.SetLicense(metaData.License, entity.SrcMeta)
details.SetSoftware(metaData.Software, entity.SrcMeta)
details.SetKeywords(data.Keywords.String(), entity.SrcMeta)
details.SetNotes(data.Notes, entity.SrcMeta)
details.SetSubject(data.Subject, entity.SrcMeta)
details.SetArtist(data.Artist, entity.SrcMeta)
details.SetCopyright(data.Copyright, entity.SrcMeta)
details.SetLicense(data.License, entity.SrcMeta)
details.SetSoftware(data.Software, entity.SrcMeta)
if metaData.HasDocumentID() && photo.UUID == "" {
log.Infof("index: %s has document_id %s", logName, clean.Log(metaData.DocumentID))
if data.HasDocumentID() && photo.UUID == "" {
log.Infof("index: %s has document_id %s", logName, clean.Log(data.DocumentID))
photo.UUID = metaData.DocumentID
photo.UUID = data.DocumentID
}
if metaData.HasInstanceID() {
log.Infof("index: %s has instance_id %s", logName, clean.Log(metaData.InstanceID))
if data.HasInstanceID() {
log.Infof("index: %s has instance_id %s", logName, clean.Log(data.InstanceID))
file.InstanceID = metaData.InstanceID
file.InstanceID = data.InstanceID
}
if file.OriginalName == "" && filepath.Base(file.FileName) != metaData.FileName {
file.OriginalName = metaData.FileName
if file.OriginalName == "" && filepath.Base(file.FileName) != data.FileName {
file.OriginalName = data.FileName
if photo.OriginalName == "" {
photo.OriginalName = fs.StripKnownExt(metaData.FileName)
photo.OriginalName = fs.StripKnownExt(data.FileName)
}
}
file.FileCodec = metaData.Codec
file.FileCodec = data.Codec
file.FileWidth = m.Width()
file.FileHeight = m.Height()
file.FileAspectRatio = m.AspectRatio()
file.FilePortrait = m.Portrait()
file.SetMediaUTC(metaData.TakenAt)
file.SetDuration(metaData.Duration)
file.SetFPS(metaData.FPS)
file.SetFrames(metaData.Frames)
file.SetProjection(metaData.Projection)
file.SetHDR(metaData.IsHDR())
file.SetColorProfile(metaData.ColorProfile)
file.SetSoftware(metaData.Software)
file.SetMediaUTC(data.TakenAt)
file.SetDuration(data.Duration)
file.SetFPS(data.FPS)
file.SetFrames(data.Frames)
file.SetProjection(data.Projection)
file.SetHDR(data.IsHDR())
file.SetColorProfile(data.ColorProfile)
file.SetSoftware(data.Software)
// Set photo resolution based on the largest media file.
if res := m.Megapixels(); res > photo.PhotoResolution {
photo.PhotoResolution = res
}
@ -577,55 +604,56 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
photo.PhotoType = entity.MediaVector
}
case m.IsVideo():
if metaData := m.MetaData(); metaData.Error == nil {
photo.SetTitle(metaData.Title, entity.SrcMeta)
photo.SetDescription(metaData.Description, entity.SrcMeta)
photo.SetTakenAt(metaData.TakenAt, metaData.TakenAtLocal, metaData.TimeZone, entity.SrcMeta)
photo.SetCoordinates(metaData.Lat, metaData.Lng, metaData.Altitude, entity.SrcMeta)
photo.SetCameraSerial(metaData.CameraSerial)
if data := m.MetaData(); data.Error == nil {
photo.SetTitle(data.Title, entity.SrcMeta)
photo.SetDescription(data.Description, entity.SrcMeta)
photo.SetTakenAt(data.TakenAt, data.TakenAtLocal, data.TimeZone, entity.SrcMeta)
photo.SetCoordinates(data.Lat, data.Lng, data.Altitude, entity.SrcMeta)
photo.SetCameraSerial(data.CameraSerial)
// Update metadata details.
details.SetKeywords(metaData.Keywords.String(), entity.SrcMeta)
details.SetNotes(metaData.Notes, entity.SrcMeta)
details.SetSubject(metaData.Subject, entity.SrcMeta)
details.SetArtist(metaData.Artist, entity.SrcMeta)
details.SetCopyright(metaData.Copyright, entity.SrcMeta)
details.SetLicense(metaData.License, entity.SrcMeta)
details.SetSoftware(metaData.Software, entity.SrcMeta)
details.SetKeywords(data.Keywords.String(), entity.SrcMeta)
details.SetNotes(data.Notes, entity.SrcMeta)
details.SetSubject(data.Subject, entity.SrcMeta)
details.SetArtist(data.Artist, entity.SrcMeta)
details.SetCopyright(data.Copyright, entity.SrcMeta)
details.SetLicense(data.License, entity.SrcMeta)
details.SetSoftware(data.Software, entity.SrcMeta)
if metaData.HasDocumentID() && photo.UUID == "" {
log.Infof("index: %s has document_id %s", logName, clean.Log(metaData.DocumentID))
if data.HasDocumentID() && photo.UUID == "" {
log.Infof("index: %s has document_id %s", logName, clean.Log(data.DocumentID))
photo.UUID = metaData.DocumentID
photo.UUID = data.DocumentID
}
if metaData.HasInstanceID() {
log.Infof("index: %s has instance_id %s", logName, clean.Log(metaData.InstanceID))
if data.HasInstanceID() {
log.Infof("index: %s has instance_id %s", logName, clean.Log(data.InstanceID))
file.InstanceID = metaData.InstanceID
file.InstanceID = data.InstanceID
}
if file.OriginalName == "" && filepath.Base(file.FileName) != metaData.FileName {
file.OriginalName = metaData.FileName
if file.OriginalName == "" && filepath.Base(file.FileName) != data.FileName {
file.OriginalName = data.FileName
if photo.OriginalName == "" {
photo.OriginalName = fs.StripKnownExt(metaData.FileName)
photo.OriginalName = fs.StripKnownExt(data.FileName)
}
}
file.FileCodec = metaData.Codec
file.FileCodec = data.Codec
file.FileWidth = m.Width()
file.FileHeight = m.Height()
file.FileAspectRatio = m.AspectRatio()
file.FilePortrait = m.Portrait()
file.SetMediaUTC(metaData.TakenAt)
file.SetDuration(metaData.Duration)
file.SetFPS(metaData.FPS)
file.SetFrames(metaData.Frames)
file.SetProjection(metaData.Projection)
file.SetHDR(metaData.IsHDR())
file.SetColorProfile(metaData.ColorProfile)
file.SetSoftware(metaData.Software)
file.SetMediaUTC(data.TakenAt)
file.SetDuration(data.Duration)
file.SetFPS(data.FPS)
file.SetFrames(data.Frames)
file.SetProjection(data.Projection)
file.SetHDR(data.IsHDR())
file.SetColorProfile(data.ColorProfile)
file.SetSoftware(data.Software)
// Set photo resolution based on the largest media file.
if res := m.Megapixels(); res > photo.PhotoResolution {
photo.PhotoResolution = res
}
@ -641,7 +669,7 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
if photo.TypeSrc == entity.SrcAuto {
// Update photo type only if not manually modified.
if file.FileDuration == 0 || file.FileDuration > LivePhotoDurationLimit {
if file.FileDuration == 0 || file.FileDuration > video.LiveDuration {
photo.PhotoType = entity.MediaVideo
} else {
photo.PhotoType = entity.MediaLive
@ -704,27 +732,27 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
}
// Read metadata from embedded Exif and JSON sidecar file, if exists.
if metaData := m.MetaData(); metaData.Error == nil {
if data := m.MetaData(); data.Error == nil {
// Update basic metadata.
photo.SetTitle(metaData.Title, entity.SrcMeta)
photo.SetDescription(metaData.Description, entity.SrcMeta)
photo.SetTakenAt(metaData.TakenAt, metaData.TakenAtLocal, metaData.TimeZone, entity.SrcMeta)
photo.SetCoordinates(metaData.Lat, metaData.Lng, metaData.Altitude, entity.SrcMeta)
photo.SetCameraSerial(metaData.CameraSerial)
photo.SetTitle(data.Title, entity.SrcMeta)
photo.SetDescription(data.Description, entity.SrcMeta)
photo.SetTakenAt(data.TakenAt, data.TakenAtLocal, data.TimeZone, entity.SrcMeta)
photo.SetCoordinates(data.Lat, data.Lng, data.Altitude, entity.SrcMeta)
photo.SetCameraSerial(data.CameraSerial)
// Update metadata details.
details.SetKeywords(metaData.Keywords.String(), entity.SrcMeta)
details.SetNotes(metaData.Notes, entity.SrcMeta)
details.SetSubject(metaData.Subject, entity.SrcMeta)
details.SetArtist(metaData.Artist, entity.SrcMeta)
details.SetCopyright(metaData.Copyright, entity.SrcMeta)
details.SetLicense(metaData.License, entity.SrcMeta)
details.SetSoftware(metaData.Software, entity.SrcMeta)
details.SetKeywords(data.Keywords.String(), entity.SrcMeta)
details.SetNotes(data.Notes, entity.SrcMeta)
details.SetSubject(data.Subject, entity.SrcMeta)
details.SetArtist(data.Artist, entity.SrcMeta)
details.SetCopyright(data.Copyright, entity.SrcMeta)
details.SetLicense(data.License, entity.SrcMeta)
details.SetSoftware(data.Software, entity.SrcMeta)
if metaData.HasDocumentID() && photo.UUID == "" {
log.Debugf("index: %s has document_id %s", logName, clean.Log(metaData.DocumentID))
if data.HasDocumentID() && photo.UUID == "" {
log.Debugf("index: %s has document_id %s", logName, clean.Log(data.DocumentID))
photo.UUID = metaData.DocumentID
photo.UUID = data.DocumentID
}
}
@ -755,15 +783,21 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
photo.PhotoPanorama = true
}
// Set remaining file properties.
// Update file properties.
file.FileSidecar = m.IsSidecar()
file.FileVideo = m.IsVideo()
file.FileVideo = m.IsVideo() || m.MetaData().EmbeddedVideo
file.FileType = m.FileType().String()
file.MediaType = m.Media().String()
file.FileMime = m.MimeType()
file.SetOrientation(m.Orientation(), entity.SrcMeta)
file.ModTime = modTime.UTC().Truncate(time.Second).Unix()
// Update file media type.
if mediaType := m.MetaData().MediaType; mediaType != media.Unknown {
file.MediaType = mediaType.String()
} else {
file.MediaType = m.Media().String()
}
// Detect ICC color profile for JPEGs if still unknown at this point.
if file.FileColorProfile == "" && fs.ImageJPEG.Equal(file.FileType) {
file.SetColorProfile(m.ColorProfile())

View file

@ -1,6 +0,0 @@
package photoprism
import "time"
// LivePhotoDurationLimit is the maximum duration of a live photo.
var LivePhotoDurationLimit = time.Millisecond * 3100

View file

@ -1,8 +1,6 @@
package photoprism
import (
"bytes"
"errors"
"fmt"
"image"
_ "image/gif"
@ -11,7 +9,6 @@ import (
"io"
"math"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
@ -34,6 +31,7 @@ import (
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/photoprism/photoprism/pkg/video"
)
// MediaFile represents a single photo, video, sidecar, or other supported media file.
@ -57,6 +55,8 @@ type MediaFile struct {
height int
metaData meta.Data
metaOnce sync.Once
videoInfo video.Info
videoOnce sync.Once
fileMutex sync.Mutex
location *entity.Cell
imageConfig *image.Config
@ -80,8 +80,9 @@ func NewMediaFileSkipResolve(fileName string, fileNameResolved string) (*MediaFi
fileName: fileName,
fileNameResolved: fileNameResolved,
fileRoot: entity.RootUnknown,
fileType: fs.UnknownType,
metaData: meta.New(),
fileType: fs.TypeUnknown,
metaData: meta.NewData(),
videoInfo: video.NewInfo(),
width: -1,
height: -1,
}
@ -317,72 +318,6 @@ func (m *MediaFile) EditedName() string {
return ""
}
// ExtractEmbeddedVideo extracts an embedded video file and returns its filename, if any.
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), // TODO: Is this safe?
"-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 file.
outputPath = strings.Replace(outputPath, "%f", m.BasePrefix(false), 1)
// Detect mime type of the extracted video file.
mimeType := fs.MimeType(outputPath)
if l := len(strings.Split(mimeType, "/")); l <= 1 {
log.Debugf("Error detecting the mime type of video file at %s", outputPath)
return "", nil
} else if extension := strings.Split(mimeType, "/")[l-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("failed to move extracted video file to %s", outputPath)
return "", err
}
return dstPath, nil
}
}
return "", nil
}
// PathNameInfo returns file name infos for indexing.
func (m *MediaFile) PathNameInfo(stripSequence bool) (fileRoot, fileBase, relativePath, relativeName string) {
fileRoot = m.Root()
@ -804,12 +739,8 @@ func (m *MediaFile) IsWebP() bool {
return m.MimeType() == fs.MimeTypeWebP
}
// Duration returns the duration if the file is a video.
// Duration returns the duration is the media content is playable.
func (m *MediaFile) Duration() time.Duration {
if !m.IsVideo() {
return 0
}
return m.MetaData().Duration
}
@ -956,6 +887,10 @@ func (m *MediaFile) IsImageNative() bool {
// IsLive checks if the file is a live photo.
func (m *MediaFile) IsLive() bool {
if m.MetaData().MediaType == media.Live {
return true
}
if m.IsHEIC() {
return fs.VideoMOV.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != ""
}
@ -989,13 +924,15 @@ func (m *MediaFile) PreviewImage() (*MediaFile, error) {
return nil, fmt.Errorf("%s is empty", m.RootRelName())
}
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 jpegName != "" {
return NewMediaFile(jpegName)
}
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 pngName != "" {
return NewMediaFile(pngName)
@ -1015,25 +952,15 @@ func (m *MediaFile) HasPreviewImage() bool {
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 {
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 {
return true

View file

@ -7,6 +7,7 @@ import (
"github.com/photoprism/photoprism/internal/meta"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/video"
)
// HasSidecarJson returns true if this file has or is a json sidecar file.
@ -81,10 +82,11 @@ func (m *MediaFile) ReadExifToolJson() error {
// MetaData returns exif meta data of a media file.
func (m *MediaFile) MetaData() (result meta.Data) {
if !m.Ok() || !m.IsMedia() {
// No valid media file.
// Not a main media file.
return m.metaData
}
// Gather the data once and cache it.
m.metaOnce.Do(func() {
var err error
@ -125,3 +127,22 @@ func (m *MediaFile) MetaData() (result meta.Data) {
return m.metaData
}
// VideoInfo returns video information if this is a video file or has a video embedded.
func (m *MediaFile) VideoInfo() video.Info {
if !m.Ok() || !m.IsMedia() {
// Not a main media file.
return m.videoInfo
}
// Gather the data once and cache it.
m.videoOnce.Do(func() {
if info, err := video.ProbeFile(m.FileName()); err != nil {
log.Debugf("video: %s in %s", err, clean.Log(m.BaseName()))
} else {
m.videoInfo = info
}
})
return m.videoInfo
}

View file

@ -11,7 +11,9 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/meta"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/projection"
"github.com/photoprism/photoprism/pkg/video"
)
func TestMediaFile_HasSidecarJson(t *testing.T) {
@ -435,3 +437,52 @@ func TestMediaFile_Exif_HEIC(t *testing.T) {
t.Error(err)
}
}
func TestMediaFile_VideoInfo(t *testing.T) {
c := config.TestConfig()
t.Run(
"samsung-motion-photo.jpg", func(t *testing.T) {
fileName := filepath.Join(c.ExamplesPath(), "samsung-motion-photo.jpg")
mf, err := NewMediaFile(fileName)
if err != nil {
t.Fatal(err)
}
info := mf.VideoInfo()
assert.Equal(t, video.MP4, info.VideoType)
assert.Equal(t, video.CodecAVC, info.VideoCodec)
assert.Equal(t, 1440, info.VideoWidth)
assert.Equal(t, 1080, info.VideoHeight)
assert.Equal(t, int64(2685814), info.VideoOffset)
assert.Equal(t, int64(0), info.ThumbOffset)
assert.Equal(t, "2.933s", info.Duration.String())
assert.Equal(t, fs.ImageJPEG, info.FileType)
assert.Equal(t, media.Live, info.MediaType)
},
)
t.Run(
"beach_sand.jpg", func(t *testing.T) {
fileName := filepath.Join(conf.ExamplesPath(), "beach_sand.jpg")
mf, err := NewMediaFile(fileName)
if err != nil {
t.Fatal(err)
}
info := mf.VideoInfo()
assert.Equal(t, video.Unknown, info.VideoType)
assert.Equal(t, video.CodecUnknown, info.VideoCodec)
assert.Equal(t, 0, info.VideoWidth)
assert.Equal(t, 0, info.VideoHeight)
assert.Equal(t, int64(-1), info.VideoOffset)
assert.Equal(t, int64(-1), info.ThumbOffset)
assert.Equal(t, time.Duration(0), info.Duration)
assert.Equal(t, fs.ImageJPEG, info.FileType)
assert.Equal(t, media.Image, info.MediaType)
},
)
}

View file

@ -56,13 +56,6 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e
matches = append(matches, name)
}
// Extract an embedded video file and add it to the list of files, if successful.
if videoName, videoErr := m.ExtractEmbeddedVideo(); videoErr != nil {
log.Warnf("media: %s om %s (extract embedded video)", clean.Error(videoErr), clean.Log(m.RootRelName()))
} else if videoName != "" {
matches = append(matches, videoName)
}
isHEIC := false
for _, fileName := range matches {

View file

@ -2610,66 +2610,3 @@ 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)
}
},
)
}

View file

@ -15,6 +15,7 @@ func FileName(s string) string {
s = strings.TrimSpace(s)
// Remove non-printable and other potentially problematic characters.
// The following characters must never be used in a filename: / \ > < | : &
s = strings.Map(func(r rune) rune {
if !unicode.IsPrint(r) {
return -1

View file

@ -16,6 +16,7 @@ func Path(s string) string {
s = strings.TrimSpace(s)
// Remove non-printable and other potentially problematic characters.
// The following characters must never be used in a filepath: \ > < | : &
s = strings.Map(func(r rune) rune {
if !unicode.IsPrint(r) {
return -1
@ -38,6 +39,7 @@ func UserPath(dir string) string {
return dir
}
// The following characters must never be used in a filepath: \ > < | : &
dir = strings.Trim(path.Clean(Path(strings.ReplaceAll(dir, "\\", "/"))), " ~./\\|?*%<>")
if strings.Contains(dir, "/.") || strings.Contains(dir, "..") || strings.Contains(dir, "//") {

View file

@ -53,5 +53,5 @@ var TypeInfo = TypeMap{
SidecarYAML: "Serialized YAML Data (Config, Metadata)",
SidecarText: "Plain Text",
SidecarMarkdown: "Markdown Formatted Text",
UnknownType: "Other",
TypeUnknown: "Other",
}

View file

@ -8,13 +8,13 @@ import (
)
// FileType returns the type associated with the specified filename,
// and UnknownType if it could not be matched.
// and TypeUnknown if it could not be matched.
func FileType(fileName string) Type {
if t, found := Extensions[LowerExt(fileName)]; found {
return t
}
return UnknownType
return TypeUnknown
}
// IsAnimatedImage checks if the type associated with the specified filename may be animated.

View file

@ -167,7 +167,7 @@ func TestType_FindAll(t *testing.T) {
func TestFileType(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
result := FileType("")
assert.Equal(t, UnknownType, result)
assert.Equal(t, TypeUnknown, result)
})
t.Run("JPEG", func(t *testing.T) {
result := FileType("testdata/test.jpg")

View file

@ -61,7 +61,7 @@ const (
SidecarJSON Type = "json" // JSON metadata / config / sidecar file
SidecarText Type = "txt" // Text config / sidecar file
SidecarMarkdown Type = "md" // Markdown text sidecar file
UnknownType Type = "" // Unknown file
TypeUnknown Type = "" // Unknown file
)
// TypeAnimated maps animated file types to their mime type.

View file

@ -37,6 +37,6 @@ func ContainsAny(l, s []string) bool {
}
}
// Nothing found.
// Not found.
return false
}

50
pkg/live/README.md Normal file
View file

@ -0,0 +1,50 @@
Hybrid Photo/Video File Support
===============================
- Apple iOS: Live Photos consist of a JPEG/HEIC image and a QuickTime AVC/HEVC video, which are both required for viewing.
- Android: Samsung and Google Pixel smartphones support taking "Motion Photos" with the included Photos app. Motion Photos are JPEG/HEIC image with a short MP4 video embedded after the image data. The image part of these files can be opened in any image viewer that supports JPEG/HEIC, but the video part cannot. However, since the MP4 video is simply appended at the end of the image file, it can be easily read by our software and streamed through the API as needed.
Introductory Tutorials
----------------------
| Title | Date | URL |
|---------------------------------------------------------|----------|------------------------------------------------------------------------------------|
| How to detect Android motion photos in Flutter | May 2023 | https://ente.io/blog/tech/android-motion-photos-flutter/ |
| Stripping Embedded MP4s out of Android 12 Motion Photos | Oct 2021 | https://mjanja.ch/2021/10/stripping-embedded-mp4s-out-of-android-12-motion-photos/ |
| Google Pixel "Motion Photo" Howto | Mar 2021 | https://linuxreviews.org/Google_Pixel_%22Motion_Photo%22 |
| go-mp4: Golang Library and CLI Tool for MP4 | Jul 2020 | https://dev.to/sunfishshogi/go-mp4-golang-library-and-cli-tool-for-mp4-52o1 |
| Working with Motion Photos | Jan 2019 | https://medium.com/android-news/working-with-motion-photos-da0aa49b50c |
| Google: Behind the Motion Photos Technology in Pixel 2 | Mar 2018 | https://blog.research.google/2018/03/behind-motion-photos-technology-in.html |
Software Libraries and References
---------------------------------
| Title | URL |
|------------------------------------------------------|-------------------------------------------------------------------------|
| Web Video Codec Guide | https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_codecs |
| Media Container Formats | https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers |
| MP4 Signature Format | https://www.file-recovery.com/mp4-signature-format.htm |
| List of file signatures (Wikipedia) | https://en.wikipedia.org/wiki/List_of_file_signatures |
| Go library for reading and writing MP4 files | https://github.com/abema/go-mp4 |
| Go library for buffered I/O with io.Seeker interface | https://github.com/sunfish-shogi/bufseekio |
| How to use the io.Reader interface | https://yourbasic.org/golang/io-reader-interface-explained/ |
| AV1 Codec ISO Media File Format | https://aomediacodec.github.io/av1-isobmff |
Related GitHub Issues
---------------------
- https://github.com/photoprism/photoprism/issues/439 (Samsung: Initial support for Motion Photos)
- https://github.com/photoprism/photoprism/issues/1739 (Google: Initial support for Motion Photos)
- https://github.com/photoprism/photoprism/issues/2788 (Metadata: Flag Samsung/Google Motion Photos as Live Photos)
- https://github.com/cliveontoast/GoMoPho/issues/23 (Google Motion Photos Video Extractor: Add Android 12 Support)
Related Pull Requests
---------------------
- https://github.com/photoprism/photoprism/pull/3709 (Google: Initial support for Motion Photos)
- https://github.com/photoprism/photoprism/pull/3722 (Google: Add support for Motion Photos)
- https://github.com/photoprism/photoprism/pull/3660 (Samsung: Improved support for Motion Photos)
----
*PhotoPrism® is a [registered trademark](https://www.photoprism.app/trademark). By using the software and services we provide, you agree to our [Terms of Service](https://www.photoprism.app/terms), [Privacy Policy](https://www.photoprism.app/privacy), and [Code of Conduct](https://www.photoprism.app/code-of-conduct). Docs are [available](https://link.photoprism.app/github-docs) under the [CC BY-NC-SA 4.0 License](https://creativecommons.org/licenses/by-nc-sa/4.0/); [additional terms](https://github.com/photoprism/photoprism/blob/develop/assets/README.md) may apply.*

25
pkg/live/live.go Normal file
View file

@ -0,0 +1,25 @@
/*
Package live provides types and abstractions for hybrid photo/video file formats.
Copyright (c) 2018 - 2023 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package live

View file

@ -52,5 +52,5 @@ var Formats = map[fs.Type]Type{
fs.SidecarText: Sidecar,
fs.SidecarJSON: Sidecar,
fs.SidecarMarkdown: Sidecar,
fs.UnknownType: Other,
fs.TypeUnknown: Other,
}

7
pkg/media/headers.go Normal file
View file

@ -0,0 +1,7 @@
package media
// Standard media file headers.
var (
JpegImageHeader = []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}
ISOBaseMediaHeader = []byte{0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D}
)

23
pkg/video/README.md Normal file
View file

@ -0,0 +1,23 @@
Video File Support
==================
Technical References and Tutorials
----------------------------------
| Title | URL |
|------------------------------------------------------|-------------------------------------------------------------------------|
| Web Video Codec Guide | https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_codecs |
| Media Container Formats | https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers |
| MP4 Signature Format | https://www.file-recovery.com/mp4-signature-format.htm |
| List of file signatures (Wikipedia) | https://en.wikipedia.org/wiki/List_of_file_signatures |
| How to use the io.Reader interface | https://yourbasic.org/golang/io-reader-interface-explained/ |
| AV1 Codec ISO Media File Format | https://aomediacodec.github.io/av1-isobmff |
Hybrid Photo/Video Formats
--------------------------
For more information on hybrid photo/video file formats, e.g. Apple Live Photos and Samsung/Google Motion Photos, see [github.com/photoprism/photoprism/tree/develop/pkg/live](https://github.com/photoprism/photoprism/tree/develop/pkg/live).
----
*PhotoPrism® is a [registered trademark](https://www.photoprism.app/trademark). By using the software and services we provide, you agree to our [Terms of Service](https://www.photoprism.app/terms), [Privacy Policy](https://www.photoprism.app/privacy), and [Code of Conduct](https://www.photoprism.app/code-of-conduct). Docs are [available](https://link.photoprism.app/github-docs) under the [CC BY-NC-SA 4.0 License](https://creativecommons.org/licenses/by-nc-sa/4.0/); [additional terms](https://github.com/photoprism/photoprism/blob/develop/assets/README.md) may apply.*

109
pkg/video/chunk.go Normal file
View file

@ -0,0 +1,109 @@
package video
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/sunfish-shogi/bufseekio"
)
// Chunk represents a fixed length file chunk.
type Chunk [4]byte
// Get returns the chunk as byte array.
func (c Chunk) Get() [4]byte {
return c
}
// Hex returns the chunk as hex formatted string.
func (c Chunk) Hex() string {
return fmt.Sprintf("0x%x", c[:])
}
// String returns the chunk as string.
func (c Chunk) String() string {
return string(c[:])
}
// Bytes returns the chunk as byte slice.
func (c Chunk) Bytes() []byte {
return c[:]
}
// Uint32 returns the chunk as unsigned integer.
func (c Chunk) Uint32() uint32 {
return binary.BigEndian.Uint32(c.Bytes())
}
// Equal compares the chunk with a byte slice.
func (c Chunk) Equal(b []byte) bool {
return bytes.Equal(c.Bytes(), b)
}
// FileOffset returns the index of the chunk in the specified file, or -1 if it was not found.
func (c Chunk) FileOffset(fileName string) (int, error) {
if !fs.FileExists(fileName) {
return -1, errors.New("file not found")
}
file, err := os.Open(fileName)
if err != nil {
return -1, err
}
defer file.Close()
index, err := c.DataOffset(file)
return index, err
}
// DataOffset returns the index of the chunk in f, or -1 if it was not found.
func (c Chunk) DataOffset(file io.ReadSeeker) (int, error) {
if file == nil {
return -1, errors.New("file is nil")
}
data := c.Bytes()
dataSize := len(data)
blockSize := 128 * 1024
buffer := make([]byte, blockSize)
// Create buffered read seeker.
r := bufseekio.NewReadSeeker(file, blockSize, dataSize)
// Index offset.
var offset int
// Search in batches.
for {
n, err := r.Read(buffer)
buffer = buffer[:n]
if err != nil {
if err != io.EOF {
return -1, err
}
break
} else if n == 0 {
break
}
// Return data index, if found.
if i := bytes.Index(buffer, data); i >= 0 {
return offset + i, nil
}
offset += n
}
return -1, nil
}

23
pkg/video/chunks.go Normal file
View file

@ -0,0 +1,23 @@
package video
// Chunks represents a list of file chunks.
type Chunks []Chunk
// ContainsAny checks if at least one common chunk exists.
func (c Chunks) ContainsAny(b [][4]byte) bool {
if len(c) == 0 || len(b) == 0 {
return false
}
// Find matches.
for i := range c {
for j := range b {
if b[j] == c[i] {
return true
}
}
}
// Not found.
return false
}

52
pkg/video/chunks_mp4.go Normal file
View file

@ -0,0 +1,52 @@
package video
// ChunkMP4 specifies the start chunk of MP4 video files,
// it must be followed by a valid subtype chunk.
var (
ChunkMP4 = Chunk{'f', 't', 'y', 'p'}
ChunkQT = Chunk{'q', 't', ' ', ' '}
ChunkISOM = Chunk{'i', 's', 'o', 'm'}
ChunkISO2 = Chunk{'i', 's', 'o', '2'}
ChunkISO3 = Chunk{'i', 's', 'o', '3'}
ChunkISO4 = Chunk{'i', 's', 'o', '4'}
ChunkISO5 = Chunk{'i', 's', 'o', '5'}
ChunkISO6 = Chunk{'i', 's', 'o', '6'}
ChunkISO7 = Chunk{'i', 's', 'o', '7'}
ChunkISO8 = Chunk{'i', 's', 'o', '8'}
ChunkISO9 = Chunk{'i', 's', 'o', '9'}
ChunkAVC1 = Chunk{'a', 'v', 'c', '1'}
ChunkHEV1 = Chunk{'h', 'e', 'v', '1'}
ChunkHVC1 = Chunk{'h', 'v', 'c', '1'}
ChunkAV01 = Chunk{'a', 'v', '0', '1'}
ChunkAV1C = Chunk{'a', 'v', '1', 'C'}
ChunkMMP4 = Chunk{'m', 'm', 'p', '4'}
ChunkMP4V = Chunk{'m', 'p', '4', 'v'}
ChunkMP41 = Chunk{'m', 'p', '4', '1'}
ChunkMP42 = Chunk{'m', 'p', '4', '2'}
ChunkMP71 = Chunk{'m', 'p', '7', '1'}
ChunkHEIC = Chunk{'h', 'e', 'i', 'c'}
)
// CompatibleBrands contains compatible subtypes chunks.
var CompatibleBrands = Chunks{
ChunkQT,
ChunkISOM,
ChunkISO2,
ChunkISO3,
ChunkISO4,
ChunkISO5,
ChunkISO6,
ChunkISO7,
ChunkISO8,
ChunkISO9,
ChunkAVC1,
ChunkHEV1,
ChunkHVC1,
ChunkAV01,
ChunkAV1C,
ChunkMMP4,
ChunkMP4V,
ChunkMP41,
ChunkMP42,
ChunkMP71,
}

120
pkg/video/chunks_test.go Normal file
View file

@ -0,0 +1,120 @@
package video
import (
"io"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sunfish-shogi/bufseekio"
)
func TestChunk_TypeCast(t *testing.T) {
t.Run("String", func(t *testing.T) {
assert.Equal(t, "ftyp", ChunkMP4.String())
})
t.Run("Hex", func(t *testing.T) {
assert.Equal(t, "0x66747970", ChunkMP4.Hex())
})
t.Run("Uint32", func(t *testing.T) {
assert.Equal(t, uint32(0x66747970), ChunkMP4.Uint32())
})
}
func TestChunk_FileOffset(t *testing.T) {
t.Run("mp4v-avc1.mp4", func(t *testing.T) {
index, err := ChunkMP4.FileOffset("testdata/mp4v-avc1.mp4")
require.NoError(t, err)
assert.Equal(t, 4, index)
})
t.Run("isom-avc1.mp4", func(t *testing.T) {
index, err := ChunkMP4.FileOffset("testdata/isom-avc1.mp4")
require.NoError(t, err)
assert.Equal(t, 4, index)
})
t.Run("image-isom-avc1.jpg", func(t *testing.T) {
index, err := ChunkMP4.FileOffset("testdata/image-isom-avc1.jpg")
require.NoError(t, err)
assert.Equal(t, 23213, index)
})
}
func TestChunks(t *testing.T) {
t.Run("mp4v-avc1.mp4", func(t *testing.T) {
f, fileErr := os.Open("testdata/mp4v-avc1.mp4")
require.NoError(t, fileErr)
defer f.Close()
r := bufseekio.NewReadSeeker(f, 8, 4)
var startChunk = make([]byte, 4)
var subType = make([]byte, 4)
if _, err := r.Seek(4, io.SeekStart); err != nil {
t.Fatal(err)
}
// Read first 4-byte chunk.
if n, err := r.Read(startChunk); err != nil {
t.Fatal(err)
} else if n != 4 {
t.Fatal("expected to read 4 bytes")
}
// Read second 4-byte chunk.
if n, err := r.Read(subType); err != nil {
t.Fatal(err)
} else if n != 4 {
t.Fatal("expected to read 4 bytes")
}
assert.Equal(t, ChunkMP4.Bytes(), startChunk[:4])
assert.Equal(t, ChunkMP4V.Bytes(), subType[:4])
})
t.Run("isom-avc1.mp4", func(t *testing.T) {
f, fileErr := os.Open("testdata/isom-avc1.mp4")
require.NoError(t, fileErr)
defer f.Close()
b := make([]byte, 12)
// Read first 12 bytes from video file.
if n, err := f.Read(b); err != nil {
t.Fatal(err)
} else if n != 12 {
t.Fatalf("expected to read 12 bytes instead of %d", n)
}
assert.Equal(t, ChunkMP4[:], b[4:8])
assert.Equal(t, ChunkISOM[:], b[8:12])
})
t.Run("image-isom-avc1.jpg", func(t *testing.T) {
f, fileErr := os.Open("testdata/image-isom-avc1.jpg")
require.NoError(t, fileErr)
defer f.Close()
b := make([]byte, 12)
// Read first 12 bytes from video file.
if n, err := f.Read(b); err != nil {
t.Fatal(err)
} else if n != 12 {
t.Fatalf("expected to read 12 bytes instead of %d", n)
}
assert.NotEqual(t, ChunkMP4, *(*[4]byte)(b[4:8]))
assert.NotEqual(t, ChunkISOM, *(*[4]byte)(b[8:12]))
})
}
func TestChunks_ContainsAny(t *testing.T) {
t.Run("Found", func(t *testing.T) {
chunks := [][4]byte{ChunkMP41, ChunkMP42}
assert.True(t, CompatibleBrands.ContainsAny(chunks))
})
t.Run("NotFound", func(t *testing.T) {
chunks := [][4]byte{ChunkMP4}
assert.False(t, CompatibleBrands.ContainsAny(chunks))
})
}

View file

@ -5,9 +5,9 @@ type Codec string
// Check browser support: https://cconcolato.github.io/media-mime-support/
const (
UnknownCodec Codec = ""
CodecUnknown Codec = ""
CodecAVC Codec = "avc1"
CodecHEVC Codec = "hvc1"
CodecHVC Codec = "hvc1"
CodecVVC Codec = "vvc"
CodecAV1 Codec = "av01"
CodecVP8 Codec = "vp8"
@ -18,18 +18,18 @@ const (
// Codecs maps identifiers to codecs.
var Codecs = StandardCodecs{
"": UnknownCodec,
"a_opus": UnknownCodec,
"a_vorbis": UnknownCodec,
"": CodecUnknown,
"a_opus": CodecUnknown,
"a_vorbis": CodecUnknown,
"avc": CodecAVC,
"avc1": CodecAVC,
"v_avc": CodecAVC,
"v_avc1": CodecAVC,
"hevc": CodecHEVC,
"hvc": CodecHEVC,
"hvc1": CodecHEVC,
"v_hvc": CodecHEVC,
"v_hvc1": CodecHEVC,
"hevc": CodecHVC,
"hvc": CodecHVC,
"hvc1": CodecHVC,
"v_hvc": CodecHVC,
"v_hvc1": CodecHVC,
"vvc": CodecVVC,
"v_vvc": CodecVVC,
"av1": CodecAV1,

View file

@ -3,8 +3,8 @@ package video
import "testing"
func TestCodecs(t *testing.T) {
if val := Codecs[""]; val != UnknownCodec {
t.Fatal("default codec should be UnknownCodec")
if val := Codecs[""]; val != CodecUnknown {
t.Fatal("default codec should be CodecUnknown")
}
if val := Codecs["avc"]; val != CodecAVC {

25
pkg/video/content_type.go Normal file
View file

@ -0,0 +1,25 @@
package video
import (
"fmt"
"github.com/photoprism/photoprism/pkg/fs"
)
// Standard content types.
const (
ContentTypeDefault = "application/octet-stream"
ContentTypeAVC = fs.MimeTypeMP4 + `; codecs="avc1"`
ContentTypeMOV = fs.MimeTypeMOV
)
// ContentType composes the video content type from the given mime type and codec.
func ContentType(mimeType string, codec Codec) string {
if codec == CodecUnknown {
return mimeType
} else if mimeType == "" {
return ContentTypeDefault
}
return fmt.Sprintf("%s; codecs=\"%s\"", mimeType, codec)
}

View file

@ -0,0 +1,27 @@
package video
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestContentType(t *testing.T) {
t.Run("QuickTime", func(t *testing.T) {
assert.Equal(t, fs.MimeTypeMOV, ContentType(fs.MimeTypeMOV, ""))
})
t.Run("QuickTime_HVC", func(t *testing.T) {
assert.Equal(t, `video/quicktime; codecs="hvc1"`, ContentType(fs.MimeTypeMOV, CodecHVC))
})
t.Run("MP4", func(t *testing.T) {
assert.Equal(t, fs.MimeTypeMP4, ContentType(fs.MimeTypeMP4, ""))
})
t.Run("MP4_AVC", func(t *testing.T) {
assert.Equal(t, ContentTypeAVC, ContentType(fs.MimeTypeMP4, CodecAVC))
})
t.Run("MP4_HVC", func(t *testing.T) {
assert.Equal(t, `video/mp4; codecs="hvc1"`, ContentType(fs.MimeTypeMP4, CodecHVC))
})
}

61
pkg/video/info.go Normal file
View file

@ -0,0 +1,61 @@
package video
import (
"time"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
)
// Info represents video file information.
type Info struct {
FileName string
FileSize int64
FileType fs.Type
MediaType media.Type
ThumbOffset int64
ThumbMimeType string
VideoOffset int64
VideoMimeType string
VideoType Type
VideoCodec Codec
VideoWidth int
VideoHeight int
Duration time.Duration
Frames int
FPS float64
Tracks int
Encrypted bool
FastStart bool
Compatible bool
}
// NewInfo returns a new Info struct with default values.
func NewInfo() Info {
return Info{
FileType: fs.TypeUnknown,
FileSize: -1,
MediaType: media.Unknown,
ThumbOffset: -1,
VideoOffset: -1,
VideoType: Unknown,
VideoCodec: CodecUnknown,
FPS: 0.0,
}
}
// VideoSize returns the size of the embedded video, if possible.
func (info Info) VideoSize() int64 {
if info.FileSize < 0 || info.VideoOffset < 0 {
return 0
}
return info.FileSize - info.VideoOffset
}
// VideoContentType composes the video content type from its mime type and codec.
func (info Info) VideoContentType() string {
if info.VideoMimeType == "" {
return ""
}
return ContentType(info.VideoMimeType, info.VideoCodec)
}

6
pkg/video/live.go Normal file
View file

@ -0,0 +1,6 @@
package video
import "time"
// LiveDuration is the maximum duration for a video to play inline like a live photo.
var LiveDuration = time.Millisecond * 3100

165
pkg/video/probe.go Normal file
View file

@ -0,0 +1,165 @@
package video
import (
"errors"
"io"
"math"
"os"
"time"
"github.com/abema/go-mp4"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
)
// ProbeFile returns information for the given filename.
func ProbeFile(fileName string) (info Info, err error) {
if fileName == "" {
return info, errors.New("filename missing")
}
var stat os.FileInfo
var file *os.File
// Ensure the file exists and is not a directory.
if stat, err = os.Stat(fileName); err != nil {
return info, err
} else if stat.IsDir() {
return info, errors.New("invalid filename")
}
// Open the file for reading.
if file, err = os.Open(fileName); err != nil {
return info, err
}
defer file.Close()
// Get video information.
info, err = Probe(file)
// Add file name.
info.FileName = fileName
info.FileSize = stat.Size()
// Set file type based on filename?
if info.FileType == fs.TypeUnknown {
info.FileType = fs.FileType(fileName)
}
// Set media type based on filename?
if info.MediaType == media.Unknown {
info.MediaType = media.Formats[info.FileType]
}
return info, err
}
// Probe returns information on the provided video file.
// see https://pkg.go.dev/github.com/abema/go-mp4#ProbeInfo
func Probe(file io.ReadSeeker) (info Info, err error) {
// Set defaults.
info = NewInfo()
if file == nil {
return info, errors.New("file is nil")
}
// Probe file from the beginning.
if _, err = file.Seek(0, io.SeekStart); err != nil {
return info, err
}
// Find start chunk.
if offset, findErr := ChunkMP4.DataOffset(file); findErr != nil {
return info, findErr
} else if offset < 4 {
return info, nil
} else {
info.VideoOffset = int64(offset) - 4
}
// Ignore any data before the video offset.
videoFile := NewReadSeeker(file, info.VideoOffset)
var video *mp4.ProbeInfo
// Detect MP4 video metadata.
if video, err = mp4.Probe(videoFile); err != nil {
return info, err
}
// Check compatibility.
if CompatibleBrands.ContainsAny(video.CompatibleBrands) {
info.Compatible = true
info.VideoType = MP4
info.FPS = 30.0 // TODO: Detect actual frames per second!
if info.VideoOffset > 0 {
info.MediaType = media.Live
info.ThumbOffset = 0
} else {
info.MediaType = media.Video
info.FileType = fs.VideoMP4
}
}
// Check major brand.
switch video.MajorBrand {
case ChunkMP41.Get(), ChunkMP42.Get():
info.VideoMimeType = fs.MimeTypeMP4
case ChunkQT.Get():
info.VideoType = MOV
info.VideoMimeType = fs.MimeTypeMOV
if info.MediaType == media.Video {
info.FileType = fs.VideoMOV
}
}
// Additional properties.
info.FastStart = video.FastStart
info.Tracks = len(video.Tracks)
// Check tracks for codec, resolution and encryption.
for _, track := range video.Tracks {
if track.Encrypted && !info.Encrypted {
info.Encrypted = track.Encrypted
}
switch track.Codec {
case mp4.CodecAVC1:
info.VideoCodec = CodecAVC
}
if avc := track.AVC; avc != nil {
if info.VideoMimeType == "" {
info.VideoMimeType = fs.MimeTypeMP4
}
if w := int(avc.Width); w > info.VideoWidth {
info.VideoWidth = w
}
if h := int(avc.Height); h > info.VideoHeight {
info.VideoHeight = h
}
}
}
// Detect codec by searching for matching chunks.
if info.VideoCodec == "" {
if found, _ := ChunkHVC1.DataOffset(file); found > 0 {
info.VideoCodec = CodecHVC
}
}
// Calculate video duration in seconds.
if video.Duration > 0 {
s := float64(video.Duration) / float64(video.Timescale)
info.Duration = time.Duration(s * float64(time.Second))
if info.FPS > 0 {
info.Frames = int(math.Round(info.FPS * s))
}
}
return info, err
}

281
pkg/video/probe_test.go Normal file
View file

@ -0,0 +1,281 @@
package video
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
)
func TestProbeFile(t *testing.T) {
t.Run("PathName", func(t *testing.T) {
fileName := "testdata"
info, err := ProbeFile(fileName)
require.Error(t, err)
require.NotNil(t, info)
})
t.Run("NotFound", func(t *testing.T) {
fileName := "testdata/invalid"
info, err := ProbeFile(fileName)
require.Error(t, err)
require.NotNil(t, info)
})
/*t.Run("motion-photo.heif", func(t *testing.T) {
fileName := "testdata/motion-photo.heif"
info, err := ProbeFile(fileName)
require.NoError(t, err)
require.NotNil(t, info)
assert.Equal(t, fileName, info.FileName)
assert.Equal(t, int64(3366300), info.FileSize)
assert.Equal(t, fs.ImageHEIC, info.FileType)
assert.Equal(t, MOV, info.VideoType)
assert.Equal(t, int64(0), info.VideoOffset)
assert.Equal(t, int64(-1), info.ThumbOffset)
assert.Equal(t, media.Image, info.MediaType)
assert.Equal(t, CodecHVC, info.VideoCodec)
assert.Equal(t, fs.MimeTypeMOV, info.VideoMimeType)
assert.Equal(t, "", info.VideoContentType())
assert.Equal(t, "1.166666666s", info.Duration.String())
assert.InEpsilon(t, 1.166, info.Duration.Seconds(), 0.01)
assert.Equal(t, 5, info.Tracks)
assert.Equal(t, 0, info.VideoWidth)
assert.Equal(t, 0, info.VideoHeight)
assert.Equal(t, 35, info.Frames)
assert.Equal(t, 30.0, info.FPS)
assert.Equal(t, false, info.Encrypted)
assert.Equal(t, false, info.FastStart)
assert.Equal(t, true, info.Compatible)
})*/
t.Run("mp4v-avc1.mp4", func(t *testing.T) {
fileName := "testdata/mp4v-avc1.mp4"
info, err := ProbeFile(fileName)
require.NoError(t, err)
require.NotNil(t, info)
assert.Equal(t, fileName, info.FileName)
assert.Equal(t, int64(55061), info.FileSize)
assert.Equal(t, fs.VideoMP4, info.FileType)
assert.Equal(t, MP4, info.VideoType)
assert.Equal(t, int64(0), info.VideoOffset)
assert.Equal(t, int64(-1), info.ThumbOffset)
assert.Equal(t, media.Video, info.MediaType)
assert.Equal(t, CodecAVC, info.VideoCodec)
assert.Equal(t, fs.MimeTypeMP4, info.VideoMimeType)
assert.Equal(t, ContentTypeAVC, info.VideoContentType())
assert.Equal(t, "810.0081ms", info.Duration.String())
assert.InEpsilon(t, 0.81, info.Duration.Seconds(), 0.01)
assert.Equal(t, 1, info.Tracks)
assert.Equal(t, 640, info.VideoWidth)
assert.Equal(t, 416, info.VideoHeight)
assert.Equal(t, 24, info.Frames)
assert.Equal(t, 30.0, info.FPS)
assert.Equal(t, false, info.Encrypted)
assert.Equal(t, true, info.FastStart)
assert.Equal(t, true, info.Compatible)
})
t.Run("mp42-hvc1.mp4", func(t *testing.T) {
fileName := "testdata/mp42-hvc1.mp4"
info, err := ProbeFile(fileName)
require.NoError(t, err)
require.NotNil(t, info)
assert.Equal(t, fileName, info.FileName)
assert.Equal(t, int64(217963), info.FileSize)
assert.Equal(t, fs.VideoMP4, info.FileType)
assert.Equal(t, MP4, info.VideoType)
assert.Equal(t, int64(0), info.VideoOffset)
assert.Equal(t, int64(-1), info.ThumbOffset)
assert.Equal(t, media.Video, info.MediaType)
assert.Equal(t, CodecAVC, info.VideoCodec)
assert.Equal(t, fs.MimeTypeMP4, info.VideoMimeType)
assert.Equal(t, ContentTypeAVC, info.VideoContentType())
assert.Equal(t, "1.066666666s", info.Duration.String())
assert.InEpsilon(t, 1.066, info.Duration.Seconds(), 0.01)
assert.Equal(t, 2, info.Tracks)
assert.Equal(t, 464, info.VideoWidth)
assert.Equal(t, 848, info.VideoHeight)
assert.Equal(t, 32, info.Frames)
assert.Equal(t, 30.0, info.FPS)
assert.Equal(t, false, info.Encrypted)
assert.Equal(t, false, info.FastStart)
assert.Equal(t, true, info.Compatible)
})
t.Run("quicktime-hvc1.mov", func(t *testing.T) {
fileName := "testdata/quicktime-hvc1.mov"
info, err := ProbeFile(fileName)
require.NoError(t, err)
require.NotNil(t, info)
assert.Equal(t, fileName, info.FileName)
assert.Equal(t, int64(710953), info.FileSize)
assert.Equal(t, fs.VideoMOV, info.FileType)
assert.Equal(t, MOV, info.VideoType)
assert.Equal(t, int64(0), info.VideoOffset)
assert.Equal(t, int64(-1), info.ThumbOffset)
assert.Equal(t, media.Video, info.MediaType)
assert.Equal(t, CodecHVC, info.VideoCodec)
assert.Equal(t, fs.MimeTypeMOV, info.VideoMimeType)
assert.Equal(t, ContentTypeMOV+`; codecs="hvc1"`, info.VideoContentType())
assert.Equal(t, "1.166666666s", info.Duration.String())
assert.InEpsilon(t, 1.166, info.Duration.Seconds(), 0.01)
assert.Equal(t, 5, info.Tracks)
assert.Equal(t, 0, info.VideoWidth)
assert.Equal(t, 0, info.VideoHeight)
assert.Equal(t, 35, info.Frames)
assert.Equal(t, 30.0, info.FPS)
assert.Equal(t, false, info.Encrypted)
assert.Equal(t, false, info.FastStart)
assert.Equal(t, true, info.Compatible)
})
t.Run("quicktime-jpeg.mov", func(t *testing.T) {
fileName := "testdata/quicktime-jpeg.mov"
info, err := ProbeFile(fileName)
require.NoError(t, err)
require.NotNil(t, info)
assert.Equal(t, fileName, info.FileName)
assert.Equal(t, int64(475190), info.FileSize)
assert.Equal(t, fs.VideoMOV, info.FileType)
assert.Equal(t, Unknown, info.VideoType)
assert.Equal(t, int64(-1), info.VideoOffset)
assert.Equal(t, int64(-1), info.ThumbOffset)
assert.Equal(t, media.Video, info.MediaType)
assert.Equal(t, CodecUnknown, info.VideoCodec)
assert.Equal(t, "", info.VideoContentType())
assert.Equal(t, "", info.VideoMimeType)
assert.Equal(t, "0s", info.Duration.String())
assert.Equal(t, 0, info.Tracks)
assert.Equal(t, 0, info.VideoWidth)
assert.Equal(t, 0, info.VideoHeight)
assert.Equal(t, 0, info.Frames)
assert.Equal(t, 0.0, info.FPS)
assert.Equal(t, false, info.Encrypted)
assert.Equal(t, false, info.FastStart)
assert.Equal(t, false, info.Compatible)
})
t.Run("image-isom-avc1.jpg", func(t *testing.T) {
fileName := "testdata/image-isom-avc1.jpg"
info, err := ProbeFile(fileName)
require.NoError(t, err)
require.NotNil(t, info)
assert.Equal(t, fileName, info.FileName)
assert.Equal(t, int64(31487), info.FileSize)
assert.Equal(t, fs.ImageJPEG, info.FileType)
assert.Equal(t, MP4, info.VideoType)
assert.Equal(t, int64(23209), info.VideoOffset)
assert.Equal(t, int64(0), info.ThumbOffset)
assert.Equal(t, media.Live, info.MediaType)
assert.Equal(t, CodecAVC, info.VideoCodec)
assert.Equal(t, fs.MimeTypeMP4, info.VideoMimeType)
assert.Equal(t, ContentTypeAVC, info.VideoContentType())
assert.Equal(t, "1.024s", info.Duration.String())
assert.InEpsilon(t, 1.024, info.Duration.Seconds(), 0.01)
assert.Equal(t, 2, info.Tracks)
assert.Equal(t, 320, info.VideoWidth)
assert.Equal(t, 180, info.VideoHeight)
assert.Equal(t, 31, info.Frames)
assert.Equal(t, 30.0, info.FPS)
assert.Equal(t, false, info.Encrypted)
assert.Equal(t, false, info.FastStart)
assert.Equal(t, true, info.Compatible)
})
}
func TestProbe(t *testing.T) {
t.Run("mp4v-avc1.mp4", func(t *testing.T) {
f, fileErr := os.Open("testdata/mp4v-avc1.mp4")
require.NoError(t, fileErr)
defer f.Close()
info, err := Probe(f)
require.NoError(t, err)
require.NotNil(t, info)
assert.Equal(t, "", info.FileName)
assert.Equal(t, int64(-1), info.FileSize)
assert.Equal(t, fs.VideoMP4, info.FileType)
assert.Equal(t, MP4, info.VideoType)
assert.Equal(t, int64(0), info.VideoOffset)
assert.Equal(t, int64(-1), info.ThumbOffset)
assert.Equal(t, media.Video, info.MediaType)
assert.Equal(t, CodecAVC, info.VideoCodec)
assert.Equal(t, fs.MimeTypeMP4, info.VideoMimeType)
assert.Equal(t, ContentTypeAVC, info.VideoContentType())
assert.Equal(t, "810.0081ms", info.Duration.String())
assert.InEpsilon(t, 0.81, info.Duration.Seconds(), 0.01)
assert.Equal(t, 1, info.Tracks)
assert.Equal(t, 640, info.VideoWidth)
assert.Equal(t, 416, info.VideoHeight)
assert.Equal(t, 24, info.Frames)
assert.Equal(t, 30.0, info.FPS)
assert.Equal(t, false, info.Encrypted)
assert.Equal(t, true, info.FastStart)
assert.Equal(t, true, info.Compatible)
})
t.Run("isom-avc1.mp4", func(t *testing.T) {
f, fileErr := os.Open("testdata/isom-avc1.mp4")
require.NoError(t, fileErr)
defer f.Close()
info, err := Probe(f)
require.NoError(t, err)
require.NotNil(t, info)
assert.Equal(t, "", info.FileName)
assert.Equal(t, int64(-1), info.FileSize)
assert.Equal(t, fs.VideoMP4, info.FileType)
assert.Equal(t, MP4, info.VideoType)
assert.Equal(t, int64(0), info.VideoOffset)
assert.Equal(t, int64(-1), info.ThumbOffset)
assert.Equal(t, media.Video, info.MediaType)
assert.Equal(t, CodecAVC, info.VideoCodec)
assert.Equal(t, fs.MimeTypeMP4, info.VideoMimeType)
assert.Equal(t, ContentTypeAVC, info.VideoContentType())
assert.Equal(t, "1.024s", info.Duration.String())
assert.InEpsilon(t, 1.024, info.Duration.Seconds(), 0.01)
assert.Equal(t, 2, info.Tracks)
assert.Equal(t, 320, info.VideoWidth)
assert.Equal(t, 180, info.VideoHeight)
assert.Equal(t, 31, info.Frames)
assert.Equal(t, 30.0, info.FPS)
assert.Equal(t, false, info.Encrypted)
assert.Equal(t, false, info.FastStart)
assert.Equal(t, true, info.Compatible)
})
t.Run("image-isom-avc1.jpg", func(t *testing.T) {
f, fileErr := os.Open("testdata/image-isom-avc1.jpg")
require.NoError(t, fileErr)
defer f.Close()
info, err := Probe(f)
require.NoError(t, err)
require.NotNil(t, info)
assert.Equal(t, "", info.FileName)
assert.Equal(t, int64(-1), info.FileSize)
assert.Equal(t, fs.TypeUnknown, info.FileType)
assert.Equal(t, MP4, info.VideoType)
assert.Equal(t, int64(23209), info.VideoOffset)
assert.Equal(t, int64(0), info.ThumbOffset)
assert.Equal(t, media.Live, info.MediaType)
assert.Equal(t, CodecAVC, info.VideoCodec)
assert.Equal(t, fs.MimeTypeMP4, info.VideoMimeType)
assert.Equal(t, ContentTypeAVC, info.VideoContentType())
assert.Equal(t, "1.024s", info.Duration.String())
assert.InEpsilon(t, 1.024, info.Duration.Seconds(), 0.01)
assert.Equal(t, 2, info.Tracks)
assert.Equal(t, 320, info.VideoWidth)
assert.Equal(t, 180, info.VideoHeight)
assert.Equal(t, 31, info.Frames)
assert.Equal(t, 30.0, info.FPS)
assert.Equal(t, false, info.Encrypted)
assert.Equal(t, false, info.FastStart)
assert.Equal(t, true, info.Compatible)
})
}

100
pkg/video/reader.go Normal file
View file

@ -0,0 +1,100 @@
package video
import (
"errors"
"io"
"os"
"github.com/photoprism/photoprism/pkg/fs"
)
// Reader reads an existing file from an offset until the end.
type Reader struct {
fileName string
file *os.File
offset int64
}
// NewReader creates a new Reader.
func NewReader(fileName string, offset int64) (*Reader, error) {
// Check if the file exists.
if !fs.FileExists(fileName) {
return nil, errors.New("file not found")
}
// Open file for reading.
file, err := os.Open(fileName)
if err != nil {
return nil, err
}
// Ensure that the offset is positive and we are starting at the right position.
if offset < 0 {
offset = 0
} else if offset > 0 {
if _, seekErr := file.Seek(offset, io.SeekStart); seekErr != nil {
_ = file.Close()
return nil, err
}
}
// Create new reader and return it.
return &Reader{fileName: fileName, file: file, offset: int64(offset)}, nil
}
// Read reads up to len(p) bytes into p. It returns the number of bytes read and any error encountered.
func (r *Reader) Read(p []byte) (n int, err error) {
return r.file.Read(p)
}
// Close closes the file after reading.
func (r *Reader) Close() error {
r.fileName = ""
r.offset = 0
return r.file.Close()
}
// ReadSeeker reads an existing file from an offset until the end.
type ReadSeeker struct {
file io.ReadSeeker
offset int64
}
// NewReadSeeker creates a new ReadSeeker.
func NewReadSeeker(file io.ReadSeeker, offset int64) *ReadSeeker {
// Ensure that the offset is positive.
if offset < 0 {
offset = 0
}
return &ReadSeeker{
file: file,
offset: offset,
}
}
// Read reads up to len(p) bytes into p. It returns the number of bytes read and any error encountered.
func (r *ReadSeeker) Read(p []byte) (n int, err error) {
return r.file.Read(p)
}
// Seek sets the offset for the next Read or Write to offset, interpreted according to whence:
// - SeekStart means relative to the start of the file
// - SeekCurrent means relative to the current offset
// - SeekEnd means relative to the end (for example, offset = -2 specifies the penultimate byte of the file)
//
// Seek returns the new offset relative to the start of the file or an error, if any.
// Seeking to an offset before the start of the file is an error.
func (r *ReadSeeker) Seek(offset int64, whence int) (pos int64, err error) {
switch whence {
case io.SeekStart:
pos, err = r.file.Seek(offset+r.offset, whence)
case io.SeekCurrent, io.SeekEnd:
pos, err = r.file.Seek(offset, whence)
default:
return 0, errors.New("unknown whence")
}
return pos - r.offset, err
}

50
pkg/video/reader_test.go Normal file
View file

@ -0,0 +1,50 @@
package video
import (
"io"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gabriel-vasile/mimetype"
)
func TestReader(t *testing.T) {
t.Run("Read", func(t *testing.T) {
info, probeErr := ProbeFile("testdata/image-isom-avc1.jpg")
if probeErr != nil {
t.Fatal(probeErr)
}
require.NotNil(t, info)
reader, readerErr := NewReader(info.FileName, info.VideoOffset)
if readerErr != nil {
t.Fatal(probeErr)
}
defer reader.Close()
require.NotNil(t, reader)
videoData, ioErr := io.ReadAll(reader)
if ioErr != nil {
t.Fatal(probeErr)
}
stat, statErr := os.Stat(info.FileName)
if statErr != nil {
t.Fatal(probeErr)
}
assert.True(t, int(stat.Size()) > len(videoData))
assert.Equal(t, int(stat.Size()-info.VideoOffset), len(videoData))
assert.Equal(t, info.VideoMimeType, mimetype.Detect(videoData).String())
})
}

BIN
pkg/video/testdata/image-isom-avc1.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
pkg/video/testdata/image.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
pkg/video/testdata/isom-avc1.mp4 vendored Normal file

Binary file not shown.

78
pkg/video/testdata/isom-avc1.mp4.json vendored Normal file
View file

@ -0,0 +1,78 @@
[{
"SourceFile": "isom-avc1.mp4",
"ExifToolVersion": 12.56,
"FileName": "isom-avc1.mp4",
"Directory": ".",
"FileSize": 8278,
"FileModifyDate": "2023:09:21 13:26:12+00:00",
"FileAccessDate": "2023:09:21 14:31:07+00:00",
"FileInodeChangeDate": "2023:09:21 14:31:07+00:00",
"FilePermissions": 100664,
"FileType": "MP4",
"FileTypeExtension": "MP4",
"MIMEType": "video/mp4",
"MajorBrand": "isom",
"MinorVersion": "0.2.0",
"CompatibleBrands": ["isom","iso2","avc1","mp41"],
"MediaDataSize": 6394,
"MediaDataOffset": 48,
"MovieHeaderVersion": 0,
"CreateDate": "0000:00:00 00:00:00",
"ModifyDate": "0000:00:00 00:00:00",
"TimeScale": 1000,
"Duration": 1.024,
"PreferredRate": 1,
"PreferredVolume": 1,
"PreviewTime": 0,
"PreviewDuration": 0,
"PosterTime": 0,
"SelectionTime": 0,
"SelectionDuration": 0,
"CurrentTime": 0,
"NextTrackID": 3,
"TrackHeaderVersion": 0,
"TrackCreateDate": "0000:00:00 00:00:00",
"TrackModifyDate": "0000:00:00 00:00:00",
"TrackID": 1,
"TrackDuration": 1,
"TrackLayer": 0,
"TrackVolume": 0,
"ImageWidth": 320,
"ImageHeight": 180,
"GraphicsMode": 0,
"OpColor": "0 0 0",
"CompressorID": "avc1",
"SourceImageWidth": 320,
"SourceImageHeight": 180,
"XResolution": 72,
"YResolution": 72,
"BitDepth": 24,
"PixelAspectRatio": "1:1",
"VideoFrameRate": 10,
"MatrixStructure": "1 0 0 0 1 0 0 0 1",
"MediaHeaderVersion": 0,
"MediaCreateDate": "0000:00:00 00:00:00",
"MediaModifyDate": "0000:00:00 00:00:00",
"MediaTimeScale": 44100,
"MediaDuration": 1.02321995464853,
"MediaLanguageCode": "eng",
"HandlerDescription": "SoundHandle",
"Balance": 0,
"AudioFormat": "mp4a",
"AudioChannels": 2,
"AudioBitsPerSample": 16,
"AudioSampleRate": 44100,
"HandlerType": "mdir",
"HandlerVendorID": "appl",
"Encoder": "Lavf58.29.100",
"LocationInformation": "(none) Role=shooting Lat=0.00000 Lon=0.00000 Alt=0.00 Body=earth Notes=",
"ImageSize": "320 180",
"Megapixels": 0.0576,
"AvgBitrate": 49953,
"GPSAltitude": 0,
"GPSAltitudeRef": 0,
"GPSLatitude": 0.00000,
"GPSLongitude": 0.00000,
"Rotation": 0,
"GPSPosition": "0.00000 0.00000"
}]

BIN
pkg/video/testdata/mp42-hvc1.mp4 vendored Normal file

Binary file not shown.

71
pkg/video/testdata/mp42-hvc1.mp4.json vendored Normal file
View file

@ -0,0 +1,71 @@
[{
"SourceFile": "mp42-hvc1.mp4",
"ExifToolVersion": 12.40,
"FileName": "mp42-hvc1.mp4",
"Directory": ".",
"FileSize": 217963,
"FileModifyDate": "2023:09:22 18:36:10+02:00",
"FileAccessDate": "2023:09:22 18:36:12+02:00",
"FileInodeChangeDate": "2023:09:22 18:36:10+02:00",
"FilePermissions": 100664,
"FileType": "MP4",
"FileTypeExtension": "MP4",
"MIMEType": "video/mp4",
"MajorBrand": "mp42",
"MinorVersion": "0.0.1",
"CompatibleBrands": ["isom","mp41","mp42"],
"MediaDataSize": 216002,
"MediaDataOffset": 44,
"MovieHeaderVersion": 0,
"CreateDate": "2023:09:22 16:35:02",
"ModifyDate": "2023:09:22 16:35:03",
"TimeScale": 44100,
"Duration": 1.06666666666667,
"PreferredRate": 1,
"PreferredVolume": 1,
"PreviewTime": 0,
"PreviewDuration": 0,
"PosterTime": 0,
"SelectionTime": 0,
"SelectionDuration": 0,
"CurrentTime": 0,
"NextTrackID": 3,
"TrackHeaderVersion": 0,
"TrackCreateDate": "2023:09:22 16:35:02",
"TrackModifyDate": "2023:09:22 16:35:03",
"TrackID": 1,
"TrackDuration": 1.06666666666667,
"TrackLayer": 0,
"TrackVolume": 0,
"ImageWidth": 464,
"ImageHeight": 848,
"GraphicsMode": 0,
"OpColor": "0 0 0",
"CompressorID": "avc1",
"SourceImageWidth": 464,
"SourceImageHeight": 848,
"XResolution": 72,
"YResolution": 72,
"BitDepth": 24,
"ColorRepresentation": "nclx 1 1 1",
"PixelAspectRatio": "3:3",
"VideoFrameRate": 29.0625,
"MatrixStructure": "1 0 0 0 1 0 0 0 1",
"MediaHeaderVersion": 0,
"MediaCreateDate": "2023:09:22 16:35:02",
"MediaModifyDate": "2023:09:22 16:35:03",
"MediaTimeScale": 44100,
"MediaDuration": 1.11455782312925,
"MediaLanguageCode": "und",
"HandlerType": "soun",
"HandlerDescription": "Core Media Audio",
"Balance": 0,
"AudioFormat": "mp4a",
"AudioChannels": 2,
"AudioBitsPerSample": 16,
"AudioSampleRate": 44100,
"ImageSize": "464 848",
"Megapixels": 0.393472,
"AvgBitrate": 1620015,
"Rotation": 0
}]

BIN
pkg/video/testdata/mp4v-avc1.mp4 vendored Normal file

Binary file not shown.

63
pkg/video/testdata/mp4v-avc1.mp4.json vendored Normal file
View file

@ -0,0 +1,63 @@
[{
"SourceFile": "mp4v-avc1.mp4",
"ExifToolVersion": 12.56,
"FileName": "mp4v-avc1.mp4",
"Directory": ".",
"FileSize": 55061,
"FileModifyDate": "2021:11:01 14:39:36+00:00",
"FileAccessDate": "2023:09:21 14:30:55+00:00",
"FileInodeChangeDate": "2023:09:21 14:30:55+00:00",
"FilePermissions": 100664,
"FileType": "MP4",
"FileTypeExtension": "MP4",
"MIMEType": "video/mp4",
"MajorBrand": "mp4v",
"MinorVersion": "0.0.0",
"CompatibleBrands": ["mp4v","mp42","isom"],
"MovieHeaderVersion": 0,
"CreateDate": "0000:00:00 00:00:00",
"ModifyDate": "0000:00:00 00:00:00",
"TimeScale": 11111,
"Duration": 0.810008100081001,
"PreferredRate": 1,
"PreferredVolume": 1,
"PreviewTime": 0,
"PreviewDuration": 0,
"PosterTime": 0,
"SelectionTime": 0,
"SelectionDuration": 0,
"CurrentTime": 0,
"NextTrackID": 2,
"TrackHeaderVersion": 0,
"TrackCreateDate": "0000:00:00 00:00:00",
"TrackModifyDate": "0000:00:00 00:00:00",
"TrackID": 1,
"TrackDuration": 0.810008100081001,
"TrackLayer": 0,
"TrackVolume": 1,
"MatrixStructure": "1 0 0 0 1 0 0 0 1",
"ImageWidth": 640,
"ImageHeight": 416,
"MediaHeaderVersion": 0,
"MediaCreateDate": "0000:00:00 00:00:00",
"MediaModifyDate": "0000:00:00 00:00:00",
"MediaTimeScale": 11111,
"MediaDuration": 0.810008100081001,
"MediaLanguageCode": "und",
"HandlerType": "vide",
"GraphicsMode": 0,
"OpColor": "0 0 0",
"CompressorID": "avc1",
"SourceImageWidth": 640,
"SourceImageHeight": 416,
"XResolution": 72,
"YResolution": 72,
"BitDepth": 24,
"VideoFrameRate": 11.111,
"MediaDataSize": 54336,
"MediaDataOffset": 725,
"ImageSize": "640 416",
"Megapixels": 0.26624,
"AvgBitrate": 536646,
"Rotation": 0
}]

BIN
pkg/video/testdata/quicktime-hvc1.mov vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,99 @@
[{
"SourceFile": "quicktime-hvc1.mov",
"ExifToolVersion": 12.40,
"FileName": "quicktime-hvc1.mov",
"Directory": ".",
"FileSize": 710953,
"FileModifyDate": "2023:09:22 18:41:14+02:00",
"FileAccessDate": "2023:09:22 18:43:09+02:00",
"FileInodeChangeDate": "2023:09:22 18:44:53+02:00",
"FilePermissions": 100664,
"FileType": "MOV",
"FileTypeExtension": "MOV",
"MIMEType": "video/quicktime",
"MajorBrand": "qt ",
"MinorVersion": "0.0.0",
"CompatibleBrands": ["qt "],
"MediaDataSize": 702641,
"MediaDataOffset": 36,
"MovieHeaderVersion": 0,
"CreateDate": "2023:09:22 16:34:05",
"ModifyDate": "2023:09:22 16:34:07",
"TimeScale": 600,
"Duration": 1.16666666666667,
"PreferredRate": 1,
"PreferredVolume": 1,
"PreviewTime": 0,
"PreviewDuration": 0,
"PosterTime": 0,
"SelectionTime": 0,
"SelectionDuration": 0,
"CurrentTime": 0,
"NextTrackID": 6,
"TrackHeaderVersion": 0,
"TrackCreateDate": "2023:09:22 16:34:05",
"TrackModifyDate": "2023:09:22 16:34:07",
"TrackID": 1,
"TrackDuration": 1.16666666666667,
"TrackLayer": 0,
"TrackVolume": 0,
"ImageWidth": 1280,
"ImageHeight": 720,
"CleanApertureDimensions": "1280 720",
"ProductionApertureDimensions": "1280 720",
"EncodedPixelsDimensions": "1280 720",
"GraphicsMode": 64,
"OpColor": "32768 32768 32768",
"CompressorID": "hvc1",
"SourceImageWidth": 1280,
"SourceImageHeight": 720,
"XResolution": 72,
"YResolution": 72,
"CompressorName": "HEVC",
"BitDepth": 24,
"VideoFrameRate": 29.9572039942939,
"CameraLensModel-und-DE": "iPad mini back camera 3mm f/1.8",
"CameraFocalLength35mmEquivalent-und-DE": 30,
"Balance": 0,
"AudioFormat": "mp4a",
"AudioChannels": 1,
"AudioBitsPerSample": 16,
"AudioSampleRate": 44100,
"PurchaseFileFormat": "mp4a",
"Warning": "[minor] The ExtractEmbedded option may find more tags in the media data",
"MatrixStructure": "1 0 0 0 1 0 0 0 1",
"ContentDescribes": 1,
"MediaHeaderVersion": 0,
"MediaCreateDate": "2023:09:22 16:34:05",
"MediaModifyDate": "2023:09:22 16:34:07",
"MediaTimeScale": 600,
"MediaDuration": 1.16833333333333,
"MediaLanguageCode": "und",
"GenMediaVersion": 0,
"GenFlags": "0 0 0",
"GenGraphicsMode": 64,
"GenOpColor": "32768 32768 32768",
"GenBalance": 0,
"HandlerClass": "dhlr",
"HandlerVendorID": "appl",
"HandlerDescription": "Core Media Data Handler",
"MetaFormat": "mebx",
"HandlerType": "mdta",
"LocationAccuracyHorizontal": 35.000000,
"GPSCoordinates": "52.4597 13.322 51.178",
"Make": "Apple",
"Model": "iPad mini",
"Software": 17.0,
"CreationDate": "2023:09:22 18:34:05+02:00",
"CameraLensModel": "iPad mini back camera 3mm f/1.8",
"CameraFocalLength35mmEquivalent": 30,
"ImageSize": "1280 720",
"Megapixels": 0.9216,
"AvgBitrate": 4818110,
"GPSAltitude": 51.178,
"GPSAltitudeRef": 0,
"GPSLatitude": 52.4597,
"GPSLongitude": 13.322,
"Rotation": 90,
"GPSPosition": "52.4597 13.322"
}]

BIN
pkg/video/testdata/quicktime-jpeg.mov vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,62 @@
[{
"SourceFile": "quicktime-jpeg.mov",
"ExifToolVersion": 12.40,
"FileName": "quicktime-jpeg.mov",
"Directory": ".",
"FileSize": 475190,
"FileModifyDate": "2023:09:22 18:30:07+02:00",
"FileAccessDate": "2023:09:22 18:46:27+02:00",
"FileInodeChangeDate": "2023:09:22 18:46:27+02:00",
"FilePermissions": 100664,
"FileType": "MOV",
"FileTypeExtension": "MOV",
"MIMEType": "video/quicktime",
"MovieHeaderVersion": 0,
"CreateDate": "2003:06:29 17:17:17",
"ModifyDate": "2003:06:29 17:18:19",
"TimeScale": 600,
"Duration": 41.6666666666667,
"PreferredRate": 1,
"PreferredVolume": 0.99609375,
"PreviewTime": 0,
"PreviewDuration": 0,
"PosterTime": 0,
"SelectionTime": 0,
"SelectionDuration": 0,
"CurrentTime": 0,
"NextTrackID": 2,
"TrackHeaderVersion": 0,
"TrackCreateDate": "2003:06:29 17:17:17",
"TrackModifyDate": "2003:06:29 17:17:40",
"TrackID": 1,
"TrackDuration": 41.6666666666667,
"TrackLayer": 0,
"TrackVolume": 0,
"MatrixStructure": "1 0 0 0 1 0 0 0 1",
"ImageWidth": 434,
"ImageHeight": 343,
"MediaHeaderVersion": 0,
"MediaCreateDate": "2003:06:29 17:17:17",
"MediaModifyDate": "2003:06:29 17:17:40",
"MediaTimeScale": 600,
"MediaDuration": 41.6666666666667,
"GraphicsMode": 64,
"OpColor": "32768 32768 32768",
"HandlerClass": "dhlr",
"HandlerType": "alis",
"HandlerVendorID": "appl",
"HandlerDescription": "Apple Alias Data Handler",
"CompressorID": "jpeg",
"VendorID": "appl",
"SourceImageWidth": 434,
"SourceImageHeight": 343,
"XResolution": 72,
"YResolution": 72,
"CompressorName": "Photo - JPEG",
"BitDepth": 24,
"VideoFrameRate": 12,
"WindowLocation": "88 32",
"ImageSize": "434 343",
"Megapixels": 0.148862,
"Rotation": 0
}]

View file

@ -6,9 +6,9 @@ import (
// Type represents a video format type.
type Type struct {
File fs.Type
Codec Codec
Width int
Height int
Public bool
Codec Codec
FileType fs.Type
WidthLimit int
HeightLimit int
Public bool
}

View file

@ -4,83 +4,98 @@ import (
"github.com/photoprism/photoprism/pkg/fs"
)
// Unknown represents an unknown video file type.
var Unknown = Type{
Codec: CodecUnknown,
FileType: fs.TypeUnknown,
}
// MP4 is a Multimedia Container (MPEG-4 Part 14).
var MP4 = Type{
File: fs.VideoMP4,
Codec: CodecAVC,
Width: 0,
Height: 0,
Public: true,
Codec: CodecAVC,
FileType: fs.VideoMP4,
WidthLimit: 8192,
HeightLimit: 4320,
Public: true,
}
// MOV are QuickTime videos based on the MPEG-4 format,
var MOV = Type{
Codec: CodecAVC,
FileType: fs.VideoMOV,
WidthLimit: 8192,
HeightLimit: 4320,
Public: true,
}
// AVC aka Advanced Video Coding (H.264).
var AVC = Type{
File: fs.VideoAVC,
Codec: CodecAVC,
Width: 0,
Height: 0,
Public: true,
Codec: CodecAVC,
FileType: fs.VideoAVC,
WidthLimit: 8192,
HeightLimit: 4320,
Public: true,
}
// HEVC aka High Efficiency Video Coding (H.265).
var HEVC = Type{
File: fs.VideoHEVC,
Codec: CodecHEVC,
Width: 0,
Height: 0,
Public: false,
Codec: CodecHVC,
FileType: fs.VideoHEVC,
WidthLimit: 0,
HeightLimit: 0,
Public: false,
}
// VVC aka Versatile Video Coding (H.266).
var VVC = Type{
File: fs.VideoVVC,
Codec: CodecVVC,
Width: 0,
Height: 0,
Public: false,
Codec: CodecVVC,
FileType: fs.VideoVVC,
WidthLimit: 0,
HeightLimit: 0,
Public: false,
}
// VP8 + Google WebM.
var VP8 = Type{
File: fs.VideoWebM,
Codec: CodecVP8,
Width: 0,
Height: 0,
Public: false,
Codec: CodecVP8,
FileType: fs.VideoWebM,
WidthLimit: 0,
HeightLimit: 0,
Public: false,
}
// VP9 + Google WebM.
var VP9 = Type{
File: fs.VideoWebM,
Codec: CodecVP9,
Width: 0,
Height: 0,
Public: false,
Codec: CodecVP9,
FileType: fs.VideoWebM,
WidthLimit: 0,
HeightLimit: 0,
Public: false,
}
// AV1 + Google WebM.
var AV1 = Type{
File: fs.VideoWebM,
Codec: CodecAV1,
Width: 0,
Height: 0,
Public: false,
Codec: CodecAV1,
FileType: fs.VideoWebM,
WidthLimit: 0,
HeightLimit: 0,
Public: false,
}
// OGV aka Ogg/Theora.
var OGV = Type{
File: fs.VideoOGV,
Codec: CodecOGV,
Width: 0,
Height: 0,
Public: false,
Codec: CodecOGV,
FileType: fs.VideoOGV,
WidthLimit: 0,
HeightLimit: 0,
Public: false,
}
// WebM Container.
var WebM = Type{
File: fs.VideoWebM,
Codec: UnknownCodec,
Width: 0,
Height: 0,
Public: false,
Codec: CodecUnknown,
FileType: fs.VideoWebM,
WidthLimit: 0,
HeightLimit: 0,
Public: false,
}

View file

@ -1,5 +1,5 @@
/*
Package video provides video file related types and functions.
Package video provides video file related types and abstractions.
Copyright (c) 2018 - 2023 PhotoPrism UG. All rights reserved.