Live Photos: Reset duration and improve type checks when indexing #5089

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-07-07 11:15:02 +02:00
parent 5ad7f6318b
commit 8fcc2a232b
6 changed files with 112 additions and 67 deletions

View File

@@ -8180,12 +8180,8 @@
1000000000,
60000000000,
3600000000000,
1,
1000,
1000000,
1000000000,
60000000000,
3600000000000,
-9223372036854775808,
9223372036854775807,
1,
1000,
1000000,
@@ -8202,12 +8198,8 @@
"Second",
"Minute",
"Hour",
"Nanosecond",
"Microsecond",
"Millisecond",
"Second",
"Minute",
"Hour",
"minDuration",
"maxDuration",
"Nanosecond",
"Microsecond",
"Millisecond",

View File

@@ -367,11 +367,16 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
}
}
// Reset the video duration if this is a forced rescan.
if o.Rescan && photoUID == "" {
photo.PhotoDuration = 0
}
// Reset file perceptive diff and chroma percent.
file.FileDiff = -1
file.FileChroma = -1
file.FileVideo = m.IsVideo()
file.MediaType = m.Media().String()
file.MediaType = m.MediaType().String()
// Handle file types.
switch {
@@ -455,14 +460,10 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
}
}
// If the photo contains an animation or has a video,
// change the photo type from image to animated or live.
if photo.HasMediaType(media.Image) {
if m.IsAnimatedImage() {
photo.SetMediaType(media.Animated, entity.SrcAuto)
} else if m.IsLive() {
photo.SetMediaType(media.Live, entity.SrcAuto)
}
// If the file contains multiple images for an animation,
// change the media type to "animated".
if photo.HasMediaType(media.Image) && m.IsAnimatedImage() {
photo.SetMediaType(media.Animated, entity.SrcAuto)
}
case m.IsXMP():
if data, dataErr := meta.XMP(m.FileName()); dataErr == nil {
@@ -573,13 +574,14 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
photo.SetExposure(m.FocalLength(), m.FNumber(), m.Iso(), m.Exposure(), entity.SrcMeta)
}
// Update photo type if an image and not manually modified.
// If the media type is still set to "image" and has not been
// manually modified, then check and update it as needed.
if photo.HasMediaType(media.Image) {
if m.IsAnimatedImage() {
photo.SetMediaType(media.Animated, entity.SrcAuto)
} else if m.IsRaw() {
photo.SetMediaType(media.Raw, entity.SrcAuto)
} else if m.IsLive() {
} else if m.IsLive(photo.PhotoDuration) {
photo.SetMediaType(media.Live, entity.SrcAuto)
} else if m.IsVector() {
photo.SetMediaType(media.Vector, entity.SrcAuto)
@@ -756,8 +758,9 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
photo.SetExposure(m.FocalLength(), m.FNumber(), m.Iso(), m.Exposure(), entity.SrcMeta)
}
// Set photo media type to "live" or "video".
if m.IsLive() {
// Set the media type to "live" instead of "video" if the video duration
// is less than 3.1 seconds and a JPEG or HEIC image exists.
if photo.PhotoDuration > 0 && m.IsLive(photo.PhotoDuration) {
photo.SetMediaType(media.Live, entity.SrcAuto)
} else {
photo.SetMediaType(media.Video, entity.SrcAuto)

View File

@@ -938,14 +938,22 @@ func (m *MediaFile) CheckType() error {
return fmt.Errorf("has an invalid extension for media type %s", clean.LogQuote(mimeType))
}
// Media returns the media content type (video, image, raw, sidecar,...).
func (m *MediaFile) Media() media.Type {
// MediaType returns the media content type, e.g. video, image, raw, or sidecar.
func (m *MediaFile) MediaType() media.Type {
return media.FromName(m.fileName)
}
// HasMediaType checks if the file has is the given media type.
func (m *MediaFile) HasMediaType(mediaType media.Type) bool {
return m.Media() == mediaType
// HasMediaType checks if the file has any of the given media types.
func (m *MediaFile) HasMediaType(mediaTypes ...media.Type) bool {
mediaType := m.MediaType()
for _, t := range mediaTypes {
if mediaType == t {
return true
}
}
return false
}
// HasFileType checks if the file has the given file type.
@@ -992,9 +1000,14 @@ func (m *MediaFile) IsVideo() bool {
return m.HasMediaType(media.Video)
}
// IsMov returns true if this is a MOV (QuickTime) video file.
func (m *MediaFile) IsMov() bool {
return fs.FileType(m.fileName) == fs.VideoMov
}
// IsSidecar checks if the file is a metadata sidecar file, independent of the storage location.
func (m *MediaFile) IsSidecar() bool {
return !m.Media().IsMain()
return !m.MediaType().IsMain()
}
// IsArchive returns true if this is an archive file.
@@ -1063,22 +1076,38 @@ func (m *MediaFile) IsImageNative() bool {
}
// IsLive checks if the file is a live photo.
func (m *MediaFile) IsLive() bool {
func (m *MediaFile) IsLive(videoDuration time.Duration) bool {
if !m.InOriginals() {
// Live Photos must be located in the Originals folder.
return false
} else if !m.HasMediaType(media.Video, media.Image, media.Live) {
// Live Photos may only consist of video, image, or live files.
return false
} else if videoDuration > media.LiveMaxDuration {
// Live Photos can include a maximum of 3.1 seconds of video.
return false
}
if m.HasFileType(fs.VideoMov) {
if fs.ImageHeic.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" ||
fs.ImageJpeg.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" {
return true
// Check for related image or video files in the expected formats.
switch m.MediaType() {
case media.Video:
// Live Photos may only have MOV video sidecar files.
if m.IsMov() {
if fs.ImageHeic.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" ||
fs.ImageJpeg.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" {
return true
}
}
} else if m.IsHeic() || m.IsJpeg() {
if fs.VideoMov.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" {
return true
case media.Image:
// Live Photos must be either HEIC or JPEG image files.
if m.IsHeic() || m.IsJpeg() {
if fs.VideoMov.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" {
return true
}
}
}
// If none of the above applies, check the metadata for embedded videos.
return m.MetaData().MediaType == media.Live && m.VideoInfo().Compatible
}

View File

@@ -150,6 +150,11 @@ func TestMediaFile_CreateExifToolJson(t *testing.T) {
assert.True(t, mediaFile.IsM2TS())
assert.True(t, mediaFile.IsVideo())
assert.True(t, mediaFile.HasMediaType(media.Video))
assert.True(t, mediaFile.HasMediaType(media.Video, media.Image))
assert.False(t, mediaFile.HasMediaType(media.Image))
assert.False(t, mediaFile.HasMediaType(media.Live))
assert.False(t, mediaFile.HasMediaType())
assert.Equal(t, media.Video, mediaFile.MediaType())
assert.Equal(t, fs.VideoM2TS, mediaFile.FileType())
assert.Equal(t, header.ContentTypeM2TS, mediaFile.MimeType())
assert.Equal(t, header.ContentTypeM2TS, mediaFile.ContentType())

View File

@@ -13,6 +13,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/media/http/header"
)
@@ -689,73 +690,79 @@ func TestMediaFile_MimeType(t *testing.T) {
c := config.TestConfig()
t.Run("elephants.jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
f, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "image/jpeg", mediaFile.MimeType())
assert.Equal(t, "image/jpeg", f.MimeType())
})
t.Run("canon_eos_6d.dng", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/canon_eos_6d.dng")
f, err := NewMediaFile(c.ExamplesPath() + "/canon_eos_6d.dng")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "image/dng", mediaFile.MimeType())
assert.True(t, mediaFile.IsDng())
assert.True(t, mediaFile.IsRaw())
assert.Equal(t, "image/dng", f.MimeType())
assert.True(t, f.IsDng())
assert.True(t, f.IsRaw())
})
t.Run("iphone_7.xmp", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.xmp")
f, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.xmp")
if err != nil {
t.Fatal(err)
}
assert.True(t, fs.SameType(header.ContentTypeText, mediaFile.BaseType()))
assert.Equal(t, "text/plain", mediaFile.BaseType())
assert.Equal(t, "text/plain; charset=utf-8", mediaFile.MimeType())
assert.True(t, mediaFile.HasMimeType("text/plain"))
assert.True(t, fs.SameType(header.ContentTypeText, f.BaseType()))
assert.Equal(t, "text/plain", f.BaseType())
assert.Equal(t, "text/plain; charset=utf-8", f.MimeType())
assert.True(t, f.HasMimeType("text/plain"))
})
t.Run("iphone_7.json", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
f, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
if err != nil {
t.Fatal(err)
}
assert.True(t, fs.SameType(header.ContentTypeJson, mediaFile.MimeType()))
assert.True(t, fs.SameType(header.ContentTypeJson, f.MimeType()))
})
t.Run("fox.profile0.8bpc.yuv420.avif", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/fox.profile0.8bpc.yuv420.avif")
f, err := NewMediaFile(c.ExamplesPath() + "/fox.profile0.8bpc.yuv420.avif")
if err != nil {
t.Fatal(err)
}
assert.True(t, fs.SameType(header.ContentTypeAvif, mediaFile.MimeType()))
assert.True(t, mediaFile.IsAvif())
assert.True(t, fs.SameType(header.ContentTypeAvif, f.MimeType()))
assert.True(t, f.IsAvif())
})
t.Run("iphone_7.heic", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
f, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
if err != nil {
t.Fatal(err)
}
assert.True(t, fs.SameType(header.ContentTypeHeic, mediaFile.MimeType()))
assert.True(t, mediaFile.IsHeic())
assert.True(t, fs.SameType(header.ContentTypeHeic, f.MimeType()))
assert.True(t, f.IsHeic())
assert.False(t, f.IsMov())
assert.False(t, f.IsVideo())
})
t.Run("IMG_4120.AAE", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/IMG_4120.AAE")
f, err := NewMediaFile(c.ExamplesPath() + "/IMG_4120.AAE")
if err != nil {
t.Fatal(err)
}
assert.True(t, fs.SameType(header.ContentTypeXml, mediaFile.BaseType()))
assert.Equal(t, "text/xml", mediaFile.BaseType())
assert.Equal(t, "text/xml; charset=utf-8", mediaFile.MimeType())
assert.True(t, mediaFile.HasMimeType("text/xml"))
assert.True(t, fs.SameType(header.ContentTypeXml, f.BaseType()))
assert.Equal(t, "text/xml", f.BaseType())
assert.Equal(t, "text/xml; charset=utf-8", f.MimeType())
assert.True(t, f.HasMimeType("text/xml"))
assert.False(t, f.IsMov())
})
t.Run("earth.mov", func(t *testing.T) {
if f, err := NewMediaFile(filepath.Join(c.ExamplesPath(), "earth.mov")); err != nil {
t.Fatal(err)
} else {
assert.Equal(t, "video/quicktime", f.MimeType())
assert.False(t, f.IsHeic())
assert.True(t, f.IsMov())
assert.True(t, f.IsVideo())
}
})
t.Run("blue-go-video.mp4", func(t *testing.T) {
@@ -763,6 +770,9 @@ func TestMediaFile_MimeType(t *testing.T) {
t.Fatal(err)
} else {
assert.Equal(t, "video/mp4", f.MimeType())
assert.False(t, f.IsHeic())
assert.False(t, f.IsMov())
assert.True(t, f.IsVideo())
}
})
t.Run("bear.m2ts", func(t *testing.T) {
@@ -1528,7 +1538,7 @@ func TestMediaFile_IsLive(t *testing.T) {
if f, err := NewMediaFile(fileName); err != nil {
t.Fatal(err)
} else {
assert.False(t, f.IsLive()) // Image is not in originals path.
assert.False(t, f.IsLive(media.LiveMaxDuration)) // Image is not in originals path.
assert.False(t, f.IsRaw())
assert.True(t, f.IsImage())
assert.False(t, f.IsVideo())
@@ -1540,7 +1550,7 @@ func TestMediaFile_IsLive(t *testing.T) {
if f, err := NewMediaFile(filepath.Join(c.ExamplesPath(), "christmas.mp4")); err != nil {
t.Fatal(err)
} else {
assert.False(t, f.IsLive())
assert.False(t, f.IsLive(time.Second*3))
assert.False(t, f.IsRaw())
assert.False(t, f.IsImage())
assert.True(t, f.IsVideo())
@@ -1552,7 +1562,7 @@ func TestMediaFile_IsLive(t *testing.T) {
if f, err := NewMediaFile(filepath.Join(c.ExamplesPath(), "canon_eos_6d.dng")); err != nil {
t.Fatal(err)
} else {
assert.False(t, f.IsLive())
assert.False(t, f.IsLive(time.Second*3))
assert.True(t, f.IsRaw())
assert.False(t, f.IsImage())
assert.False(t, f.IsVideo())
@@ -1564,7 +1574,7 @@ func TestMediaFile_IsLive(t *testing.T) {
if f, err := NewMediaFile(filepath.Join(c.ExamplesPath(), "iphone_7.json")); err != nil {
t.Fatal(err)
} else {
assert.False(t, f.IsLive())
assert.False(t, f.IsLive(time.Second*3))
assert.False(t, f.IsRaw())
assert.False(t, f.IsImage())
assert.False(t, f.IsVideo())

6
pkg/media/live.go Normal file
View File

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