Videos: Index and display durations of less than one second #3224

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-02-22 16:33:33 +01:00
parent e7f6d79018
commit eaff0abb6d
39 changed files with 827 additions and 379 deletions

View file

@ -176,6 +176,8 @@ export default class Util {
return "Bitmap";
case "png":
return "Portable Network Graphics";
case "apng":
return "Animated PNG";
case "tiff":
return "TIFF";
case "psd":
@ -189,6 +191,8 @@ export default class Util {
return "Advanced Video Coding (AVC) / H.264";
case "avif":
return "AOMedia Video 1 (AV1)";
case "avifs":
return "AVIF Image Sequence";
case "hevc":
case "hvc":
case "hvc1":
@ -265,10 +269,14 @@ export default class Util {
return "Motion JPEG (M-JPEG)";
case "avif":
return "AV1 Image File Format (AVIF)";
case "avifs":
return "AVIF Image Sequence";
case "heif":
return "High Efficiency Image File Format (HEIF)";
case "heic":
return "High Efficiency Image Container (HEIC)";
case "heics":
return "HEIC Image Sequence";
case "1":
return "Uncompressed";
case "2":

View file

@ -200,10 +200,8 @@ export class File extends RestModel {
isAnimated() {
return (
this.MediaType &&
this.Frames &&
this.MediaType === MediaImage &&
this.Frames &&
this.Frames > 1
((this.Frames && this.Frames > 1) || (this.Duration && this.Duration > 1))
);
}

View file

@ -481,7 +481,7 @@ export class Photo extends RestModel {
return false;
}
return this.Files.find((f) => f.FileType === FormatGif || !!f.Frames);
return this.Files.find((f) => f.FileType === FormatGif || !!f.Frames || !!f.Duration);
}
videoUrl() {

View file

@ -82,7 +82,7 @@ func UploadUserAvatar(router *gin.RouterGroup) {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
} else if !mimeType.Is(fs.MimeTypeJpeg) {
} else if !mimeType.Is(fs.MimeTypeJPEG) {
event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", "only jpeg supported"}, s.RefID)
Abort(c, http.StatusBadRequest, i18n.ErrUnsupportedFormat)
return

View file

@ -52,14 +52,14 @@ func GetVideo(router *gin.RouterGroup) {
f, err = query.VideoByPhotoUID(f.PhotoUID)
if err != nil {
log.Errorf("video: %s", err.Error())
log.Errorf("video: no playable file found")
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
return
}
}
if f.FileError != "" {
log.Errorf("video: file error %s", f.FileError)
log.Errorf("video: file has error %s", f.FileError)
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
return
}

View file

@ -618,7 +618,7 @@ var Flags = CliFlags{
Flag: cli.StringFlag{
Name: "imagemagick-blacklist",
Usage: "do not use ImageMagick to convert files with these `EXTENSIONS`",
Value: "",
Value: "heif,heic,heics,avif,avifs,jxl",
EnvVar: "PHOTOPRISM_IMAGEMAGICK_BLACKLIST",
}}, {
Flag: cli.StringFlag{

View file

@ -655,7 +655,7 @@ func (m *File) SetDuration(d time.Duration) {
return
}
m.FileDuration = d.Round(time.Second)
m.FileDuration = d.Round(10e6)
// Update number of frames.
if m.FileFrames == 0 && m.FileFPS > 1 {

View file

@ -18,8 +18,8 @@ func AvcConvertCommand(fileName, avcName, ffmpegBin, bitrate string, encoder Avc
// Don't transcode more than one video at the same time.
useMutex = true
// Animated GIF or PNG?
if fs.FileType(fileName) == fs.ImageGIF || fs.FileType(fileName) == fs.ImagePNG {
// Don't use hardware transcoding for animated images.
if fs.TypeAnimated[fs.FileType(fileName)] != "" {
result = exec.Command(
ffmpegBin,
"-i", fileName,

View file

@ -23,10 +23,10 @@ type Data struct {
TakenGps time.Time `meta:"GPSDateTime,GPSDateStamp"`
TakenNs int `meta:"-"`
TimeZone string `meta:"-"`
Duration time.Duration `meta:"Duration,MediaDuration,TrackDuration"`
Duration time.Duration `meta:"Duration,MediaDuration,TrackDuration,PreviewDuration"`
FPS float64 `meta:"VideoFrameRate,VideoAvgFrameRate"`
Frames int `meta:"FrameCount,AnimationFrames"`
Codec string `meta:"CompressorID,VideoCodecID,CodecID,FileType"`
Codec string `meta:"CompressorID,VideoCodecID,CodecID,OtherFormat,MajorBrand,FileType"`
Title string `meta:"Headline,Title" xmp:"dc:title" dc:"title,title.Alt"`
Subject string `meta:"Subject,PersonInImage,ObjectName,HierarchicalSubject,CatalogSets" xmp:"Subject"`
Keywords Keywords `meta:"Keywords"`

View file

@ -1,40 +1,33 @@
package meta
import (
"regexp"
"strconv"
"strings"
"time"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt"
)
var DurationSecondsRegexp = regexp.MustCompile("[0-9\\.]+")
// StringToDuration converts a metadata string to a valid duration.
func StringToDuration(s string) (d time.Duration) {
// Duration converts a metadata string to a valid duration.
func Duration(s string) (result time.Duration) {
if s == "" {
return d
return 0
}
s = strings.TrimSpace(s)
s = clean.Duration(s)
if s == "" {
return d
if txt.IsFloat(s) {
result = time.Duration(txt.Float(s) * 1e9)
} else if n := strings.Split(strings.TrimSpace(s), ":"); len(n) == 3 {
hr, _ := strconv.Atoi(n[0])
min, _ := strconv.Atoi(n[1])
sec, _ := strconv.Atoi(n[2])
result = time.Duration(hr)*time.Hour + time.Duration(min)*time.Minute + time.Duration(sec)*time.Second
} else if d, err := time.ParseDuration(s); err == nil {
result = d
}
sec := DurationSecondsRegexp.FindAllString(s, -1)
if len(sec) == 1 {
secFloat, _ := strconv.ParseFloat(sec[0], 64)
d = time.Duration(secFloat) * time.Second
} else if n := strings.Split(s, ":"); len(n) == 3 {
h, _ := strconv.Atoi(n[0])
m, _ := strconv.Atoi(n[1])
s, _ := strconv.Atoi(n[2])
d = time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(s)*time.Second
} else if pd, err := time.ParseDuration(s); err != nil {
d = pd
}
return d
return result.Round(10e6)
}

View file

@ -6,49 +6,54 @@ import (
"github.com/stretchr/testify/assert"
)
func TestStringToDuration(t *testing.T) {
func TestDuration(t *testing.T) {
t.Run("empty", func(t *testing.T) {
d := StringToDuration("")
d := Duration("")
assert.Equal(t, "0s", d.String())
})
t.Run("0", func(t *testing.T) {
d := StringToDuration("0")
d := Duration("0")
assert.Equal(t, "0s", d.String())
})
t.Run("0.5", func(t *testing.T) {
d := Duration("0.5")
assert.Equal(t, "500ms", d.String())
})
t.Run("2.41 s", func(t *testing.T) {
d := StringToDuration("2.41 s")
assert.Equal(t, "2s", d.String())
d := Duration("2.41 s")
assert.Equal(t, "2.41s", d.String())
})
t.Run("0.41 s", func(t *testing.T) {
d := StringToDuration("0.41 s")
assert.Equal(t, "0s", d.String())
d := Duration("0.41 s")
assert.Equal(t, "410ms", d.String())
})
t.Run("41 s", func(t *testing.T) {
d := StringToDuration("41 s")
d := Duration("41 s")
assert.Equal(t, "41s", d.String())
})
t.Run("0:0:1", func(t *testing.T) {
d := StringToDuration("0:0:1")
d := Duration("0:0:1")
assert.Equal(t, "1s", d.String())
})
t.Run("0:04:25", func(t *testing.T) {
d := StringToDuration("0:04:25")
d := Duration("0:04:25")
assert.Equal(t, "4m25s", d.String())
})
t.Run("0001:04:25", func(t *testing.T) {
d := StringToDuration("0001:04:25")
d := Duration("0001:04:25")
assert.Equal(t, "1h4m25s", d.String())
})
t.Run("invalid", func(t *testing.T) {
d := StringToDuration("01:04:25:67")
d := Duration("01:04:25:67")
assert.Equal(t, "0s", d.String())
})
}

View file

@ -73,7 +73,7 @@ func RawExif(fileName string, fileFormat fs.Type, bruteForce bool) (rawExif []by
parsed = true
}
}
case fs.ImageHEIC:
case fs.ImageHEIF, fs.ImageHEIC, fs.ImageHEICS, fs.ImageAVIF, fs.ImageAVIFS:
heicMp := heicexif.NewHeicExifMediaParser()
cs, err := heicMp.ParseFile(fileName)

View file

@ -100,7 +100,7 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
continue
}
fieldValue.Set(reflect.ValueOf(StringToDuration(jsonValue.String())))
fieldValue.Set(reflect.ValueOf(Duration(jsonValue.String())))
case int, int64:
if !fieldValue.IsZero() {
continue
@ -118,7 +118,7 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
if f := jsonValue.Float(); f != 0 {
fieldValue.SetFloat(f)
} else if f = txt.Float64(jsonValue.String()); f != 0 {
} else if f = txt.Float(jsonValue.String()); f != 0 {
fieldValue.SetFloat(f)
}
case uint, uint64:

View file

@ -23,7 +23,8 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "20170323-083538-Berlin-Zoologischer-Garten-2017-2u4.mov", data.FileName)
assert.Equal(t, CodecAvc1, data.Codec)
assert.Equal(t, "3s", data.Duration.String())
assert.Equal(t, int64(3540), data.Duration.Milliseconds())
assert.Equal(t, "3.54s", data.Duration.String())
assert.Equal(t, "2018-09-08 19:20:14 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2018-09-08 17:20:14 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 0, data.TakenNs)
@ -49,7 +50,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "yoga-av1.webm", data.FileName)
assert.Equal(t, "", data.Codec)
assert.Equal(t, "20s", data.Duration.String())
assert.Equal(t, "20.3s", data.Duration.String())
assert.Equal(t, 854, data.Width)
assert.Equal(t, 480, data.Height)
assert.Equal(t, 854, data.ActualWidth())
@ -65,7 +66,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "stream.webm", data.FileName)
assert.Equal(t, CodecAv1, data.Codec)
assert.Equal(t, "2m24s", data.Duration.String())
assert.Equal(t, "2m24.12s", data.Duration.String())
assert.Equal(t, 1280, data.Width)
assert.Equal(t, 720, data.Height)
assert.Equal(t, 1280, data.ActualWidth())
@ -113,7 +114,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "earth-animation.ogv.720p.vp9.webm", data.FileName)
assert.Equal(t, string(video.CodecVP9), data.Codec)
assert.Equal(t, "8s", data.Duration.String())
assert.Equal(t, "8.03s", data.Duration.String())
assert.Equal(t, 1280, data.Width)
assert.Equal(t, 720, data.Height)
assert.Equal(t, 1280, data.ActualWidth())
@ -130,7 +131,7 @@ func TestJSON(t *testing.T) {
// t.Logf("DATA: %+v", data)
assert.Equal(t, CodecAvc1, data.Codec)
assert.Equal(t, "2s", data.Duration.String())
assert.Equal(t, "2.41s", data.Duration.String())
assert.Equal(t, "2020-05-11 14:18:35 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2020-05-11 14:18:35 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 0, data.TakenNs)
@ -157,7 +158,7 @@ func TestJSON(t *testing.T) {
// t.Logf("DATA: %+v", data)
assert.Equal(t, CodecAvc1, data.Codec)
assert.Equal(t, "2s", data.Duration.String())
assert.Equal(t, "2.42s", data.Duration.String())
assert.Equal(t, "2020-05-11 16:16:48 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2020-05-11 14:16:48 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Berlin", data.TimeZone)
@ -184,7 +185,7 @@ func TestJSON(t *testing.T) {
// t.Logf("DATA: %+v", data)
assert.Equal(t, CodecAvc1, data.Codec)
assert.Equal(t, "4s", data.Duration.String())
assert.Equal(t, "4.3s", data.Duration.String())
assert.Equal(t, "2020-05-14 13:34:41 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2020-05-14 11:34:41 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 0, data.TakenNs)
@ -649,7 +650,7 @@ func TestJSON(t *testing.T) {
}
assert.Equal(t, CodecAvc1, data.Codec)
assert.Equal(t, "6s", data.Duration.String())
assert.Equal(t, "6.83s", data.Duration.String())
assert.Equal(t, "2015-06-10 14:06:09 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2015-06-10 11:06:09 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Moscow", data.TimeZone)
@ -673,7 +674,7 @@ func TestJSON(t *testing.T) {
}
assert.Equal(t, string(video.CodecAVC), data.Codec)
assert.Equal(t, "10s", data.Duration.String())
assert.Equal(t, "10.67s", data.Duration.String())
assert.Equal(t, "2015-12-06 18:22:29 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2015-12-06 15:22:29 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Moscow", data.TimeZone)
@ -697,7 +698,7 @@ func TestJSON(t *testing.T) {
}
assert.Equal(t, string(video.CodecHEVC), data.Codec)
assert.Equal(t, "6s", data.Duration.String())
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())
assert.Equal(t, "", data.TimeZone)
@ -721,7 +722,7 @@ func TestJSON(t *testing.T) {
}
assert.Equal(t, string(video.CodecHEVC), data.Codec)
assert.Equal(t, "2s", data.Duration.String())
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())
assert.Equal(t, "America/New_York", data.TimeZone)
@ -758,7 +759,7 @@ func TestJSON(t *testing.T) {
}
assert.Equal(t, string(video.CodecAVC), data.Codec)
assert.Equal(t, "6s", data.Duration.String())
assert.Equal(t, "6.09s", data.Duration.String())
assert.Equal(t, "2022-06-25 06:50:58 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2022-06-25 04:50:58 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "", data.TimeZone) // Local Time
@ -934,7 +935,7 @@ func TestJSON(t *testing.T) {
}
assert.Equal(t, string(video.CodecAVC), data.Codec)
assert.Equal(t, "1s", data.Duration.String())
assert.Equal(t, "1.03s", data.Duration.String())
assert.Equal(t, "2012-07-11 07:16:01 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2012-07-11 05:16:01 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Paris", data.TimeZone)
@ -951,7 +952,7 @@ func TestJSON(t *testing.T) {
}
assert.Equal(t, string(video.CodecAVC), data.Codec)
assert.Equal(t, "1s", data.Duration.String())
assert.Equal(t, "1.03s", data.Duration.String())
assert.Equal(t, "2012-07-11 07:16:01 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2012-07-11 05:16:01 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Paris", data.TimeZone)
@ -968,7 +969,7 @@ func TestJSON(t *testing.T) {
}
assert.Equal(t, string(video.CodecAVC), data.Codec)
assert.Equal(t, "1s", data.Duration.String())
assert.Equal(t, "1.03s", data.Duration.String())
assert.Equal(t, "2012-07-11 07:16:01 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2012-07-11 05:16:01 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Paris", data.TimeZone)
@ -1180,7 +1181,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, 1533, data.Height)
assert.Equal(t, 1917, data.Width)
assert.Equal(t, 34, data.Frames)
assert.Equal(t, 49*time.Second, data.Duration)
assert.Equal(t, "49.5s", data.Duration.String())
assert.Equal(t, float32(0), data.Lat)
assert.Equal(t, float32(0), data.Lng)
assert.Equal(t, 0.0, data.Altitude)

View file

@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
@ -98,7 +99,7 @@ func (c *Convert) ToImage(f *MediaFile, force bool) (*MediaFile, error) {
if err == nil {
log.Infof("convert: %s created in %s (%s)", clean.Log(filepath.Base(imageName)), time.Since(start), f.FileType())
return NewMediaFile(imageName)
} else if !f.IsTiff() {
} else if !f.IsTIFF() {
// See https://github.com/photoprism/photoprism/issues/1612
// for TIFF file format compatibility.
return nil, err
@ -113,10 +114,10 @@ func (c *Convert) ToImage(f *MediaFile, force bool) (*MediaFile, error) {
switch fs.LowerExt(imageName) {
case fs.ExtPNG:
cmds, useMutex, err = c.PngConvertCommands(f, imageName)
expectedMime = fs.MimeTypePng
expectedMime = fs.MimeTypePNG
case fs.ExtJPEG:
cmds, useMutex, err = c.JpegConvertCommands(f, imageName, xmpName)
expectedMime = fs.MimeTypeJpeg
expectedMime = fs.MimeTypeJPEG
default:
return nil, fmt.Errorf("convert: unspported target format %s (%s)", fs.LowerExt(imageName), clean.Log(f.RootRelName()))
}
@ -157,11 +158,11 @@ func (c *Convert) ToImage(f *MediaFile, force bool) (*MediaFile, error) {
// Run convert command.
if err = cmd.Run(); err != nil {
if stderr.String() != "" {
err = errors.New(stderr.String())
if errStr := strings.TrimSpace(stderr.String()); errStr != "" {
err = errors.New(errStr)
}
log.Tracef("convert: %s (%s)", err, filepath.Base(cmd.Path))
log.Tracef("convert: %s (%s)", strings.TrimSpace(err.Error()), filepath.Base(cmd.Path))
continue
} else if fs.FileExistsNotEmpty(imageName) {
log.Infof("convert: %s created in %s (%s)", clean.Log(filepath.Base(imageName)), time.Since(start), filepath.Base(cmd.Path))

View file

@ -26,17 +26,17 @@ func (c *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName str
result = append(result, exec.Command(c.conf.SipsBin(), "-Z", maxSize, "-s", "format", "jpeg", "--out", jpegName, f.FileName()))
}
// Extract a still image to be used as preview.
if f.IsAnimated() && c.conf.FFmpegEnabled() {
// Use "ffmpeg" to extract a JPEG still image from the video.
result = append(result, exec.Command(c.conf.FFmpegBin(), "-y", "-i", f.FileName(), "-ss", ffmpeg.PreviewTimeOffset(f.Duration()), "-vframes", "1", jpegName))
}
// Use heif-convert for HEIC/HEIF and AVIF image files.
if (f.IsHEIC() || f.IsAVIF()) && c.conf.HeifConvertEnabled() {
result = append(result, exec.Command(c.conf.HeifConvertBin(), "-q", c.conf.JpegQuality().String(), f.FileName(), jpegName))
}
// Extract a video still image that can be used as preview.
if f.IsVideo() && c.conf.FFmpegEnabled() {
// Use "ffmpeg" to extract a JPEG still image from the video.
result = append(result, exec.Command(c.conf.FFmpegBin(), "-y", "-i", f.FileName(), "-ss", ffmpeg.PreviewTimeOffset(f.Duration()), "-vframes", "1", jpegName))
}
// RAW files may be concerted with Darktable and RawTherapee.
if f.IsRaw() && c.conf.RawEnabled() {
if c.conf.DarktableEnabled() && c.darktableBlacklist.Allow(fileExt) {
@ -95,7 +95,7 @@ func (c *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName str
// Try ImageMagick for other image file formats if allowed.
if c.conf.ImageMagickEnabled() && c.imagemagickBlacklist.Allow(fileExt) &&
(f.IsImage() && !f.IsJpegXL() && !f.IsRaw() || f.IsVector() && c.conf.VectorEnabled()) {
(f.IsImage() && !f.IsJpegXL() && !f.IsRaw() && !f.IsAnimated() || f.IsVector() && c.conf.VectorEnabled()) {
quality := fmt.Sprintf("%d", c.conf.JpegQuality())
resize := fmt.Sprintf("%dx%d>", c.conf.JpegSize(), c.conf.JpegSize())
args := []string{f.FileName(), "-flatten", "-resize", resize, "-quality", quality, jpegName}

View file

@ -26,11 +26,16 @@ func (c *Convert) PngConvertCommands(f *MediaFile, pngName string) (result []*ex
}
// Extract a video still image that can be used as preview.
if f.IsVideo() && c.conf.FFmpegEnabled() {
if f.IsAnimated() && c.conf.FFmpegEnabled() {
// Use "ffmpeg" to extract a PNG still image from the video.
result = append(result, exec.Command(c.conf.FFmpegBin(), "-y", "-i", f.FileName(), "-ss", ffmpeg.PreviewTimeOffset(f.Duration()), "-vframes", "1", pngName))
}
// Use heif-convert for HEIC/HEIF and AVIF image files.
if (f.IsHEIC() || f.IsAVIF()) && c.conf.HeifConvertEnabled() {
result = append(result, exec.Command(c.conf.HeifConvertBin(), f.FileName(), pngName))
}
// Decode JPEG XL image if support is enabled.
if f.IsJpegXL() && c.conf.JpegXLEnabled() {
result = append(result, exec.Command(c.conf.JpegXLDecoderBin(), f.FileName(), pngName))
@ -38,7 +43,7 @@ func (c *Convert) PngConvertCommands(f *MediaFile, pngName string) (result []*ex
// Try ImageMagick for other image file formats if allowed.
if c.conf.ImageMagickEnabled() && c.imagemagickBlacklist.Allow(fileExt) &&
(f.IsImage() && !f.IsJpegXL() && !f.IsRaw() || f.IsVector() && c.conf.VectorEnabled()) {
(f.IsImage() && !f.IsJpegXL() && !f.IsRaw() && !f.IsAnimated() || f.IsVector() && c.conf.VectorEnabled()) {
resize := fmt.Sprintf("%dx%d>", c.conf.PngSize(), c.conf.PngSize())
args := []string{f.FileName(), "-flatten", "-resize", resize, pngName}
result = append(result, exec.Command(c.conf.ImageMagickBin(), args...))

View file

@ -13,7 +13,6 @@ import (
"path/filepath"
"regexp"
"runtime/debug"
"sort"
"strings"
"sync"
"time"
@ -314,123 +313,6 @@ func (m *MediaFile) EditedName() string {
return ""
}
// RelatedFiles returns files which are related to this file.
func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err error) {
// File path and name without any extensions.
prefix := m.AbsPrefix(stripSequence)
// Storage folder path prefixes.
sidecarPrefix := Config().SidecarPath() + "/"
originalsPrefix := Config().OriginalsPath() + "/"
// Ignore RAW images?
skipRaw := Config().DisableRaw()
// Ignore JPEG XL files?
skipJpegXL := Config().DisableJpegXL()
// Ignore vector graphics?
skipVectors := Config().DisableVectors()
// Replace sidecar with originals path in search prefix.
if len(sidecarPrefix) > 1 && sidecarPrefix != originalsPrefix && strings.HasPrefix(prefix, sidecarPrefix) {
prefix = strings.Replace(prefix, sidecarPrefix, originalsPrefix, 1)
log.Debugf("media: replaced sidecar with originals path in related file matching pattern")
}
// Quote path for glob.
if stripSequence {
// Strip common name sequences like "copy 2" and escape meta characters.
prefix = regexp.QuoteMeta(prefix)
} else {
// Use strict file name matching and escape meta characters.
prefix = regexp.QuoteMeta(prefix + ".")
}
// Find related files.
matches, err := filepath.Glob(prefix + "*")
if err != nil {
return result, err
}
if name := m.EditedName(); name != "" {
matches = append(matches, name)
}
isHEIC := false
for _, fileName := range matches {
f, fileErr := NewMediaFile(fileName)
if fileErr != nil || f.Empty() {
continue
}
// Ignore file format?
switch {
case skipRaw && f.IsRaw():
log.Debugf("media: skipped related raw image %s", clean.Log(f.RootRelName()))
continue
case skipJpegXL && f.IsJpegXL():
log.Debugf("media: skipped related JPEG XL file %s", clean.Log(f.RootRelName()))
continue
case skipVectors && f.IsVector():
log.Debugf("media: skipped related vector graphic %s", clean.Log(f.RootRelName()))
continue
}
// Set main file.
if result.Main == nil && f.IsPreviewImage() {
result.Main = f
} else if f.IsRaw() {
result.Main = f
} else if f.IsVector() {
result.Main = f
} else if f.IsHEIC() {
isHEIC = true
result.Main = f
} else if f.IsImage() && !f.IsPreviewImage() {
result.Main = f
} else if f.IsVideo() && !isHEIC {
result.Main = f
} else if result.Main != nil && f.IsPreviewImage() {
if result.Main.IsPreviewImage() && len(result.Main.FileName()) > len(f.FileName()) {
result.Main = f
}
}
result.Files = append(result.Files, f)
}
if len(result.Files) == 0 || result.Main == nil {
t := m.MimeType()
if t == "" {
t = "unknown type"
}
return result, fmt.Errorf("%s is unsupported (%s)", clean.Log(m.BaseName()), t)
}
// Add hidden preview image if needed.
if !result.HasPreview() {
if jpegName := fs.ImageJPEG.FindFirst(result.Main.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), stripSequence); jpegName != "" {
if resultFile, _ := NewMediaFile(jpegName); resultFile.Ok() {
result.Files = append(result.Files, resultFile)
}
} else if pngName := fs.ImagePNG.FindFirst(result.Main.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), stripSequence); pngName != "" {
if resultFile, _ := NewMediaFile(pngName); resultFile.Ok() {
result.Files = append(result.Files, resultFile)
}
}
}
sort.Sort(result.Files)
return result, nil
}
// PathNameInfo returns file name infos for indexing.
func (m *MediaFile) PathNameInfo(stripSequence bool) (fileRoot, fileBase, relativePath, relativeName string) {
fileRoot = m.Root()
@ -726,7 +608,7 @@ func (m *MediaFile) Extension() string {
// IsPreviewImage return true if this media file is a JPEG or PNG image.
func (m *MediaFile) IsPreviewImage() bool {
return m.IsJpeg() || m.IsPng()
return m.IsJpeg() || m.IsPNG()
}
// IsJpeg checks if the file is a JPEG image with a supported file type extension.
@ -743,7 +625,7 @@ func (m *MediaFile) IsJpeg() bool {
// Since mime type detection is expensive, it is only
// performed after other checks have passed.
return m.MimeType() == fs.MimeTypeJpeg
return m.MimeType() == fs.MimeTypeJPEG
}
// IsJpegXL checks if the file is a JPEG XL image with a supported file type extension.
@ -752,11 +634,11 @@ func (m *MediaFile) IsJpegXL() bool {
return false
}
return m.MimeType() == fs.MimeTypeJpegXL
return m.MimeType() == fs.MimeTypeJPEGXL
}
// IsPng checks if the file is a PNG image with a supported file type extension.
func (m *MediaFile) IsPng() bool {
// IsPNG checks if the file is a PNG image with a supported file type extension.
func (m *MediaFile) IsPNG() bool {
if fs.FileType(m.fileName) != fs.ImagePNG {
// Files with an incorrect file extension are no longer
// recognized as PNG to improve indexing performance.
@ -766,25 +648,25 @@ func (m *MediaFile) IsPng() bool {
// Since mime type detection is expensive, it is only
// performed after other checks have passed.
mimeType := m.MimeType()
return mimeType == fs.MimeTypePng || mimeType == fs.MimeTypeAnimatedPng
return mimeType == fs.MimeTypePNG || mimeType == fs.MimeTypeAPNG
}
// IsGif checks if the file is a GIF image with a supported file type extension.
func (m *MediaFile) IsGif() bool {
// IsGIF checks if the file is a GIF image with a supported file type extension.
func (m *MediaFile) IsGIF() bool {
if fs.FileType(m.fileName) != fs.ImageGIF {
return false
}
return m.MimeType() == fs.MimeTypeGif
return m.MimeType() == fs.MimeTypeGIF
}
// IsTiff checks if the file is a TIFF image with a supported file type extension.
func (m *MediaFile) IsTiff() bool {
// IsTIFF checks if the file is a TIFF image with a supported file type extension.
func (m *MediaFile) IsTIFF() bool {
if fs.FileType(m.fileName) != fs.ImageTIFF {
return false
}
return m.MimeType() == fs.MimeTypeTiff
return m.MimeType() == fs.MimeTypeTIFF
}
// IsDNG checks if the file is a Adobe Digital Negative (DNG) image with a supported file type extension.
@ -798,29 +680,39 @@ func (m *MediaFile) IsDNG() bool {
// IsHEIC checks if the file is a High Efficiency Image File Format (HEIC/HEIF) image with a supported file type extension.
func (m *MediaFile) IsHEIC() bool {
if t := fs.FileType(m.fileName); t != fs.ImageHEIC && t != fs.ImageHEIF {
if t := fs.FileType(m.fileName); t != fs.ImageHEIF && t != fs.ImageHEIC {
return false
}
return m.MimeType() == fs.MimeTypeHEIC
}
// IsHEICS checks if the file is a HEIC image sequence with a supported file type extension.
func (m *MediaFile) IsHEICS() bool {
return m.HasFileType(fs.ImageHEICS)
}
// IsAVIF checks if the file is an AV1 Image File Format image with a supported file type extension.
func (m *MediaFile) IsAVIF() bool {
if fs.FileType(m.fileName) != fs.ImageAVIF {
if t := fs.FileType(m.fileName); t != fs.ImageAVIF {
return false
}
return m.MimeType() == fs.MimeTypeAVIF
}
// IsBitmap checks if the file is a bitmap image with a supported file type extension.
func (m *MediaFile) IsBitmap() bool {
// IsAVIFS checks if the file is an AVIF image sequence with a supported file type extension.
func (m *MediaFile) IsAVIFS() bool {
return m.HasFileType(fs.ImageAVIFS)
}
// IsBMP checks if the file is a bitmap image with a supported file type extension.
func (m *MediaFile) IsBMP() bool {
if fs.FileType(m.fileName) != fs.ImageBMP {
return false
}
return m.MimeType() == fs.MimeTypeBitmap
return m.MimeType() == fs.MimeTypeBMP
}
// IsWebP checks if the file is a WebP image file with a supported file type extension.
@ -841,13 +733,13 @@ func (m *MediaFile) Duration() time.Duration {
return m.MetaData().Duration
}
// IsAnimatedImage checks if the file is an animated image with a supported file type extension.
// IsAnimatedImage checks if the file is an animated image.
func (m *MediaFile) IsAnimatedImage() bool {
return (m.IsGif() || m.IsPng()) && m.MetaData().Frames > 1
return fs.FileAnimated(m.fileName) && (m.MetaData().Frames > 1 || m.MetaData().Duration > 0)
}
// IsJson checks if the file is a JSON sidecar file with a supported file type extension.
func (m *MediaFile) IsJson() bool {
// IsJSON checks if the file is a JSON sidecar file with a supported file type extension.
func (m *MediaFile) IsJSON() bool {
return m.HasFileType(fs.SidecarJSON)
}
@ -856,11 +748,11 @@ func (m *MediaFile) FileType() fs.Type {
switch {
case m.IsJpeg():
return fs.ImageJPEG
case m.IsPng():
case m.IsPNG():
return fs.ImagePNG
case m.IsGif():
case m.IsGIF():
return fs.ImageGIF
case m.IsBitmap():
case m.IsBMP():
return fs.ImageBMP
case m.IsDNG():
return fs.ImageDNG
@ -950,7 +842,7 @@ func (m *MediaFile) IsPlayableVideo() bool {
// IsImageOther returns true if this is a PNG, GIF, BMP, TIFF, or WebP file.
func (m *MediaFile) IsImageOther() bool {
switch {
case m.IsPng(), m.IsGif(), m.IsTiff(), m.IsBitmap(), m.IsWebP():
case m.IsPNG(), m.IsGIF(), m.IsTIFF(), m.IsBMP(), m.IsWebP():
return true
default:
return false
@ -977,7 +869,7 @@ func (m *MediaFile) IsLive() bool {
// ExifSupported returns true if parsing exif metadata is supported for the media file type.
func (m *MediaFile) ExifSupported() bool {
return m.IsJpeg() || m.IsRaw() || m.IsHEIC() || m.IsPng() || m.IsTiff()
return m.IsJpeg() || m.IsRaw() || m.IsHEIC() || m.IsHEICS() || m.IsAVIF() || m.IsAVIFS() || m.IsPNG() || m.IsTIFF()
}
// IsMedia returns true if this is a media file (photo or video, not sidecar or other).
@ -1025,13 +917,13 @@ func (m *MediaFile) HasPreviewImage() bool {
jpegName := fs.ImageJPEG.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false)
if m.hasPreviewImage = fs.MimeType(jpegName) == fs.MimeTypeJpeg; m.hasPreviewImage {
if m.hasPreviewImage = fs.MimeType(jpegName) == fs.MimeTypeJPEG; m.hasPreviewImage {
return true
}
pngName := fs.ImagePNG.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false)
if m.hasPreviewImage = fs.MimeType(pngName) == fs.MimeTypePng; m.hasPreviewImage {
if m.hasPreviewImage = fs.MimeType(pngName) == fs.MimeTypePNG; m.hasPreviewImage {
return true
}

View file

@ -12,7 +12,7 @@ import (
// HasSidecarJson returns true if this file has or is a json sidecar file.
func (m *MediaFile) HasSidecarJson() bool {
if m.IsJson() {
if m.IsJSON() {
return true
}

View file

@ -0,0 +1,131 @@
package photoprism
import (
"fmt"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
// RelatedFiles returns files which are related to this file.
func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err error) {
// File path and name without any extensions.
prefix := m.AbsPrefix(stripSequence)
// Storage folder path prefixes.
sidecarPrefix := Config().SidecarPath() + "/"
originalsPrefix := Config().OriginalsPath() + "/"
// Ignore RAW images?
skipRaw := Config().DisableRaw()
// Ignore JPEG XL files?
skipJpegXL := Config().DisableJpegXL()
// Ignore vector graphics?
skipVectors := Config().DisableVectors()
// Replace sidecar with originals path in search prefix.
if len(sidecarPrefix) > 1 && sidecarPrefix != originalsPrefix && strings.HasPrefix(prefix, sidecarPrefix) {
prefix = strings.Replace(prefix, sidecarPrefix, originalsPrefix, 1)
log.Debugf("media: replaced sidecar with originals path in related file matching pattern")
}
// Quote path for glob.
if stripSequence {
// Strip common name sequences like "copy 2" and escape meta characters.
prefix = regexp.QuoteMeta(prefix)
} else {
// Use strict file name matching and escape meta characters.
prefix = regexp.QuoteMeta(prefix + ".")
}
// Find related files.
matches, err := filepath.Glob(prefix + "*")
if err != nil {
return result, err
}
if name := m.EditedName(); name != "" {
matches = append(matches, name)
}
isHEIC := false
for _, fileName := range matches {
f, fileErr := NewMediaFile(fileName)
if fileErr != nil || f.Empty() {
continue
}
// Ignore file format?
switch {
case skipRaw && f.IsRaw():
log.Debugf("media: skipped related raw image %s", clean.Log(f.RootRelName()))
continue
case skipJpegXL && f.IsJpegXL():
log.Debugf("media: skipped related JPEG XL file %s", clean.Log(f.RootRelName()))
continue
case skipVectors && f.IsVector():
log.Debugf("media: skipped related vector graphic %s", clean.Log(f.RootRelName()))
continue
}
// Set main file.
if result.Main == nil && f.IsPreviewImage() {
result.Main = f
} else if f.IsRaw() {
result.Main = f
} else if f.IsVector() {
result.Main = f
} else if f.IsHEIC() {
isHEIC = true
result.Main = f
} else if f.IsAVIF() {
result.Main = f
} else if f.IsImage() && !f.IsPreviewImage() {
result.Main = f
} else if f.IsVideo() && !isHEIC {
result.Main = f
} else if result.Main != nil && f.IsPreviewImage() {
if result.Main.IsPreviewImage() && len(result.Main.FileName()) > len(f.FileName()) {
result.Main = f
}
}
result.Files = append(result.Files, f)
}
if len(result.Files) == 0 || result.Main == nil {
t := m.MimeType()
if t == "" {
t = "unknown type"
}
return result, fmt.Errorf("%s is unsupported (%s)", clean.Log(m.BaseName()), t)
}
// Add hidden preview image if needed.
if !result.HasPreview() {
if jpegName := fs.ImageJPEG.FindFirst(result.Main.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), stripSequence); jpegName != "" {
if resultFile, _ := NewMediaFile(jpegName); resultFile.Ok() {
result.Files = append(result.Files, resultFile)
}
} else if pngName := fs.ImagePNG.FindFirst(result.Main.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), stripSequence); pngName != "" {
if resultFile, _ := NewMediaFile(pngName); resultFile.Ok() {
result.Files = append(result.Files, resultFile)
}
}
}
sort.Sort(result.Files)
return result, nil
}

View file

@ -6,7 +6,6 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
@ -1216,7 +1215,7 @@ func TestMediaFile_IsPng(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, false, mediaFile.IsPng())
assert.Equal(t, false, mediaFile.IsPNG())
})
t.Run("tweethog.png", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/tweethog.png")
@ -1227,7 +1226,7 @@ func TestMediaFile_IsPng(t *testing.T) {
assert.Equal(t, fs.ImagePNG, mediaFile.FileType())
assert.Equal(t, "image/png", mediaFile.MimeType())
assert.Equal(t, true, mediaFile.IsPng())
assert.Equal(t, true, mediaFile.IsPNG())
})
}
@ -1241,7 +1240,7 @@ func TestMediaFile_IsTiff(t *testing.T) {
}
assert.Equal(t, fs.SidecarJSON, mediaFile.FileType())
assert.Equal(t, fs.MimeTypeJSON, mediaFile.MimeType())
assert.Equal(t, false, mediaFile.IsTiff())
assert.Equal(t, false, mediaFile.IsTIFF())
})
t.Run("purple.tiff", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/purple.tiff")
@ -1250,7 +1249,7 @@ func TestMediaFile_IsTiff(t *testing.T) {
}
assert.Equal(t, fs.ImageTIFF, mediaFile.FileType())
assert.Equal(t, "image/tiff", mediaFile.MimeType())
assert.Equal(t, true, mediaFile.IsTiff())
assert.Equal(t, true, mediaFile.IsTIFF())
})
t.Run("example.tiff", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/example.tif")
@ -1259,7 +1258,7 @@ func TestMediaFile_IsTiff(t *testing.T) {
}
assert.Equal(t, fs.ImageTIFF, mediaFile.FileType())
assert.Equal(t, "image/tiff", mediaFile.MimeType())
assert.Equal(t, true, mediaFile.IsTiff())
assert.Equal(t, true, mediaFile.IsTIFF())
})
}
@ -1286,9 +1285,9 @@ func TestMediaFile_IsImageOther(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, false, mediaFile.IsJpeg())
assert.Equal(t, false, mediaFile.IsGif())
assert.Equal(t, true, mediaFile.IsPng())
assert.Equal(t, false, mediaFile.IsBitmap())
assert.Equal(t, false, mediaFile.IsGIF())
assert.Equal(t, true, mediaFile.IsPNG())
assert.Equal(t, false, mediaFile.IsBMP())
assert.Equal(t, false, mediaFile.IsWebP())
assert.Equal(t, true, mediaFile.IsImage())
assert.Equal(t, true, mediaFile.IsImageNative())
@ -1304,8 +1303,8 @@ func TestMediaFile_IsImageOther(t *testing.T) {
assert.Equal(t, fs.ImageBMP, mediaFile.FileType())
assert.Equal(t, "image/bmp", mediaFile.MimeType())
assert.Equal(t, false, mediaFile.IsJpeg())
assert.Equal(t, false, mediaFile.IsGif())
assert.Equal(t, true, mediaFile.IsBitmap())
assert.Equal(t, false, mediaFile.IsGIF())
assert.Equal(t, true, mediaFile.IsBMP())
assert.Equal(t, false, mediaFile.IsWebP())
assert.Equal(t, true, mediaFile.IsImage())
assert.Equal(t, true, mediaFile.IsImageNative())
@ -1322,8 +1321,8 @@ func TestMediaFile_IsImageOther(t *testing.T) {
assert.Equal(t, fs.ImageGIF, mediaFile.FileType())
assert.Equal(t, "image/gif", mediaFile.MimeType())
assert.Equal(t, false, mediaFile.IsJpeg())
assert.Equal(t, true, mediaFile.IsGif())
assert.Equal(t, false, mediaFile.IsBitmap())
assert.Equal(t, true, mediaFile.IsGIF())
assert.Equal(t, false, mediaFile.IsBMP())
assert.Equal(t, false, mediaFile.IsWebP())
assert.Equal(t, true, mediaFile.IsImage())
assert.Equal(t, true, mediaFile.IsImageNative())
@ -1341,8 +1340,8 @@ func TestMediaFile_IsImageOther(t *testing.T) {
assert.Equal(t, fs.ImageWebP, mediaFile.FileType())
assert.Equal(t, fs.MimeTypeWebP, mediaFile.MimeType())
assert.Equal(t, false, mediaFile.IsJpeg())
assert.Equal(t, false, mediaFile.IsGif())
assert.Equal(t, false, mediaFile.IsBitmap())
assert.Equal(t, false, mediaFile.IsGIF())
assert.Equal(t, false, mediaFile.IsBMP())
assert.Equal(t, true, mediaFile.IsWebP())
assert.Equal(t, true, mediaFile.IsImage())
assert.Equal(t, true, mediaFile.IsImageNative())
@ -1467,7 +1466,7 @@ func TestMediaFile_IsVideo(t *testing.T) {
assert.Equal(t, false, f.IsRaw())
assert.Equal(t, false, f.IsImage())
assert.Equal(t, true, f.IsVideo())
assert.Equal(t, false, f.IsJson())
assert.Equal(t, false, f.IsJSON())
assert.Equal(t, false, f.IsSidecar())
}
})
@ -1478,7 +1477,7 @@ func TestMediaFile_IsVideo(t *testing.T) {
assert.Equal(t, true, f.IsRaw())
assert.Equal(t, false, f.IsImage())
assert.Equal(t, false, f.IsVideo())
assert.Equal(t, false, f.IsJson())
assert.Equal(t, false, f.IsJSON())
assert.Equal(t, false, f.IsSidecar())
}
})
@ -1489,7 +1488,7 @@ func TestMediaFile_IsVideo(t *testing.T) {
assert.Equal(t, false, f.IsRaw())
assert.Equal(t, false, f.IsImage())
assert.Equal(t, false, f.IsVideo())
assert.Equal(t, true, f.IsJson())
assert.Equal(t, true, f.IsJSON())
assert.Equal(t, true, f.IsSidecar())
}
})
@ -1497,7 +1496,23 @@ func TestMediaFile_IsVideo(t *testing.T) {
func TestMediaFile_IsAnimated(t *testing.T) {
cnf := config.TestConfig()
t.Run("star.avifs", func(t *testing.T) {
if f, err := NewMediaFile("testdata/star.avifs"); err != nil {
t.Fatal(err)
} else {
assert.Equal(t, true, f.IsImage())
assert.Equal(t, true, f.IsAVIFS())
assert.Equal(t, true, f.IsAnimated())
assert.Equal(t, true, f.IsAnimatedImage())
assert.Equal(t, true, f.ExifSupported())
assert.Equal(t, false, f.IsVideo())
assert.Equal(t, false, f.IsGIF())
assert.Equal(t, false, f.IsAVIF())
assert.Equal(t, false, f.IsHEIC())
assert.Equal(t, false, f.IsHEICS())
assert.Equal(t, false, f.IsSidecar())
}
})
t.Run("example.gif", func(t *testing.T) {
if f, err := NewMediaFile(filepath.Join(cnf.ExamplesPath(), "example.gif")); err != nil {
t.Fatal(err)
@ -1505,7 +1520,7 @@ func TestMediaFile_IsAnimated(t *testing.T) {
assert.Equal(t, true, f.IsImage())
assert.Equal(t, false, f.IsVideo())
assert.Equal(t, false, f.IsAnimated())
assert.Equal(t, true, f.IsGif())
assert.Equal(t, true, f.IsGIF())
assert.Equal(t, false, f.IsAnimatedImage())
assert.Equal(t, false, f.IsSidecar())
}
@ -1517,7 +1532,7 @@ func TestMediaFile_IsAnimated(t *testing.T) {
assert.Equal(t, true, f.IsImage())
assert.Equal(t, false, f.IsVideo())
assert.Equal(t, true, f.IsAnimated())
assert.Equal(t, true, f.IsGif())
assert.Equal(t, true, f.IsGIF())
assert.Equal(t, true, f.IsAnimatedImage())
assert.Equal(t, false, f.IsSidecar())
}
@ -1529,7 +1544,7 @@ func TestMediaFile_IsAnimated(t *testing.T) {
assert.Equal(t, false, f.IsImage())
assert.Equal(t, true, f.IsVideo())
assert.Equal(t, true, f.IsAnimated())
assert.Equal(t, false, f.IsGif())
assert.Equal(t, false, f.IsGIF())
assert.Equal(t, false, f.IsAnimatedImage())
assert.Equal(t, false, f.IsSidecar())
}
@ -2141,7 +2156,7 @@ func TestMediaFile_FileType(t *testing.T) {
// No longer recognized as JPEG to improve indexing performance (skips mime type detection).
assert.False(t, m.IsJpeg())
assert.False(t, m.IsPng())
assert.False(t, m.IsPNG())
assert.Equal(t, "png", string(m.FileType()))
assert.Equal(t, "image/jpeg", m.MimeType())
assert.Equal(t, fs.ImagePNG, m.FileType())
@ -2328,7 +2343,7 @@ func TestMediaFile_IsJson(t *testing.T) {
t.Fatal(err)
}
assert.False(t, mediaFile.IsJson())
assert.False(t, mediaFile.IsJSON())
})
t.Run("true", func(t *testing.T) {
conf := config.TestConfig()
@ -2339,7 +2354,7 @@ func TestMediaFile_IsJson(t *testing.T) {
t.Fatal(err)
}
assert.True(t, mediaFile.IsJson())
assert.True(t, mediaFile.IsJSON())
})
}
@ -2511,7 +2526,7 @@ func TestMediaFile_Duration(t *testing.T) {
if f, err := NewMediaFile(filepath.Join(conf.ExamplesPath(), "blue-go-video.mp4")); err != nil {
t.Fatal(err)
} else {
assert.Equal(t, time.Duration(2000000000), f.Duration())
assert.Equal(t, "2.42s", f.Duration().String())
}
})
}

BIN
internal/photoprism/testdata/star.avifs vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,64 @@
[{
"SourceFile": "testdata/star.avifs",
"ExifToolVersion": 12.40,
"FileName": "star.avifs",
"Directory": "testdata",
"FileSize": 15679,
"FileModifyDate": "2023:02:22 10:07:46+00:00",
"FileAccessDate": "2023:02:22 10:09:03+00:00",
"FileInodeChangeDate": "2023:02:22 10:09:02+00:00",
"FilePermissions": 100664,
"FileType": "MP4",
"FileTypeExtension": "MP4",
"MIMEType": "video/mp4",
"MajorBrand": "avis",
"MinorVersion": "0.0.0",
"CompatibleBrands": ["avis","msf1","miaf","MA1B"],
"PrimaryItemReference": 1,
"ImageWidth": 159,
"ImageHeight": 159,
"ImageSpatialExtent": "159 159",
"PixelAspectRatio": "1 1",
"AV1ConfigurationVersion": 1,
"ChromaFormat": 3,
"ChromaSamplePosition": 0,
"ImagePixelDepth": "8 8 8",
"MovieHeaderVersion": 0,
"CreateDate": "2020:04:07 16:39:21",
"ModifyDate": "2020:04:07 16:39:21",
"TimeScale": 1000,
"Duration": 0.5,
"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": "2020:04:07 16:39:21",
"TrackID": 1,
"TrackDuration": 0.5,
"TrackLayer": 0,
"TrackVolume": 0,
"MatrixStructure": "1 0 0 0 1 0 0 0 1",
"MediaHeaderVersion": 0,
"MediaCreateDate": "0000:00:00 00:00:00",
"MediaModifyDate": "2020:04:07 16:39:21",
"MediaTimeScale": 10240,
"MediaDuration": 0.5,
"MediaLanguageCode": "und",
"HandlerType": "pict",
"HandlerDescription": "GPAC avifs",
"GraphicsMode": 0,
"OpColor": "0 0 0",
"OtherFormat": "av01",
"MediaDataSize": 14556,
"MediaDataOffset": 1051,
"ImageSize": "159 159",
"Megapixels": 0.025281,
"AvgBitrate": 232896
}]

View file

@ -6,6 +6,7 @@ import (
"strings"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
)
@ -71,6 +72,7 @@ func FileByPhotoUID(photoUID string) (*entity.File, error) {
}
err := Db().Where("photo_uid = ? AND file_primary = 1", photoUID).Preload("Photo").First(&f).Error
return &f, err
}
@ -82,9 +84,11 @@ func VideoByPhotoUID(photoUID string) (*entity.File, error) {
return &f, fmt.Errorf("photo uid required")
}
err := Db().Where("photo_uid = ? AND (file_video = 1 OR file_frames > 0 OR file_type = 'gif')", photoUID).
Order("file_video DESC, file_duration DESC, file_frames DESC").
err := Db().Where("photo_uid = ? AND file_missing = 0", photoUID).
Where("file_video = 1 OR file_duration > 0 OR file_frames > 0 OR file_type = ?", fs.ImageGIF).
Order("file_error ASC, file_video DESC, file_duration DESC, file_frames DESC").
Preload("Photo").First(&f).Error
return &f, err
}
@ -97,6 +101,7 @@ func FileByUID(fileUID string) (*entity.File, error) {
}
err := Db().Where("file_uid = ?", fileUID).Preload("Photo").First(&f).Error
return &f, err
}

View file

@ -26,7 +26,7 @@ var PhotosColsAll = SelectString(Photo{}, []string{"*"})
var PhotosColsView = SelectString(Photo{}, SelectCols(GeoResult{}, []string{"*"}))
// FileTypes contains a list of browser-compatible file formats returned by search queries.
var FileTypes = []string{fs.ImageJPEG.String(), fs.ImagePNG.String(), fs.ImageGIF.String(), fs.ImageWebP.String(), fs.VectorSVG.String()}
var FileTypes = []string{fs.ImageJPEG.String(), fs.ImagePNG.String(), fs.ImageGIF.String(), fs.ImageAVIF.String(), fs.ImageAVIFS.String(), fs.ImageWebP.String(), fs.VectorSVG.String()}
// Photos finds PhotoResults based on the search form without checking rights or permissions.
func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) {

47
pkg/clean/duration.go Normal file
View file

@ -0,0 +1,47 @@
package clean
import "strings"
var durationRunes = map[rune]bool{
':': true,
'-': true,
'd': true,
'h': true,
'm': true,
's': true,
'n': true,
'µ': true,
}
// Duration removes non-duration characters from a string and returns the result.
func Duration(s string) string {
if s == "" {
return ""
}
valid := false
skipDot := false
// Remove invalid characters.
s = strings.Map(func(r rune) rune {
if !skipDot && (r == ',' || r == '.') {
skipDot = true
return '.'
} else if durationRunes[r] {
skipDot = false
return r
} else if r < '0' || r > '9' {
return -1
}
valid = true
return r
}, s)
if !valid {
return ""
}
return s
}

View file

@ -0,0 +1,69 @@
package clean
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDuration(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
result := Duration("")
assert.Equal(t, "", result)
})
t.Run("NonNumeric", func(t *testing.T) {
result := Duration(" Screenshot ")
assert.Equal(t, "", result)
})
t.Run("Zero", func(t *testing.T) {
result := Duration("0")
assert.Equal(t, "0", result)
})
t.Run("Float", func(t *testing.T) {
result := Duration("0.5")
assert.Equal(t, "0.5", result)
})
t.Run("Seconds", func(t *testing.T) {
result := Duration("0.5 s")
assert.Equal(t, "0.5s", result)
})
t.Run("MinutesSeconds", func(t *testing.T) {
result := Duration("1.0 m0.01 s ")
assert.Equal(t, "1.0m0.01s", result)
})
t.Run("01:00", func(t *testing.T) {
result := Duration("01:00")
assert.Equal(t, "01:00", result)
})
t.Run("LeadingZeros", func(t *testing.T) {
result := Duration(" 000123")
assert.Equal(t, "000123", result)
})
t.Run("WhitespacePadding", func(t *testing.T) {
result := Duration(" 123,556\t ")
assert.Equal(t, "123.556", result)
})
t.Run("PositiveFloat", func(t *testing.T) {
result := Duration("123,000.45245 ")
assert.Equal(t, "123.00045245", result)
})
t.Run("NegativeFloat", func(t *testing.T) {
result := Duration(" - 123,000.45245 ")
assert.Equal(t, "-123.00045245", result)
})
t.Run("MultipleDots", func(t *testing.T) {
result := Duration("123.000.45245.44 m")
assert.Equal(t, "123.0004524544m", result)
})
}

10
pkg/clean/numeric.go Normal file
View file

@ -0,0 +1,10 @@
package clean
import (
"github.com/photoprism/photoprism/pkg/txt"
)
// Numeric removes non-numeric characters from a string and returns the result.
func Numeric(s string) string {
return txt.Numeric(s)
}

59
pkg/clean/numeric_test.go Normal file
View file

@ -0,0 +1,59 @@
package clean
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNumeric(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
result := Numeric("")
assert.Equal(t, "", result)
})
t.Run("NonNumeric", func(t *testing.T) {
result := Numeric(" Screenshot ")
assert.Equal(t, "", result)
})
t.Run("Zero", func(t *testing.T) {
result := Numeric("0")
assert.Equal(t, "0", result)
})
t.Run("0.5", func(t *testing.T) {
result := Numeric("0.5")
assert.Equal(t, "0.5", result)
})
t.Run("01:00", func(t *testing.T) {
result := Numeric("01:00")
assert.Equal(t, "0100", result)
})
t.Run("LeadingZeros", func(t *testing.T) {
result := Numeric(" 000123")
assert.Equal(t, "000123", result)
})
t.Run("WhitespacePadding", func(t *testing.T) {
result := Numeric(" 123,556\t ")
assert.Equal(t, "123.556", result)
})
t.Run("PositiveFloat", func(t *testing.T) {
result := Numeric("123,000.45245 ")
assert.Equal(t, "123000.45245", result)
})
t.Run("NegativeFloat", func(t *testing.T) {
result := Numeric(" - 123,000.45245 ")
assert.Equal(t, "-123000.45245", result)
})
t.Run("MultipleDots", func(t *testing.T) {
result := Numeric("123.000.45245.44 m")
assert.Equal(t, "1230004524544", result)
})
}

View file

@ -23,19 +23,21 @@ var Extensions = FileExtensions{
".psd": ImagePSD,
".png": ImagePNG,
".apng": ImagePNG,
".pnga": ImagePNG,
".pn": ImagePNG,
".gif": ImageGIF,
".bmp": ImageBMP,
".dng": ImageDNG,
".avif": ImageAVIF,
".avifs": ImageAVIF,
".heif": ImageHEIC,
".avis": ImageAVIFS,
".avifs": ImageAVIFS,
".hif": ImageHEIC,
".heif": ImageHEIC,
".heic": ImageHEIC,
".heifs": ImageHEIC,
".heics": ImageHEIC,
".avci": ImageHEIC,
".avcs": ImageHEIC,
".heifs": ImageHEICS,
".heics": ImageHEICS,
".webp": ImageWebP,
".mpo": ImageMPO,
".3fr": ImageRaw,

View file

@ -1,7 +1,9 @@
package fs
type TypeMap map[Type]string
// TypeInfo contains human-readable descriptions for supported file formats
var TypeInfo = map[Type]string{
var TypeInfo = TypeMap{
ImageRaw: "Unprocessed Sensor Data",
ImageDNG: "Adobe Digital Negative",
ImageJPEG: "Joint Photographic Experts Group (JPEG)",
@ -13,8 +15,10 @@ var TypeInfo = map[Type]string{
ImageBMP: "Bitmap",
ImageMPO: "Stereoscopic JPEG (3D)",
ImageAVIF: "AV1 Image File Format",
ImageAVIFS: "AV1 Image Sequence",
ImageHEIF: "High Efficiency Image File Format",
ImageHEIC: "High Efficiency Image Container",
ImageHEICS: "HEIC Image Sequence",
ImageWebP: "Google WebP",
VideoWebM: "Google WebM",
VideoMP2: "MPEG 2 (H.262)",

View file

@ -17,6 +17,15 @@ func FileType(fileName string) Type {
return UnknownType
}
// FileAnimated checks if the type associated with the specified filename may be animated.
func FileAnimated(fileName string) bool {
if t, found := Extensions[LowerExt(fileName)]; found {
return TypeAnimated[t] != ""
}
return false
}
// NewType creates a new file type from a filename extension.
func NewType(ext string) Type {
return Type(TrimExt(ext))

View file

@ -148,7 +148,7 @@ func TestType_FindAll(t *testing.T) {
})
}
func TestType(t *testing.T) {
func TestFileType(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
result := FileType("")
assert.Equal(t, UnknownType, result)
@ -157,7 +157,7 @@ func TestType(t *testing.T) {
result := FileType("testdata/test.jpg")
assert.Equal(t, ImageJPEG, result)
})
t.Run("RawCRw", func(t *testing.T) {
t.Run("RawCRW", func(t *testing.T) {
result := FileType("testdata/test (jpg).crw")
assert.Equal(t, ImageRaw, result)
})
@ -169,3 +169,36 @@ func TestType(t *testing.T) {
assert.Equal(t, Type("mp4"), FileType("file.mp"))
})
}
func TestFileAnimated(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.False(t, FileAnimated(""))
})
t.Run("JPEG", func(t *testing.T) {
assert.False(t, FileAnimated("testdata/test.jpg"))
})
t.Run("RawCRW", func(t *testing.T) {
assert.False(t, FileAnimated("testdata/test (jpg).crw"))
})
t.Run("MP4", func(t *testing.T) {
assert.False(t, FileAnimated("file.mp"))
assert.False(t, FileAnimated("file.mp4"))
})
t.Run("GIF", func(t *testing.T) {
assert.True(t, FileAnimated("file.gif"))
})
t.Run("PNG", func(t *testing.T) {
assert.True(t, FileAnimated("file.png"))
assert.True(t, FileAnimated("file.apng"))
assert.True(t, FileAnimated("file.pnga"))
})
t.Run("AVIF", func(t *testing.T) {
assert.True(t, FileAnimated("file.avif"))
assert.True(t, FileAnimated("file.avis"))
assert.True(t, FileAnimated("file.avifs"))
})
t.Run("HEIC", func(t *testing.T) {
assert.True(t, FileAnimated("file.heic"))
assert.True(t, FileAnimated("file.heics"))
})
}

View file

@ -10,52 +10,64 @@ import (
_ "golang.org/x/image/webp"
)
// File types.
// Supported file types.
const (
ImageRaw Type = "raw" // RAW image
ImageJPEG Type = "jpg" // JPEG image
ImageJPEGXL Type = "jxl" // JPEG XL image
ImagePNG Type = "png" // PNG image
ImageGIF Type = "gif" // GIF image
ImageTIFF Type = "tiff" // TIFF image
ImagePSD Type = "psd" // Adobe Photoshop
ImageDNG Type = "dng" // Adobe Digital Negative image
ImageAVIF Type = "avif" // AV1 Image File Format (AVIF)
ImageHEIF Type = "heif" // High Efficiency Image File Format (HEIF)
ImageHEIC Type = "heic" // High Efficiency Image Container (HEIC)
ImageBMP Type = "bmp" // BMP image
ImageMPO Type = "mpo" // Stereoscopic Image that consists of two JPG images that are combined into one 3D image
ImageWebP Type = "webp" // Google WebP Image
VideoWebM Type = "webm" // Google WebM Video
VideoAVC Type = "avc" // H.264, Advanced Video Coding (AVC, MPEG-4 Part 10)
VideoHEVC Type = "hevc" // H.265, High Efficiency Video Coding (HEVC)
VideoVVC Type = "vvc" // H.266, Versatile Video Coding (VVC)
VideoAV1 Type = "av1" // Alliance for Open Media Video
VideoMPG Type = "mpg" // Moving Picture Experts Group (MPEG)
VideoMJPG Type = "mjpg" // Motion JPEG (M-JPEG)
VideoMOV Type = "mov" // QuickTime File Format, can contain AVC, HEVC,...
VideoMP2 Type = "mp2" // MPEG-2, H.222/H.262
VideoMP4 Type = "mp4" // MPEG-4 Container based on QuickTime, can contain AVC, HEVC,...
VideoAVI Type = "avi" // Microsoft Audio Video Interleave (AVI)
Video3GP Type = "3gp" // Mobile Multimedia Container, MPEG-4 Part 12
Video3G2 Type = "3g2" // Similar to 3GP, consumes less space & bandwidth
VideoFlash Type = "flv" // Flash Video
VideoMKV Type = "mkv" // Matroska Multimedia Container, free and open
VideoAVCHD Type = "mts" // AVCHD (Advanced Video Coding High Definition)
VideoBDAV Type = "m2ts" // Blu-ray MPEG-2 Transport Stream
VideoOGV Type = "ogv" // Ogg container format maintained by the Xiph.Org, free and open
VideoASF Type = "asf" // Advanced Systems/Streaming Format (ASF)
VideoWMV Type = "wmv" // Windows Media Video (based on ASF)
VectorSVG Type = "svg" // Scalable Vector Graphics
VectorAI Type = "ai" // Adobe Illustrator
VectorPS Type = "ps" // Adobe PostScript
VectorEPS Type = "eps" // Encapsulated PostScript
SidecarXMP Type = "xmp" // Adobe XMP sidecar file (XML)
SidecarAAE Type = "aae" // Apple image edits sidecar file (based on XML)
SidecarXML Type = "xml" // XML metadata / config / sidecar file
SidecarYAML Type = "yml" // YAML metadata / config / sidecar file
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
ImageRaw Type = "raw" // RAW Image
ImageJPEG Type = "jpg" // JPEG Image
ImageJPEGXL Type = "jxl" // JPEG XL Image
ImagePNG Type = "png" // PNG Image
ImageGIF Type = "gif" // GIF Image
ImageTIFF Type = "tiff" // TIFF Image
ImagePSD Type = "psd" // Adobe Photoshop
ImageDNG Type = "dng" // Adobe Digital Negative image
ImageAVIF Type = "avif" // AV1 Image File (AVIF)
ImageAVIFS Type = "avifs" // AV1 Image Sequence (Animated AVIF)
ImageHEIF Type = "heif" // High Efficiency Image File Format (HEIF)
ImageHEIC Type = "heic" // High Efficiency Image Container (HEIC)
ImageHEICS Type = "heics" // HEIC Image Sequence
ImageBMP Type = "bmp" // BMP Image
ImageMPO Type = "mpo" // Stereoscopic Image that consists of two JPG images that are combined into one 3D image
ImageWebP Type = "webp" // Google WebP Image
VideoWebM Type = "webm" // Google WebM Video
VideoAVC Type = "avc" // H.264, Advanced Video Coding (AVC, MPEG-4 Part 10)
VideoHEVC Type = "hevc" // H.265, High Efficiency Video Coding (HEVC)
VideoVVC Type = "vvc" // H.266, Versatile Video Coding (VVC)
VideoAV1 Type = "av1" // Alliance for Open Media Video
VideoMPG Type = "mpg" // Moving Picture Experts Group (MPEG)
VideoMJPG Type = "mjpg" // Motion JPEG (M-JPEG)
VideoMOV Type = "mov" // QuickTime File Format, can contain AVC, HEVC,...
VideoMP2 Type = "mp2" // MPEG-2, H.222/H.262
VideoMP4 Type = "mp4" // MPEG-4 Container based on QuickTime, can contain AVC, HEVC,...
VideoAVI Type = "avi" // Microsoft Audio Video Interleave (AVI)
Video3GP Type = "3gp" // Mobile Multimedia Container, MPEG-4 Part 12
Video3G2 Type = "3g2" // Similar to 3GP, consumes less space & bandwidth
VideoFlash Type = "flv" // Flash Video
VideoMKV Type = "mkv" // Matroska Multimedia Container, free and open
VideoAVCHD Type = "mts" // AVCHD (Advanced Video Coding High Definition)
VideoBDAV Type = "m2ts" // Blu-ray MPEG-2 Transport Stream
VideoOGV Type = "ogv" // Ogg container format maintained by the Xiph.Org, free and open
VideoASF Type = "asf" // Advanced Systems/Streaming Format (ASF)
VideoWMV Type = "wmv" // Windows Media Video (based on ASF)
VectorSVG Type = "svg" // Scalable Vector Graphics
VectorAI Type = "ai" // Adobe Illustrator
VectorPS Type = "ps" // Adobe PostScript
VectorEPS Type = "eps" // Encapsulated PostScript
SidecarXMP Type = "xmp" // Adobe XMP sidecar file (XML)
SidecarAAE Type = "aae" // Apple image edits sidecar file (based on XML)
SidecarXML Type = "xml" // XML metadata / config / sidecar file
SidecarYAML Type = "yml" // YAML metadata / config / sidecar file
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
)
// TypeAnimated maps animated file types to their mime type.
var TypeAnimated = TypeMap{
ImageGIF: MimeTypeGIF,
ImagePNG: MimeTypeAPNG,
ImageAVIF: MimeTypeAVIFS,
ImageAVIFS: MimeTypeAVIFS,
ImageHEIC: MimeTypeHEICS,
ImageHEICS: MimeTypeHEICS,
}

View file

@ -8,26 +8,28 @@ import (
)
const (
MimeTypeUnknown = ""
MimeTypeJpeg = "image/jpeg"
MimeTypeJpegXL = "image/jxl"
MimeTypePng = "image/png"
MimeTypeAnimatedPng = "image/vnd.mozilla.apng"
MimeTypeGif = "image/gif"
MimeTypeBitmap = "image/bmp"
MimeTypeTiff = "image/tiff"
MimeTypeDNG = "image/dng"
MimeTypeAVIF = "image/avif"
MimeTypeHEIC = "image/heic"
MimeTypeWebP = "image/webp"
MimeTypeMP4 = "video/mp4"
MimeTypeMOV = "video/quicktime"
MimeTypeSVG = "image/svg+xml"
MimeTypeAI = "application/vnd.adobe.illustrator"
MimeTypePS = "application/ps"
MimeTypeEPS = "image/eps"
MimeTypeXML = "text/xml"
MimeTypeJSON = "application/json"
MimeTypeUnknown = ""
MimeTypeJPEG = "image/jpeg"
MimeTypeJPEGXL = "image/jxl"
MimeTypePNG = "image/png"
MimeTypeAPNG = "image/vnd.mozilla.apng"
MimeTypeGIF = "image/gif"
MimeTypeBMP = "image/bmp"
MimeTypeTIFF = "image/tiff"
MimeTypeDNG = "image/dng"
MimeTypeAVIF = "image/avif"
MimeTypeAVIFS = "image/avif-sequence"
MimeTypeHEIC = "image/heic"
MimeTypeHEICS = "image/heic-sequence"
MimeTypeWebP = "image/webp"
MimeTypeMP4 = "video/mp4"
MimeTypeMOV = "video/quicktime"
MimeTypeSVG = "image/svg+xml"
MimeTypeAI = "application/vnd.adobe.illustrator"
MimeTypePS = "application/ps"
MimeTypeEPS = "image/eps"
MimeTypeXML = "text/xml"
MimeTypeJSON = "application/json"
)
// MimeType returns the mime type of a file, or an empty string if it could not be detected.
@ -42,6 +44,12 @@ func MimeType(filename string) (mimeType string) {
return MimeTypeDNG
case ImageAVIF:
return MimeTypeAVIF
case ImageAVIFS:
return MimeTypeAVIFS
case ImageHEIC:
return MimeTypeHEIC
case ImageHEICS:
return MimeTypeHEICS
case VideoMP4:
return MimeTypeMP4
case VideoMOV:

View file

@ -15,7 +15,9 @@ var Formats = map[fs.Type]Type{
fs.ImageBMP: Image,
fs.ImageMPO: Image,
fs.ImageAVIF: Image,
fs.ImageAVIFS: Image,
fs.ImageHEIC: Image,
fs.ImageHEICS: Image,
fs.VideoHEVC: Video,
fs.ImageWebP: Image,
fs.VideoWebM: Video,

View file

@ -33,8 +33,25 @@ func Numeric(s string) string {
return s
}
// Float64 converts a string to a 64-bit floating point number or 0 if invalid.
func Float64(s string) float64 {
// IsFloat checks if the string represents a floating point number.
func IsFloat(s string) bool {
if s == "" {
return false
}
s = strings.TrimSpace(s)
for _, r := range s {
if r != '.' && r != ',' && (r < '0' || r > '9') {
return false
}
}
return true
}
// Float converts a string to a 64-bit floating point number or 0 if invalid.
func Float(s string) float64 {
if s == "" {
return 0
}

View file

@ -22,6 +22,16 @@ func TestNumeric(t *testing.T) {
assert.Equal(t, "0", result)
})
t.Run("0.5", func(t *testing.T) {
result := Numeric("0.5")
assert.Equal(t, "0.5", result)
})
t.Run("01:00", func(t *testing.T) {
result := Numeric("01:00")
assert.Equal(t, "0100", result)
})
t.Run("LeadingZeros", func(t *testing.T) {
result := Numeric(" 000123")
assert.Equal(t, "000123", result)
@ -48,6 +58,96 @@ func TestNumeric(t *testing.T) {
})
}
func TestIsFloat(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.False(t, IsFloat(""))
})
t.Run("Zero", func(t *testing.T) {
assert.True(t, IsFloat("0"))
})
t.Run("0.5", func(t *testing.T) {
assert.True(t, IsFloat("0.5"))
})
t.Run("0,5", func(t *testing.T) {
assert.True(t, IsFloat("0,5"))
})
t.Run("123000.45245", func(t *testing.T) {
assert.True(t, IsFloat("123000.45245 "))
})
t.Run("123000.", func(t *testing.T) {
assert.True(t, IsFloat("123000. "))
})
t.Run("01:00", func(t *testing.T) {
assert.False(t, IsFloat("01:00"))
})
t.Run("LeadingZeros", func(t *testing.T) {
assert.True(t, IsFloat(" 000123"))
})
t.Run("Comma", func(t *testing.T) {
assert.True(t, IsFloat(" 123,556\t "))
})
}
func TestFloat(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
result := Float("")
assert.Equal(t, 0.0, result)
})
t.Run("NonNumeric", func(t *testing.T) {
result := Float(" Screenshot ")
assert.Equal(t, 0.0, result)
})
t.Run("Zero", func(t *testing.T) {
result := Float("0")
assert.Equal(t, 0.0, result)
})
t.Run("0.5", func(t *testing.T) {
result := Float("0.5")
assert.Equal(t, 0.5, result)
})
t.Run("01:00", func(t *testing.T) {
result := Float("01:00")
assert.Equal(t, 100.0, result)
})
t.Run("LeadingZeros", func(t *testing.T) {
result := Float(" 000123")
assert.Equal(t, 123.0, result)
})
t.Run("WhitespacePadding", func(t *testing.T) {
result := Float(" 123,556\t ")
assert.Equal(t, 123.556, result)
})
t.Run("PositiveFloat", func(t *testing.T) {
result := Float("123,000.45245 ")
assert.Equal(t, 123000.45245, result)
})
t.Run("NegativeFloat", func(t *testing.T) {
result := Float(" - 123,000.45245 ")
assert.Equal(t, -123000.45245, result)
})
t.Run("MultipleDots", func(t *testing.T) {
result := Float("123.000.45245.44 m")
assert.Equal(t, 1230004524544.0, result)
})
}
func TestInt64(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
result := Int64("")
@ -89,45 +189,3 @@ func TestInt64(t *testing.T) {
assert.Equal(t, int64(1230004524544), result)
})
}
func TestFloat64(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
result := Float64("")
assert.Equal(t, 0.0, result)
})
t.Run("NonNumeric", func(t *testing.T) {
result := Float64(" Screenshot ")
assert.Equal(t, 0.0, result)
})
t.Run("Zero", func(t *testing.T) {
result := Float64("0")
assert.Equal(t, 0.0, result)
})
t.Run("LeadingZeros", func(t *testing.T) {
result := Float64(" 000123")
assert.Equal(t, 123.0, result)
})
t.Run("WhitespacePadding", func(t *testing.T) {
result := Float64(" 123,556\t ")
assert.Equal(t, 123.556, result)
})
t.Run("PositiveFloat", func(t *testing.T) {
result := Float64("123,000.45245 ")
assert.Equal(t, 123000.45245, result)
})
t.Run("NegativeFloat", func(t *testing.T) {
result := Float64(" - 123,000.45245 ")
assert.Equal(t, -123000.45245, result)
})
t.Run("MultipleDots", func(t *testing.T) {
result := Float64("123.000.45245.44 m")
assert.Equal(t, 1230004524544.0, result)
})
}

View file

@ -13,7 +13,7 @@ func TimeStamp(t *time.Time) string {
return ""
}
return t.Format("2006-01-02 15:04:05")
return t.UTC().Format("2006-01-02 15:04:05")
}
// NTimes converts an integer to a string in the format "n times" or returns an empty string if n is 0.