Index: Add experimental support for JPEG XL and APNG files #668 #3197

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-02-14 14:43:49 +01:00
parent d4cbb60b92
commit 527fc0319e
22 changed files with 167 additions and 39 deletions

View file

@ -165,6 +165,8 @@ export default class Util {
switch (value) {
case "jpg":
return "JPEG";
case "jxl":
return "JPEG XL";
case "raw":
return "Unprocessed Sensor Data (RAW)";
case "mov":

View file

@ -470,18 +470,18 @@ export class Photo extends RestModel {
}
if (!file) {
file = this.gifFile();
file = this.animatedFile();
}
return file;
});
gifFile() {
animatedFile() {
if (!this.Files) {
return false;
}
return this.Files.find((f) => f.FileType === FormatGif);
return this.Files.find((f) => f.FileType === FormatGif || !!f.Frames);
}
videoUrl() {

View file

@ -5,14 +5,13 @@ import (
"net/http"
"strings"
"github.com/photoprism/photoprism/pkg/video"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/video"
)
// GetVideo streams videos.

View file

@ -29,3 +29,24 @@ func (c *Config) ImageMagickBlacklist() string {
func (c *Config) ImageMagickEnabled() bool {
return !c.DisableImageMagick()
}
// JpegXLDecoderBin returns the JPEG XL decoder executable file name.
func (c *Config) JpegXLDecoderBin() string {
return findBin("", "djxl")
}
// JpegXLEnabled checks if JPEG XL file format support is enabled.
func (c *Config) JpegXLEnabled() bool {
return !c.DisableImageMagick()
}
// DisableJpegXL checks if JPEG XL file format support is disabled.
func (c *Config) DisableJpegXL() bool {
if c.options.DisableJpegXL {
return true
} else if c.JpegXLDecoderBin() == "" {
c.options.DisableJpegXL = true
}
return c.options.DisableJpegXL
}

View file

@ -279,6 +279,11 @@ var Flags = CliFlags{
Usage: "disable conversion of HEIC images with libheif",
EnvVar: "PHOTOPRISM_DISABLE_HEIFCONVERT",
}}, {
Flag: cli.BoolFlag{
Name: "disable-jpegxl",
Usage: "disable JPEG XL file format support",
EnvVar: "PHOTOPRISM_DISABLE_JPEGXL",
}}, {
Flag: cli.BoolFlag{
Name: "disable-raw",
Usage: "disable indexing and conversion of RAW images",

View file

@ -76,6 +76,7 @@ type Options struct {
DisableImageMagick bool `yaml:"DisableImageMagick" json:"DisableImageMagick" flag:"disable-imagemagick"`
DisableHeifConvert bool `yaml:"DisableHeifConvert" json:"DisableHeifConvert" flag:"disable-heifconvert"`
DisableVector bool `yaml:"DisableVector" json:"DisableVector" flag:"disable-vector"`
DisableJpegXL bool `yaml:"DisableJpegXL" json:"DisableJpegXL" flag:"disable-jpegxl"`
DisableRaw bool `yaml:"DisableRaw" json:"DisableRaw" flag:"disable-raw"`
RawPresets bool `yaml:"RawPresets" json:"RawPresets" flag:"raw-presets"`
ExifBruteForce bool `yaml:"ExifBruteForce" json:"ExifBruteForce" flag:"exif-bruteforce"`

View file

@ -100,6 +100,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"disable-heifconvert", fmt.Sprintf("%t", c.DisableHeifConvert())},
{"disable-rsvgconvert", fmt.Sprintf("%t", c.DisableRsvgConvert())},
{"disable-vector", fmt.Sprintf("%t", c.DisableVector())},
{"disable-jpegxl", fmt.Sprintf("%t", c.DisableJpegXL())},
{"disable-raw", fmt.Sprintf("%t", c.DisableRaw())},
// Format Flags.
@ -185,6 +186,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"imagemagick-blacklist", c.ImageMagickBlacklist()},
{"heifconvert-bin", c.HeifConvertBin()},
{"rsvgconvert-bin", c.RsvgConvertBin()},
{"jpegxldecoder-bin", c.JpegXLDecoderBin()},
// Thumbnails.
{"download-token", c.DownloadToken()},

View file

@ -704,6 +704,9 @@ func (m *File) SetFrames(n int) {
// Update FPS.
if m.FileFPS <= 0 && m.FileDuration > 0 {
m.FileFPS = float64(m.FileFrames) / m.FileDuration.Seconds()
} else if m.FileFPS == 0 && m.FileDuration == 0 {
m.FileFPS = 30.0 // Assume 30 frames per second.
m.FileDuration = time.Duration(float64(m.FileFrames)/m.FileFPS) * time.Second
}
}

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?
if fs.FileType(fileName) == fs.ImageGIF {
// Animated GIF or PNG?
if fs.FileType(fileName) == fs.ImageGIF || fs.FileType(fileName) == fs.ImagePNG {
result = exec.Command(
ffmpegBin,
"-i", fileName,

View file

@ -25,7 +25,7 @@ type Data struct {
TimeZone string `meta:"-"`
Duration time.Duration `meta:"Duration,MediaDuration,TrackDuration"`
FPS float64 `meta:"VideoFrameRate,VideoAvgFrameRate"`
Frames int `meta:"FrameCount"`
Frames int `meta:"FrameCount,AnimationFrames"`
Codec string `meta:"CompressorID,VideoCodecID,CodecID,FileType"`
Title string `meta:"Headline,Title" xmp:"dc:title" dc:"title,title.Alt"`
Subject string `meta:"Subject,PersonInImage,ObjectName,HierarchicalSubject,CatalogSets" xmp:"Subject"`

View file

@ -88,9 +88,14 @@ func (c *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName str
result = append(result, exec.Command(c.conf.ExifToolBin(), "-q", "-q", "-b", "-PreviewImage", f.FileName()))
}
// Decode JPEG XL image if support is enabled.
if f.IsJpegXL() && c.conf.JpegXLEnabled() {
result = append(result, exec.Command(c.conf.JpegXLDecoderBin(), f.FileName(), jpegName))
}
// Try ImageMagick for other image file formats if allowed.
if c.conf.ImageMagickEnabled() && c.imagemagickBlacklist.Allow(fileExt) &&
(f.IsImage() || f.IsVector() && c.conf.VectorEnabled() || f.IsRaw() && c.conf.RawEnabled()) {
(f.IsImage() && !f.IsJpegXL() || f.IsVector() && c.conf.VectorEnabled() || f.IsRaw() && c.conf.RawEnabled()) {
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

@ -31,9 +31,14 @@ func (c *Convert) PngConvertCommands(f *MediaFile, pngName string) (result []*ex
result = append(result, exec.Command(c.conf.FFmpegBin(), "-y", "-i", f.FileName(), "-ss", ffmpeg.PreviewTimeOffset(f.Duration()), "-vframes", "1", 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))
}
// Try ImageMagick for other image file formats if allowed.
if c.conf.ImageMagickEnabled() && c.imagemagickBlacklist.Allow(fileExt) &&
(f.IsImage() || f.IsVector() && c.conf.VectorEnabled() || f.IsRaw() && c.conf.RawEnabled()) {
(f.IsImage() && !f.IsJpegXL() || f.IsVector() && c.conf.VectorEnabled() || f.IsRaw() && c.conf.RawEnabled()) {
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

@ -393,6 +393,8 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
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())
@ -412,6 +414,11 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
file.InstanceID = metaData.InstanceID
}
}
// Set 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 {
// Update basic metadata.
@ -494,7 +501,7 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
// Update photo type if an image and not manually modified.
if photo.TypeSrc == entity.SrcAuto && photo.PhotoType == entity.MediaImage {
if m.IsAnimatedGif() {
if m.IsAnimatedImage() {
photo.PhotoType = entity.MediaAnimated
} else if m.IsRaw() {
photo.PhotoType = entity.MediaRaw

View file

@ -738,6 +738,15 @@ func (m *MediaFile) IsJpeg() bool {
return m.MimeType() == fs.MimeTypeJpeg
}
// IsJpegXL checks if the file is a JPEG XL image with a supported file type extension.
func (m *MediaFile) IsJpegXL() bool {
if fs.FileType(m.fileName) != fs.ImageJPEGXL {
return false
}
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 {
if fs.FileType(m.fileName) != fs.ImagePNG {
@ -748,7 +757,8 @@ func (m *MediaFile) IsPng() bool {
// Since mime type detection is expensive, it is only
// performed after other checks have passed.
return m.MimeType() == fs.MimeTypePng
mimeType := m.MimeType()
return mimeType == fs.MimeTypePng || mimeType == fs.MimeTypeAnimatedPng
}
// IsGif checks if the file is a GIF image with a supported file type extension.
@ -823,9 +833,9 @@ func (m *MediaFile) Duration() time.Duration {
return m.MetaData().Duration
}
// IsAnimatedGif checks if the file is an animated GIF with a supported file type extension.
func (m *MediaFile) IsAnimatedGif() bool {
return m.IsGif() && m.MetaData().Frames > 1
// IsAnimatedImage checks if the file is an animated image with a supported file type extension.
func (m *MediaFile) IsAnimatedImage() bool {
return (m.IsGif() || m.IsPng()) && m.MetaData().Frames > 1
}
// IsJson checks if the file is a JSON sidecar file with a supported file type extension.
@ -886,7 +896,7 @@ func (m *MediaFile) IsRaw() bool {
// IsAnimated returns true if it is a video or animated image.
func (m *MediaFile) IsAnimated() bool {
return m.IsVideo() || m.IsAnimatedGif()
return m.IsVideo() || m.IsAnimatedImage()
}
// IsVideo returns true if this is a video file.

View file

@ -1506,7 +1506,7 @@ func TestMediaFile_IsAnimated(t *testing.T) {
assert.Equal(t, false, f.IsVideo())
assert.Equal(t, false, f.IsAnimated())
assert.Equal(t, true, f.IsGif())
assert.Equal(t, false, f.IsAnimatedGif())
assert.Equal(t, false, f.IsAnimatedImage())
assert.Equal(t, false, f.IsSidecar())
}
})
@ -1518,7 +1518,7 @@ func TestMediaFile_IsAnimated(t *testing.T) {
assert.Equal(t, false, f.IsVideo())
assert.Equal(t, true, f.IsAnimated())
assert.Equal(t, true, f.IsGif())
assert.Equal(t, true, f.IsAnimatedGif())
assert.Equal(t, true, f.IsAnimatedImage())
assert.Equal(t, false, f.IsSidecar())
}
})
@ -1530,7 +1530,7 @@ func TestMediaFile_IsAnimated(t *testing.T) {
assert.Equal(t, true, f.IsVideo())
assert.Equal(t, true, f.IsAnimated())
assert.Equal(t, false, f.IsGif())
assert.Equal(t, false, f.IsAnimatedGif())
assert.Equal(t, false, f.IsAnimatedImage())
assert.Equal(t, false, f.IsSidecar())
}
})

View file

@ -6,7 +6,6 @@ import (
"strings"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
)
@ -78,7 +77,7 @@ 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_type = ?)", photoUID, fs.ImageGIF).
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").
Preload("Photo").First(&f).Error
return &f, err

View file

@ -17,10 +17,12 @@ var Extensions = FileExtensions{
".jfif": ImageJPEG,
".jfi": ImageJPEG,
".thm": ImageJPEG,
".jxl": ImageJPEGXL,
".tif": ImageTIFF,
".tiff": ImageTIFF,
".psd": ImagePSD,
".png": ImagePNG,
".apng": ImagePNG,
".pn": ImagePNG,
".gif": ImageGIF,
".bmp": ImageBMP,

View file

@ -14,6 +14,7 @@ import (
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

View file

@ -5,6 +5,7 @@ var TypeInfo = map[Type]string{
ImageRaw: "Unprocessed Sensor Data",
ImageDNG: "Adobe Digital Negative",
ImageJPEG: "Joint Photographic Experts Group (JPEG)",
ImageJPEGXL: "JPEG XL",
ImagePNG: "Portable Network Graphics",
ImageGIF: "Graphics Interchange Format",
ImageTIFF: "Tag Image File Format",

View file

@ -8,24 +8,26 @@ import (
)
const (
MimeTypeUnknown = ""
MimeTypeJpeg = "image/jpeg"
MimeTypePng = "image/png"
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"
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"
)
// MimeType returns the mime type of a file, or an empty string if it could not be detected.

View file

@ -7,6 +7,7 @@ var Formats = map[fs.Type]Type{
fs.ImageRaw: Raw,
fs.ImageDNG: Raw,
fs.ImageJPEG: Image,
fs.ImageJPEGXL: Image,
fs.ImagePNG: Image,
fs.ImageGIF: Image,
fs.ImageTIFF: Image,

62
scripts/dist/install-jxl.sh vendored Executable file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# This installs JPEG XL on Linux.
# bash <(curl -s https://raw.githubusercontent.com/photoprism/photoprism/develop/scripts/dist/install-jxl.sh)
PATH="/usr/local/sbin:/usr/sbin:/sbin:/usr/local/bin:/usr/bin:/bin:/scripts:$PATH"
# Abort if not executed as root.
if [[ $(id -u) != "0" ]]; then
echo "Usage: run ${0##*/} as root" 1>&2
exit 1
fi
if [[ $PHOTOPRISM_ARCH ]]; then
SYSTEM_ARCH=$PHOTOPRISM_ARCH
else
SYSTEM_ARCH=$(uname -m)
fi
LIB_VERSION=${2:-v0.8.1}
SYSTEM_ARCH=$("$(dirname "$0")/arch.sh")
DESTARCH=${DESTARCH:-$SYSTEM_ARCH}
set -e
. /etc/os-release
ARCHIVE="jxl-debs-${DESTARCH}-ubuntu-22.04-${LIB_VERSION}.tar.gz"
URL="https://github.com/libjxl/libjxl/releases/download/${LIB_VERSION}/${ARCHIVE}"
TMPDIR="/tmp/jpegxl"
echo "------------------------------------------------"
echo "VERSION: $LIB_VERSION"
echo "ARCHIVE: $ARCHIVE"
echo "------------------------------------------------"
echo "Installing JPEG XL for ${DESTARCH^^}..."
case $DESTARCH in
amd64 | AMD64 | x86_64 | x86-64)
if [[ $VERSION_CODENAME == "jammy" ]]; then
apt-get update
apt-get install -f libtcmalloc-minimal4 libhwy-dev libhwy0
rm -rf /tmp/jpegxl
mkdir -p "$TMPDIR"
echo "Extracting \"$URL\" to \"$TMPDIR\"."
wget --inet4-only -c "$URL" -O - | tar --overwrite --mode=755 -xz -C "$TMPDIR"
(cd "$TMPDIR" && dpkg -i jxl_0.8.1_amd64.deb libjxl_0.8.1_amd64.deb libjxl-dev_0.8.1_amd64.deb)
apt --fix-broken install
rm -rf /tmp/jpegxl
else
echo "install-jxl: target distribution currently unsupported"
fi
;;
*)
echo "Unsupported Machine Architecture: \"$BUILD_ARCH\"" 1>&2
exit 0
;;
esac
echo "Done."