Use magick generating thumbnails.

Fix #1204

* Add bad medias to scanner_test.

* Fix the script.

* Add GenerateThumbnail function.

* Add identify dimension

* Use new API. Cover json condition.

* Use magick to generate thumbnails.

* Update go mod.

* Remove dependency of executable_worker -> test_utils

* Reduce dependency of magick.

* Use magick to get dimension.

* Remove almost all code around `image` package.

* Fix thumbnailing with multi-frame gif.

* Remove the struct in wrapper.

* Fix typo.

* Build images.

* Update logs

* Add jpeg decoder.

* Remove code dependency.

* Add log to confirm scan in parallel.

* Update log

* Add reformat image perf tests.

* Rollback the build workflow.
This commit is contained in:
Googol Lee
2025-05-22 09:01:43 +02:00
committed by GitHub
parent 9d7cf8ad33
commit a378522af4
29 changed files with 336 additions and 286 deletions

View File

@@ -7,7 +7,6 @@ require (
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e
github.com/barasher/go-exiftool v1.10.0
github.com/buckket/go-blurhash v1.1.0
github.com/disintegration/imaging v1.6.2
github.com/go-sql-driver/mysql v1.9.2
github.com/google/go-cmp v0.7.0
github.com/gorilla/handlers v1.5.2
@@ -19,12 +18,10 @@ require (
github.com/pkg/errors v0.9.1
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/stretchr/testify v1.10.0
github.com/strukturag/libheif v1.18.2
github.com/vektah/gqlparser/v2 v2.5.27
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0
github.com/xor-gate/goexif2 v1.1.0
golang.org/x/crypto v0.38.0
golang.org/x/image v0.27.0
golang.org/x/text v0.25.0
gopkg.in/vansante/go-ffprobe.v2 v2.2.1
gorm.io/driver/mysql v1.5.7

View File

@@ -25,8 +25,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
@@ -93,8 +91,6 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/strukturag/libheif v1.18.2 h1:CrlRS7Kwl2odl4DYM/m6ay/HPXEcmQPK1xF8FxAqP7k=
github.com/strukturag/libheif v1.18.2/go.mod h1:E/PNRlmVtrtj9j2AvBZlrO4dsBDu6KfwDZn7X1Ce8Ks=
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
@@ -107,9 +103,6 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGC
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
@@ -118,7 +111,6 @@ golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=

View File

@@ -5,12 +5,10 @@ import (
"database/sql/driver"
"encoding/binary"
"fmt"
"image"
"strconv"
"strings"
"github.com/photoview/photoview/api/database/drivers"
"github.com/photoview/photoview/api/scanner/media_encoding/media_utils"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
@@ -75,26 +73,12 @@ func (fd FaceDescriptor) Value() (driver.Value, error) {
return buf.Bytes(), nil
}
// FaceRectangle stores a relative rectangle of a face in an image.
type FaceRectangle struct {
MinX, MaxX float64
MinY, MaxY float64
}
// ToDBFaceRectangle converts a pixel absolute rectangle to a relative FaceRectangle to be saved in the database
func ToDBFaceRectangle(imgRec image.Rectangle, imagePath string) (*FaceRectangle, error) {
size, err := media_utils.GetPhotoDimensions(imagePath)
if err != nil {
return nil, err
}
return &FaceRectangle{
MinX: float64(imgRec.Min.X) / float64(size.Width),
MaxX: float64(imgRec.Max.X) / float64(size.Width),
MinY: float64(imgRec.Min.Y) / float64(size.Height),
MaxY: float64(imgRec.Max.Y) / float64(size.Height),
}, nil
}
// GormDataType datatype used in database
func (fr FaceRectangle) GormDataType() string {
return "VARCHAR(64)"

View File

@@ -5,7 +5,6 @@ import (
"testing"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/test_utils"
"github.com/photoview/photoview/api/utils"
"github.com/stretchr/testify/assert"
)
@@ -55,8 +54,7 @@ func TestMediaURLCachePath(t *testing.T) {
}
func TestMediaURLGetURL(t *testing.T) {
done := test_utils.SetEnv(string(utils.EnvAPIEndpoint), "")
defer done()
t.Setenv(string(utils.EnvAPIEndpoint), "")
photo := models.MediaURL{
MediaName: "photo.jpg",

View File

@@ -8,6 +8,7 @@ import (
"github.com/Kagami/go-face"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/gorm"
@@ -136,7 +137,7 @@ func (fd *faceDetector) classifyFace(db *gorm.DB, face *face.Face, media *models
match := fd.classifyDescriptor(face.Descriptor)
faceRect, err := models.ToDBFaceRectangle(face.Rectangle, imagePath)
dimension, err := media_encoding.GetPhotoDimensions(imagePath)
if err != nil {
return err
}
@@ -144,7 +145,13 @@ func (fd *faceDetector) classifyFace(db *gorm.DB, face *face.Face, media *models
imageFace := models.ImageFace{
MediaID: media.ID,
Descriptor: models.FaceDescriptor(face.Descriptor),
Rectangle: *faceRect,
Rectangle: models.FaceRectangle{
// Converts a pixel absolute rectangle to a relative FaceRectangle.
MinX: float64(face.Rectangle.Min.X) / float64(dimension.Width),
MaxX: float64(face.Rectangle.Max.X) / float64(dimension.Width),
MinY: float64(face.Rectangle.Min.Y) / float64(dimension.Height),
MaxY: float64(face.Rectangle.Max.Y) / float64(dimension.Height),
},
}
var faceGroup models.FaceGroup

View File

@@ -4,59 +4,44 @@ import (
"context"
"fmt"
"image"
"image/jpeg"
"os"
"time"
"github.com/disintegration/imaging"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/media_encoding/executable_worker"
"github.com/photoview/photoview/api/scanner/media_encoding/media_utils"
"github.com/photoview/photoview/api/scanner/media_type"
"github.com/pkg/errors"
"gopkg.in/vansante/go-ffprobe.v2"
_ "github.com/strukturag/libheif/go/heif"
"gorm.io/gorm"
)
func EncodeThumbnail(db *gorm.DB, inputPath string, outputPath string) (*media_utils.PhotoDimensions, error) {
var siteInfo models.SiteInfo
if err := db.First(&siteInfo).Error; err != nil {
return nil, err
// Dimension presents the Dimension of a image.
type Dimension struct {
Width int
Height int
}
inputImage, err := imaging.Open(inputPath, imaging.AutoOrientation(true))
// GetPhotoDimensions returns the dimension of the image `imagePath`.
func GetPhotoDimensions(imagePath string) (Dimension, error) {
w, h, err := executable_worker.Magick.IdentifyDimension(imagePath)
if err != nil {
return nil, err
return Dimension{}, fmt.Errorf("identify dimension %q error: %w", imagePath, err)
}
dimensions := media_utils.PhotoDimensionsFromRect(inputImage.Bounds())
dimensions = dimensions.ThumbnailScale()
thumbImage := imaging.Resize(inputImage, dimensions.Width, dimensions.Height, imaging.NearestNeighbor)
if err = encodeImageJPEG(thumbImage, outputPath, 60); err != nil {
return nil, err
return Dimension{
Width: w,
Height: h,
}, nil
}
return &dimensions, nil
// EncodeThumbnail encodes a thumbnail of `inputPath`, and store it as `outputPath`.
// It returns the dimension of the thumbnail. The thumbnail will be not bigger than 1024x1024.
func EncodeThumbnail(db *gorm.DB, inputPath string, outputPath string) (Dimension, error) {
if err := executable_worker.Magick.GenerateThumbnail(inputPath, outputPath, 1024, 1024); err != nil {
return Dimension{}, fmt.Errorf("can't generate thumbnail of file %q: %w", inputPath, err)
}
func encodeImageJPEG(image image.Image, outputPath string, jpegQuality int) error {
photo_file, err := os.Create(outputPath)
if err != nil {
return errors.Wrapf(err, "could not create file: %s", outputPath)
}
defer photo_file.Close()
err = jpeg.Encode(photo_file, image, &jpeg.Options{Quality: jpegQuality})
if err != nil {
return err
}
return nil
return GetPhotoDimensions(outputPath)
}
// EncodeMediaData is used to easily decode media data, with a cache so expensive operations are not repeated

View File

@@ -1,13 +1,37 @@
package executable_worker
import (
"flag"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/photoview/photoview/api/test_utils"
)
const testdataBinPath = "./test_data/mock_bin"
func init() {
// Avoid panic with providing flags in `test_utils/integration_setup.go`.
flag.CommandLine.Init("executable_worker", flag.ContinueOnError)
}
// SetPathWithCurrent sets PATH env to `paths` in the directory of testing files. The PATH will restore to the previous value when the test is done.
func SetPathWithCurrent(t *testing.T, paths ...string) {
_, file, _, ok := runtime.Caller(1)
if !ok {
t.Log("Can't get the test file. Ignore setting PATH.")
return
}
base := filepath.Dir(file)
for i, path := range paths {
paths[i] = filepath.Join(base, path)
}
t.Setenv("PATH", strings.Join(paths, ":"))
}
func TestInitFfprobePath(t *testing.T) {
t.Run("PathFail", func(t *testing.T) {
err := SetFfprobePath()
@@ -17,11 +41,8 @@ func TestInitFfprobePath(t *testing.T) {
})
t.Run("VersionFail", func(t *testing.T) {
donePath := test_utils.SetPathWithCurrent(testdataBinPath)
defer donePath()
doneEnv := test_utils.SetEnv("FAIL_WITH", "expect failure")
defer doneEnv()
SetPathWithCurrent(t, testdataBinPath)
t.Setenv("FAIL_WITH", "expect failure")
err := SetFfprobePath()
if err == nil {
@@ -30,8 +51,7 @@ func TestInitFfprobePath(t *testing.T) {
})
t.Run("Succeed", func(t *testing.T) {
donePath := test_utils.SetPathWithCurrent(testdataBinPath)
defer donePath()
SetPathWithCurrent(t, testdataBinPath)
err := SetFfprobePath()
if err != nil {

View File

@@ -5,14 +5,12 @@ import (
"regexp"
"testing"
"github.com/photoview/photoview/api/test_utils"
"github.com/photoview/photoview/api/utils"
"gopkg.in/vansante/go-ffprobe.v2"
)
func TestFfmpegNotExist(t *testing.T) {
done := test_utils.SetPathWithCurrent()
defer done()
SetPathWithCurrent(t, "")
Ffmpeg = newFfmpegCli()
@@ -34,11 +32,8 @@ func TestFfmpegNotExist(t *testing.T) {
}
func TestFfmpegVersionFail(t *testing.T) {
donePath := test_utils.SetPathWithCurrent(testdataBinPath)
defer donePath()
doneEnv := test_utils.SetEnv("FAIL_WITH", "expect failure")
defer doneEnv()
SetPathWithCurrent(t, testdataBinPath)
t.Setenv("FAIL_WITH", "expect failure")
Ffmpeg = newFfmpegCli()
@@ -60,11 +55,8 @@ func TestFfmpegVersionFail(t *testing.T) {
}
func TestFfmpegIgnore(t *testing.T) {
donePath := test_utils.SetPathWithCurrent(testdataBinPath)
defer donePath()
doneEnv := test_utils.SetEnv("PHOTOVIEW_DISABLE_VIDEO_ENCODING", "true")
defer doneEnv()
SetPathWithCurrent(t, testdataBinPath)
t.Setenv("PHOTOVIEW_DISABLE_VIDEO_ENCODING", "true")
Ffmpeg = newFfmpegCli()
@@ -86,8 +78,7 @@ func TestFfmpegIgnore(t *testing.T) {
}
func TestFfmpeg(t *testing.T) {
done := test_utils.SetPathWithCurrent(testdataBinPath)
defer done()
SetPathWithCurrent(t, testdataBinPath)
Ffmpeg = newFfmpegCli()
@@ -96,8 +87,7 @@ func TestFfmpeg(t *testing.T) {
}
t.Run("EncodeMp4Failed", func(t *testing.T) {
doneEnv := test_utils.SetEnv("FAIL_WITH", "expect failure")
defer doneEnv()
t.Setenv("FAIL_WITH", "expect failure")
err := Ffmpeg.EncodeMp4("input", "output")
if err == nil {
@@ -121,8 +111,7 @@ func TestFfmpeg(t *testing.T) {
},
}
t.Run("EncodeVideoThumbnailMp4Failed", func(t *testing.T) {
doneEnv := test_utils.SetEnv("FAIL_WITH", "expect failure")
defer doneEnv()
t.Setenv("FAIL_WITH", "expect failure")
err := Ffmpeg.EncodeVideoThumbnail("input", "output", probeData)
if err == nil {
@@ -142,16 +131,12 @@ func TestFfmpeg(t *testing.T) {
}
func TestFfmpegWithHWAcc(t *testing.T) {
doneCodec := test_utils.SetEnv(utils.EnvVideoHardwareAcceleration.GetName(), "qsv")
defer doneCodec()
donePath := test_utils.SetPathWithCurrent(testdataBinPath)
defer donePath()
SetPathWithCurrent(t, testdataBinPath)
t.Setenv(utils.EnvVideoHardwareAcceleration.GetName(), "qsv")
Ffmpeg = newFfmpegCli()
doneEnv := test_utils.SetEnv("FAIL_WITH", "expect failure")
defer doneEnv()
t.Setenv("FAIL_WITH", "expect failure")
err := Ffmpeg.EncodeMp4("input", "output")
if err == nil {
@@ -162,17 +147,13 @@ func TestFfmpegWithHWAcc(t *testing.T) {
}
}
func TestFfmpegWithCustomCOdec(t *testing.T) {
doneCodec := test_utils.SetEnv(utils.EnvVideoHardwareAcceleration.GetName(), "_custom")
defer doneCodec()
donePath := test_utils.SetPathWithCurrent(testdataBinPath)
defer donePath()
func TestFfmpegWithCustomCodec(t *testing.T) {
SetPathWithCurrent(t, testdataBinPath)
t.Setenv(utils.EnvVideoHardwareAcceleration.GetName(), "_custom")
Ffmpeg = newFfmpegCli()
doneEnv := test_utils.SetEnv("FAIL_WITH", "expect failure")
defer doneEnv()
t.Setenv("FAIL_WITH", "expect failure")
err := Ffmpeg.EncodeMp4("input", "output")
if err == nil {

View File

@@ -1,6 +1,8 @@
package executable_worker
import (
"bytes"
"encoding/json"
"fmt"
"os/exec"
"strings"
@@ -69,3 +71,63 @@ func (cli *MagickCli) EncodeJpeg(inputPath string, outputPath string, jpegQualit
return nil
}
func (cli *MagickCli) GenerateThumbnail(inputPath string, outputPath string, width, height int) error {
if cli.err != nil {
return fmt.Errorf("generate thumbnail %q error: magick: %w", inputPath, cli.err)
}
args := []string{
inputPath + "[0]", // If there are multiple frames (like gif), only thumbnail the first frame.
"-thumbnail",
fmt.Sprintf("%dx%d", width, height),
outputPath,
}
cmd := exec.Command(cli.path, args...)
if err := cmd.Run(); err != nil {
return fmt.Errorf("generate thumbnail with \"%s %v\" error: %w", cli.path, args, err)
}
return nil
}
func (cli *MagickCli) IdentifyDimension(inputPath string) (width, height int, err error) {
if cli.err != nil {
err = fmt.Errorf("identify dimension %q error: magick: %w", inputPath, cli.err)
return
}
args := []string{
"identify",
"-format",
`{"height":%H, "width":%W}`,
inputPath,
}
cmd := exec.Command(cli.path, args...)
var output bytes.Buffer
cmd.Stdout = &output
if e := cmd.Run(); e != nil {
err = fmt.Errorf("identify dimension with \"%s %v\" error: %w", cli.path, args, e)
return
}
ret := struct {
Width *int
Height *int
}{
Width: &width,
Height: &height,
}
if e := json.NewDecoder(&output).Decode(&ret); e != nil {
err = fmt.Errorf("identify dimension with \"%s %v\" error: %w", cli.path, args, e)
return
}
return
}

View File

@@ -4,13 +4,10 @@ import (
"errors"
"regexp"
"testing"
"github.com/photoview/photoview/api/test_utils"
)
func TestMagickCliNotExist(t *testing.T) {
done := test_utils.SetPathWithCurrent()
defer done()
SetPathWithCurrent(t, "")
Magick = newMagickCli()
@@ -25,14 +22,22 @@ func TestMagickCliNotExist(t *testing.T) {
if got, want := Magick.EncodeJpeg("input", "output", 70), ErrNoDependency; !errors.Is(got, want) {
t.Errorf("Magick.EncodeJpeg() = %v, want: %v", got, want)
}
if got, want := Magick.GenerateThumbnail("input", "output", 100, 100), ErrNoDependency; !errors.Is(got, want) {
t.Errorf("Magick.GenerateThumbnail() = %v, want: %v", got, want)
}
{
_, _, got := Magick.IdentifyDimension("input")
if want := ErrNoDependency; !errors.Is(got, want) {
t.Errorf("Magick.IdentifyDimension() = %v, want: %v", got, want)
}
}
}
func TestMagickCliIgnore(t *testing.T) {
donePath := test_utils.SetPathWithCurrent(testdataBinPath)
defer donePath()
doneDisableRaw := test_utils.SetEnv("PHOTOVIEW_DISABLE_RAW_PROCESSING", "true")
defer doneDisableRaw()
SetPathWithCurrent(t, testdataBinPath)
t.Setenv("PHOTOVIEW_DISABLE_RAW_PROCESSING", "true")
Magick = newMagickCli()
@@ -47,14 +52,22 @@ func TestMagickCliIgnore(t *testing.T) {
if got, want := Magick.EncodeJpeg("input", "output", 70), ErrDisabledFunction; !errors.Is(got, want) {
t.Errorf("Magick.EncodeJpeg() = %v, want: %v", got, want)
}
if got, want := Magick.GenerateThumbnail("input", "output", 100, 100), ErrDisabledFunction; !errors.Is(got, want) {
t.Errorf("Magick.GenerateThumbnail() = %v, want: %v", got, want)
}
{
_, _, got := Magick.IdentifyDimension("input")
if want := ErrDisabledFunction; !errors.Is(got, want) {
t.Errorf("Magick.IdentifyDimension() = %v, want: %v", got, want)
}
}
}
func TestMagickCliVersionFail(t *testing.T) {
donePath := test_utils.SetPathWithCurrent(testdataBinPath)
defer donePath()
done := test_utils.SetEnv("FAIL_WITH", "failure")
defer done()
SetPathWithCurrent(t, testdataBinPath)
t.Setenv("FAIL_WITH", "failure")
Magick = newMagickCli()
@@ -69,11 +82,21 @@ func TestMagickCliVersionFail(t *testing.T) {
if got, want := Magick.EncodeJpeg("input", "output", 70), ErrNoDependency; !errors.Is(got, want) {
t.Errorf("Magick.EncodeJpeg() = %v, want: %v", got, want)
}
if got, want := Magick.GenerateThumbnail("input", "output", 100, 100), ErrNoDependency; !errors.Is(got, want) {
t.Errorf("Magick.GenerateThumbnail() = %v, want: %v", got, want)
}
{
_, _, got := Magick.IdentifyDimension("input")
if want := ErrNoDependency; !errors.Is(got, want) {
t.Errorf("Magick.IdentifyDimension() = %v, want: %v", got, want)
}
}
}
func TestMagickCliFail(t *testing.T) {
donePath := test_utils.SetPathWithCurrent(testdataBinPath)
defer donePath()
SetPathWithCurrent(t, testdataBinPath)
Magick = newMagickCli()
@@ -81,8 +104,7 @@ func TestMagickCliFail(t *testing.T) {
t.Fatal("MagickCli should be installed")
}
done := test_utils.SetEnv("FAIL_WITH", "failure")
defer done()
t.Setenv("FAIL_WITH", "failure")
err := Magick.EncodeJpeg("input", "output", 70)
if err == nil {
@@ -92,11 +114,25 @@ func TestMagickCliFail(t *testing.T) {
if got, want := err.Error(), `^encoding image with ".*/test_data/mock_bin/magick \[input -auto-orient -quality 70 output\]" error: .*$`; !regexp.MustCompile(want).MatchString(got) {
t.Errorf(`MagickCli.EncodeJpeg(...) = %q, should be matched with reg pattern %q`, got, want)
}
err = Magick.GenerateThumbnail("input", "output", 100, 100)
if err == nil {
t.Fatalf(`MagickCli.GenerateThumbnail(...) = nil, should be an error.`)
}
if got, want := err.Error(), `^generate thumbnail with ".*/test_data/mock_bin/magick \[input\[0\] -thumbnail 100x100 output\]" error: .*$`; !regexp.MustCompile(want).MatchString(got) {
t.Errorf(`MagickCli.GenerateThumbnail(...) = %q, should be matched with reg pattern %q`, got, want)
}
{
_, _, got := Magick.IdentifyDimension("input")
if want := `^identify dimension with ".*/test_data/mock_bin/magick \[identify -format {"height":\%H, "width":\%W} input\]" error: .*$`; !regexp.MustCompile(want).MatchString(got.Error()) {
t.Errorf("Magick.IdentifyDimension() = %v, should be matched with reg pattern %q", got, want)
}
}
}
func TestMagickCliSucceed(t *testing.T) {
donePath := test_utils.SetPathWithCurrent(testdataBinPath)
defer donePath()
SetPathWithCurrent(t, testdataBinPath)
Magick = newMagickCli()
@@ -104,10 +140,40 @@ func TestMagickCliSucceed(t *testing.T) {
t.Fatal("MagickCli should be installed")
}
t.Run("Succeeded", func(t *testing.T) {
t.Run("EncodeJpeg", func(t *testing.T) {
err := Magick.EncodeJpeg("input", "output", 70)
if err != nil {
t.Fatalf("MagickCli.EncodeJpeg(...) = %v, should be nil.", err)
}
})
t.Run("GenerateThumbnail", func(t *testing.T) {
err := Magick.GenerateThumbnail("input", "output", 100, 100)
if err != nil {
t.Fatalf("MagickCli.GenerateThumbnail(...) = %v, should be nil.", err)
}
})
t.Run("IdentifyDimension", func(t *testing.T) {
w, h, err := Magick.IdentifyDimension("input")
if err != nil {
t.Fatalf("MagickCli.IdentifyDimension(...) = %v, should be nil.", err)
}
if got, want := w, 1000; got != want {
t.Errorf("got = %d, want = %d", got, want)
}
if got, want := h, 800; got != want {
t.Errorf("got = %d, want = %d", got, want)
}
})
t.Run("IdentifyDimensionInvalidJSON", func(t *testing.T) {
t.Setenv("INVALID_OUTPUT", `{"width":1000,`)
_, _, err := Magick.IdentifyDimension("input")
if want := `unexpected EOF$`; !regexp.MustCompile(want).MatchString(err.Error()) {
t.Errorf("MagickCli.IdentifyDimension() = error(%v), which should match with regexp %q", err, want)
}
})
}

View File

@@ -2,11 +2,7 @@
set -eu
: ${FAIL_WITH=""}
if [ "$1" = "-version" ]
then
echo magick: version fake
fi
: ${INVALID_OUTPUT=""}
if [ "${FAIL_WITH}" != "" ]
then
@@ -14,4 +10,26 @@ then
exit -1
fi
if [ "$#" = "0" ] # no args
then
exit 0
fi
case "$1" in
"-version")
echo magick: version fake
exit 0
;;
"identify")
if [ "${INVALID_OUTPUT}" != "" ]
then
echo -n ${INVALID_OUTPUT}
exit 0
fi
echo -n '{"height":800, "width":1000}'
exit 0
;;
esac
echo $@

View File

@@ -1,60 +0,0 @@
package media_utils
import (
"image"
"os"
)
type PhotoDimensions struct {
Width int
Height int
}
func GetPhotoDimensions(imagePath string) (*PhotoDimensions, error) {
photoFile, err := os.Open(imagePath)
if err != nil {
return nil, err
}
defer photoFile.Close()
config, _, err := image.DecodeConfig(photoFile)
if err != nil {
return nil, err
}
return &PhotoDimensions{
Width: config.Width,
Height: config.Height,
}, nil
}
func PhotoDimensionsFromRect(rect image.Rectangle) PhotoDimensions {
return PhotoDimensions{
Width: rect.Bounds().Max.X,
Height: rect.Bounds().Max.Y,
}
}
func (dimensions *PhotoDimensions) ThumbnailScale() PhotoDimensions {
aspect := float64(dimensions.Width) / float64(dimensions.Height)
var width, height int
if aspect > 1 {
width = 1024
height = int(1024 / aspect)
} else {
width = int(1024 * aspect)
height = 1024
}
if width > dimensions.Width {
width = dimensions.Width
height = dimensions.Height
}
return PhotoDimensions{
Width: width,
Height: height,
}
}

59
api/scanner/perf_test.go Normal file
View File

@@ -0,0 +1,59 @@
package scanner
import (
"image"
"image/jpeg"
_ "image/png"
"os"
"path/filepath"
"testing"
"github.com/photoview/photoview/api/scanner/media_encoding/executable_worker"
)
func BenchmarkStdlib(b *testing.B) {
dir := b.TempDir()
for b.Loop() {
func() {
input, err := os.Open("./test_media/real_media/png.png")
if err != nil {
b.Fatal("open error:", err)
}
defer input.Close()
img, _, err := image.Decode(input)
if err != nil {
b.Fatal("decode error:", err)
}
outfile := filepath.Join(dir, "test.jpg")
defer os.Remove(outfile)
output, err := os.Create(outfile)
if err != nil {
b.Fatal("create error:", err)
}
defer output.Close()
if err := jpeg.Encode(output, img, &jpeg.Options{Quality: 70}); err != nil {
b.Fatal("encode error:", err)
}
}()
}
}
func BenchmarkMagickCLI(b *testing.B) {
dir := b.TempDir()
for b.Loop() {
func() {
output := filepath.Join(dir, "test.jpg")
defer os.Remove(output)
if err := executable_worker.Magick.EncodeJpeg("./test_media/real_media/png.png", output, 70); err != nil {
b.Fatal("encode jpeg error:", err)
}
}()
}
}

View File

@@ -136,18 +136,20 @@ func (queue *ScannerQueue) CloseBackgroundWorker() {
func (queue *ScannerQueue) processQueue(notifyThrottle *utils.Throttle) {
log.Println("Queue waiting for lock")
queue.mutex.Lock()
log.Printf("Queue running: in_progress: %d, max_tasks: %d, queue_len: %d\n", len(queue.in_progress), queue.settings.max_concurrent_tasks, len(queue.up_next))
maxJobs := queue.settings.max_concurrent_tasks
log.Printf("Queue running: in_progress: %d, max_tasks: %d, queue_len: %d\n", len(queue.in_progress), maxJobs, len(queue.up_next))
for len(queue.in_progress) < queue.settings.max_concurrent_tasks && len(queue.up_next) > 0 {
for len(queue.in_progress) < maxJobs && len(queue.up_next) > 0 {
log.Println("Queue starting job")
nextJob := queue.up_next[0]
queue.up_next = queue.up_next[1:]
queue.in_progress = append(queue.in_progress, nextJob)
jobNum := len(queue.in_progress)
go func() {
log.Println("Starting job")
log.Printf("Starting job %d/%d\n", jobNum, maxJobs)
nextJob.Run(queue.db)
log.Println("Job finished")
log.Printf("Finished job %d/%d\n", jobNum, maxJobs)
// Delete finished job from queue
queue.mutex.Lock()

View File

@@ -73,8 +73,7 @@ func TestCounterpartFilesTaskMediaFound(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
done := test_utils.SetEnv(string(utils.EnvDisableRawProcessing), fmt.Sprintf("%v", tc.disableRawProcessing))
defer done()
t.Setenv(string(utils.EnvDisableRawProcessing), fmt.Sprintf("%v", tc.disableRawProcessing))
ctx := scanner_task.NewTaskContext(context.Background(), nil, nil, nil)

View File

@@ -8,17 +8,8 @@ import (
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/scanner/media_encoding/media_utils"
"github.com/photoview/photoview/api/scanner/scanner_task"
"github.com/pkg/errors"
// Image decoders
_ "image/gif"
_ "image/png"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
)
type ProcessPhotoTask struct {
@@ -91,12 +82,11 @@ func (t ProcessPhotoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData *
}
}
var photoDimensions *media_utils.PhotoDimensions
// Save original photo to database
if origURL == nil {
// Make sure photo dimensions is set
photoDimensions, err = media_utils.GetPhotoDimensions(baseImagePath)
photoDimensions, err := media_encoding.GetPhotoDimensions(baseImagePath)
if err != nil {
return []*models.MediaURL{}, err
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/scanner/media_encoding/executable_worker"
"github.com/photoview/photoview/api/scanner/media_encoding/media_utils"
"github.com/photoview/photoview/api/scanner/scanner_task"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
@@ -144,7 +143,7 @@ func (t ProcessVideoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData *
return []*models.MediaURL{}, errors.Wrapf(err, "failed to generate thumbnail for video (%s)", video.Title)
}
thumbDimensions, err := media_utils.GetPhotoDimensions(thumbImagePath)
thumbDimensions, err := media_encoding.GetPhotoDimensions(thumbImagePath)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "get dimensions of video thumbnail image")
}
@@ -182,7 +181,7 @@ func (t ProcessVideoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData *
return []*models.MediaURL{}, errors.Wrapf(err, "failed to generate thumbnail for video (%s)", video.Title)
}
thumbDimensions, err := media_utils.GetPhotoDimensions(thumbImagePath)
thumbDimensions, err := media_encoding.GetPhotoDimensions(thumbImagePath)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "get dimensions of video thumbnail image")
}

View File

@@ -6,7 +6,6 @@ import (
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/scanner/media_encoding/media_utils"
"github.com/pkg/errors"
"gorm.io/gorm"
)
@@ -18,7 +17,7 @@ func generateSaveHighResJPEG(tx *gorm.DB, media *models.Media, imageData *media_
return nil, errors.Wrap(err, "creating high-res cached image")
}
photoDimensions, err := media_utils.GetPhotoDimensions(imagePath)
photoDimensions, err := media_encoding.GetPhotoDimensions(imagePath)
if err != nil {
return nil, err
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/scanner/media_encoding/media_utils"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/gorm"
@@ -51,7 +50,7 @@ func generateUniqueMediaName(mediaPath string) string {
return mediaName
}
func saveOriginalPhotoToDB(tx *gorm.DB, photo *models.Media, imageData *media_encoding.EncodeMediaData, photoDimensions *media_utils.PhotoDimensions) (*models.MediaURL, error) {
func saveOriginalPhotoToDB(tx *gorm.DB, photo *models.Media, imageData *media_encoding.EncodeMediaData, photoDimensions media_encoding.Dimension) (*models.MediaURL, error) {
originalImageName := generateUniqueMediaName(photo.Path)
contentType, err := imageData.ContentType()

View File

@@ -38,7 +38,6 @@ func TestFullScan(t *testing.T) {
wantNoImages := []string{
"avi.avi",
"bmp.bmp",
"mkv.mkv",
"mp4.mp4",
"mpeg.mpg",
@@ -47,12 +46,9 @@ func TestFullScan(t *testing.T) {
"webm.webm",
"wmv.wmv",
}
wantImages := []string{
"gif.gif",
"webp.webp",
}
wantThumbnailsImages := []string{
"buttercup_close_summer_yellow.jpg",
"gif.gif",
"lilac_lilac_bush_lilac.jpg",
"mount_merapi_volcano_indonesia.jpg",
"boy1.jpg",
@@ -62,10 +58,14 @@ func TestFullScan(t *testing.T) {
"girl_blond2.jpg",
"girl_blond3.jpg",
"bmp.bmp",
"jpeg.jpg",
"jpg_with_file.jpg",
"webp.webp",
"png.png",
"standalone_jpg.jpg",
"recoverable_bad_rst_marker.jpg",
}
wantHighresImages := []string{
"heif.heif",
@@ -109,7 +109,7 @@ func TestFullScan(t *testing.T) {
t.Fatal("get all media error:", err)
}
want := slices.Clone(wantImages)
var want []string
want = append(want, wantNoImages...)
want = append(want, wantThumbnailsImages...)
want = append(want, wantHighresImages...)
@@ -132,7 +132,7 @@ func TestFullScan(t *testing.T) {
t.Fatal("get all media url error:", err)
}
want := slices.Clone(wantImages)
var want []string
wantThumbs := slices.Clone(wantThumbnailsImages)
want = append(want, wantThumbnailsImages...)

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 B

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 B

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -3,6 +3,7 @@ package scanner
import (
"fmt"
"image"
_ "image/jpeg"
"log"
"os"
@@ -40,7 +41,7 @@ func GenerateBlurhashes(db *gorm.DB) error {
hashStr, err := GenerateBlurhashFromThumbnail(thumbnail)
if err != nil {
log.Printf("failed to generate blurhash for media (%d): %v", row.ID, err)
log.Printf("failed to generate blurhash: %v", err)
processErrors = append(processErrors, err)
continue
}
@@ -72,26 +73,26 @@ func GenerateBlurhashes(db *gorm.DB) error {
func GenerateBlurhashFromThumbnail(thumbnail *models.MediaURL) (string, error) {
thumbnail_path, err := thumbnail.CachedPath()
if err != nil {
return "", err
return "", fmt.Errorf("get path of media id=%d error: %w", thumbnail.MediaID, err)
}
imageFile, err := os.Open(thumbnail_path)
if err != nil {
return "", err
return "", fmt.Errorf("open %s error: %w", thumbnail_path, err)
}
imageData, _, err := image.Decode(imageFile)
if err != nil {
return "", err
return "", fmt.Errorf("decode %q error: %w", thumbnail_path, err)
}
hashStr, err := blurhash.Encode(4, 3, imageData)
if err != nil {
return "", err
return "", fmt.Errorf("encode blurhash of %q error: %w", thumbnail_path, err)
}
// if err := db.Model(&models.Media{}).Where("id = ?", thumbnail.MediaID).Update("blurhash", hashStr).Error; err != nil {
// return "", err
// return "", fmt.Errorf("update blurhash of media id=%d error: %w", thumbnail.MediaID, err)
// }
return hashStr, nil

View File

@@ -1,42 +1,11 @@
package test_utils
import (
"os"
"path/filepath"
"runtime"
"strings"
)
func SetPathWithCurrent(paths ...string) func() {
_, file, _, ok := runtime.Caller(1)
if !ok {
return func() {
// Return an empty function in case of error
}
}
base := filepath.Dir(file)
for i, path := range paths {
paths[i] = filepath.Join(base, path)
}
originalPath := os.Getenv("PATH")
os.Setenv("PATH", strings.Join(paths, ":"))
return func() {
os.Setenv("PATH", originalPath)
}
}
func SetEnv(key, value string) func() {
org := os.Getenv(key)
os.Setenv(key, value)
return func() {
os.Setenv(key, org)
}
}
// PathFromAPIRoot returns the real path in the API project root.
func PathFromAPIRoot(rootRelatedPath string) string {
_, file, _, ok := runtime.Caller(0)
if !ok {

View File

@@ -1,7 +1,6 @@
package test_utils
import (
"os"
"strings"
"testing"
)
@@ -11,16 +10,3 @@ func TestPathFromAPIRoot(t *testing.T) {
t.Fatalf(`PathFromAPIRoot("./server.go") = %q, want a suffix: %q`, got, want)
}
}
func TestSetPathWithCurrent(t *testing.T) {
pathDone := SetEnv("PATH", "")
defer pathDone()
testDone := SetPathWithCurrent("./test")
defer testDone()
path := os.Getenv("PATH")
if got, want := path, "api/test_utils/test"; !strings.HasSuffix(got, want) {
t.Errorf("path = %q, want a suffix: %q", got, want)
}
}

View File

@@ -4,9 +4,6 @@ set -eu
: ${DEB_HOST_ARCH=`dpkg --print-architecture`}
echo Arch: ${DEB_HOST_ARCH}
# Install go-heif dependencies
apt-get install -y libdav1d-dev:${DEB_HOST_ARCH} libde265-dev:${DEB_HOST_ARCH} libjpeg62-turbo-dev:${DEB_HOST_ARCH} libopenh264-dev:${DEB_HOST_ARCH} libpng-dev:${DEB_HOST_ARCH} libnuma-dev:${DEB_HOST_ARCH} zlib1g-dev:${DEB_HOST_ARCH}
# Install go-face dependencies
apt-get install -y \
libdlib-dev:${DEB_HOST_ARCH} libblas-dev:${DEB_HOST_ARCH} libatlas-base-dev:${DEB_HOST_ARCH} liblapack-dev:${DEB_HOST_ARCH} libjpeg62-turbo-dev:${DEB_HOST_ARCH}

View File

@@ -31,7 +31,7 @@ GOOS="$TARGETOS"
GOARCH="$TARGETARCH"
GOARM="7"
if [ "$TARGETARCH" = "arm" && ! -z "$TARGETVARIANT" ]; then
if [ "$TARGETARCH" = "arm" ] && [ ! -z "$TARGETVARIANT" ]; then
case "$TARGETVARIANT" in
"v5")
export GOARM="5"