Use Magickwand to handle images. (#1212)

* Add MagickWand perf test.

* Use MagickWand.

* Calculate the right thumbnail dimension.

* Remove magick bin file.

* Add building dependencies.

* Build images.

* Add --no-install-recommends

* Remove unused field.

* Fix typo

* Use uint in the worker.

* Add guard

* Always update apt.

* Fix EncodeJpeg() with the quality

* Rename the local error with a better name.

* Add worker.Terminate()

* Fix typo

* Rebase with master and rollback the build workflow.
This commit is contained in:
Googol Lee
2025-06-03 22:04:03 +02:00
committed by GitHub
parent c390f83355
commit 535f8e57fd
14 changed files with 236 additions and 336 deletions

View File

@@ -21,6 +21,35 @@ type Dimension struct {
Height int
}
// ThumbnailScale generates a new dimension for thumbnails.
func (d *Dimension) ThumbnailScale() Dimension {
if d.Height == 0 || d.Width == 0 {
return Dimension{Width: 0, Height: 0}
}
aspect := float64(d.Width) / float64(d.Height)
var width, height int
if aspect > 1 {
width = 1024
height = int(1024 / aspect)
} else {
width = int(1024 * aspect)
height = 1024
}
if width > d.Width {
width = d.Width
height = d.Height
}
return Dimension{
Width: width,
Height: height,
}
}
// GetPhotoDimensions returns the dimension of the image `imagePath`.
func GetPhotoDimensions(imagePath string) (Dimension, error) {
w, h, err := executable_worker.Magick.IdentifyDimension(imagePath)
@@ -29,19 +58,30 @@ func GetPhotoDimensions(imagePath string) (Dimension, error) {
}
return Dimension{
Width: w,
Height: h,
Width: int(w),
Height: int(h),
}, 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 {
w, h, err := executable_worker.Magick.IdentifyDimension(inputPath)
if err != nil {
return Dimension{}, fmt.Errorf("can't generate thumbnail of file %q: %w", inputPath, err)
}
return GetPhotoDimensions(outputPath)
origin := Dimension{
Width: int(w),
Height: int(h),
}
thumbnail := origin.ThumbnailScale()
if err := executable_worker.Magick.GenerateThumbnail(inputPath, outputPath, uint(thumbnail.Width), uint(thumbnail.Height)); err != nil {
return Dimension{}, fmt.Errorf("can't generate thumbnail of file %q: %w", inputPath, err)
}
return thumbnail, nil
}
// EncodeMediaData is used to easily decode media data, with a cache so expensive operations are not repeated

View File

@@ -13,16 +13,22 @@ import (
var ErrNoDependency = errors.New("dependency not found")
var ErrDisabledFunction = errors.New("function disabled")
func init() {
Magick = newMagickCli()
// Initialize Initializes all workers. It returns a function to terminate workers, which should be called before the program closing.
func Initialize() func() {
Magick = newMagickWand()
Ffmpeg = newFfmpegCli()
if err := SetFfprobePath(); err != nil {
log.Error("Init ffprobe fail.", "error", err)
}
return func() {
Magick.Terminate()
Magick = nil
}
}
var Magick *MagickCli = nil
var Magick *MagickWand = nil
var Ffmpeg *FfmpegCli = nil
type ExecutableWorker interface {

View File

@@ -1,133 +0,0 @@
package executable_worker
import (
"bytes"
"encoding/json"
"fmt"
"os/exec"
"strings"
"github.com/photoview/photoview/api/log"
"github.com/photoview/photoview/api/utils"
)
type MagickCli struct {
path string
err error
}
func newMagickCli() *MagickCli {
if utils.EnvDisableRawProcessing.GetBool() {
log.Warn("Executable magick worker disabled", utils.EnvDisableRawProcessing.GetName(), utils.EnvDisableRawProcessing.GetValue())
return &MagickCli{
err: ErrDisabledFunction,
}
}
path, err := exec.LookPath("magick")
if err != nil {
log.Error("Executable magick worker not found")
return &MagickCli{
err: ErrNoDependency,
}
}
version, err := exec.Command(path, "-version").Output()
if err != nil {
log.Error("Executable magick worker get version error", "error", err)
return &MagickCli{
err: ErrNoDependency,
}
}
log.Info("Found magick executable worker", "version", strings.Split(string(version), "\n")[0])
return &MagickCli{
path: path,
}
}
func (cli *MagickCli) IsInstalled() bool {
return cli.err == nil
}
func (cli *MagickCli) EncodeJpeg(inputPath string, outputPath string, jpegQuality int) error {
if cli.err != nil {
return fmt.Errorf("encoding jpeg %q error: magick: %w", inputPath, cli.err)
}
args := []string{
inputPath,
"-auto-orient",
"-quality", fmt.Sprintf("%d", jpegQuality),
outputPath,
}
cmd := exec.Command(cli.path, args...)
if err := cmd.Run(); err != nil {
return fmt.Errorf("encoding image with \"%s %v\" error: %w", cli.path, args, err)
}
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

@@ -1,179 +0,0 @@
package executable_worker
import (
"errors"
"regexp"
"testing"
)
func TestMagickCliNotExist(t *testing.T) {
SetPathWithCurrent(t, "")
Magick = newMagickCli()
if got, want := Magick.err, ErrNoDependency; got != want {
t.Errorf("Magick.err = %v, want: %v", got, want)
}
if Magick.IsInstalled() {
t.Error("MagickCli should not be installed, but is found:", Magick)
}
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) {
SetPathWithCurrent(t, testdataBinPath)
t.Setenv("PHOTOVIEW_DISABLE_RAW_PROCESSING", "true")
Magick = newMagickCli()
if got, want := Magick.err, ErrDisabledFunction; got != want {
t.Errorf("Magick.err = %v, want: %v", got, want)
}
if Magick.IsInstalled() {
t.Error("MagickCli should not be installed, but is found:", Magick)
}
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) {
SetPathWithCurrent(t, testdataBinPath)
t.Setenv("FAIL_WITH", "failure")
Magick = newMagickCli()
if got, want := Magick.err, ErrNoDependency; got != want {
t.Errorf("Magick.err = %v, want: %v", got, want)
}
if Magick.IsInstalled() {
t.Error("MagickCli should not be installed, but is found:", Magick)
}
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) {
SetPathWithCurrent(t, testdataBinPath)
Magick = newMagickCli()
if !Magick.IsInstalled() {
t.Fatal("MagickCli should be installed")
}
t.Setenv("FAIL_WITH", "failure")
err := Magick.EncodeJpeg("input", "output", 70)
if err == nil {
t.Fatalf(`MagickCli.EncodeJpeg(...) = nil, should be an error.`)
}
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) {
SetPathWithCurrent(t, testdataBinPath)
Magick = newMagickCli()
if !Magick.IsInstalled() {
t.Fatal("MagickCli should be installed")
}
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

@@ -0,0 +1,111 @@
package executable_worker
import (
"fmt"
"github.com/photoview/photoview/api/log"
"gopkg.in/gographics/imagick.v3/imagick"
)
type MagickWand struct {
initialized bool
}
func newMagickWand() *MagickWand {
imagick.Initialize()
verstr, vernum := imagick.GetVersion()
log.Info("Found magickwand worker: "+verstr, "version", vernum)
return &MagickWand{
initialized: true,
}
}
func (cli *MagickWand) Terminate() {
cli.initialized = false
imagick.Terminate()
}
func (cli *MagickWand) IsInstalled() bool {
return cli != nil && cli.initialized
}
func (cli *MagickWand) EncodeJpeg(inputPath string, outputPath string, jpegQuality uint) error {
if !cli.IsInstalled() {
return fmt.Errorf("ImagickWand is not initialized")
}
wand := imagick.NewMagickWand()
defer wand.Destroy()
if err := wand.ReadImage(inputPath); err != nil {
return fmt.Errorf("ImagickWand read %q error: %w", inputPath, err)
}
if err := wand.SetFormat("JPEG"); err != nil {
return fmt.Errorf("ImagickWand set JPEG format for %q error: %w", inputPath, err)
}
if err := wand.SetImageCompressionQuality(jpegQuality); err != nil {
return fmt.Errorf("ImagickWand set JPEG quality %d for %q error: %w", jpegQuality, inputPath, err)
}
if err := wand.WriteImage(outputPath); err != nil {
return fmt.Errorf("ImagickWand write %q error: %w", outputPath, err)
}
return nil
}
func (cli *MagickWand) GenerateThumbnail(inputPath string, outputPath string, width, height uint) error {
if !cli.IsInstalled() {
return fmt.Errorf("ImagickWand is not initialized")
}
wand := imagick.NewMagickWand()
defer wand.Destroy()
if err := wand.ReadImage(inputPath); err != nil {
return fmt.Errorf("ImagickWand read %q error: %w", inputPath, err)
}
if err := wand.ThumbnailImage(width, height); err != nil {
return fmt.Errorf("ImagickWand generate thumbnail for %q error: %w", inputPath, err)
}
if err := wand.SetFormat("JPEG"); err != nil {
return fmt.Errorf("ImagickWand set JPEG format for %q error: %w", inputPath, err)
}
if err := wand.SetImageCompressionQuality(70); err != nil {
return fmt.Errorf("ImagickWand set JPEG quality %d for %q error: %w", 70, inputPath, err)
}
if err := wand.WriteImage(outputPath); err != nil {
return fmt.Errorf("ImagickWand write %q error: %w", outputPath, err)
}
return nil
}
func (cli *MagickWand) IdentifyDimension(inputPath string) (width, height uint, err error) {
if !cli.IsInstalled() {
err = fmt.Errorf("ImagickWand is not initialized")
return
}
wand := imagick.NewMagickWand()
defer wand.Destroy()
if errRI := wand.ReadImage(inputPath); errRI != nil {
err = fmt.Errorf("ImagickWand read %q error: %w", inputPath, errRI)
return
}
width = wand.GetImageWidth()
height = wand.GetImageHeight()
return
}

View File

@@ -9,6 +9,7 @@ import (
"testing"
"github.com/photoview/photoview/api/scanner/media_encoding/executable_worker"
"gopkg.in/gographics/imagick.v3/imagick"
)
func BenchmarkStdlib(b *testing.B) {
@@ -57,3 +58,36 @@ func BenchmarkMagickCLI(b *testing.B) {
}()
}
}
func BenchmarkMagickWand(b *testing.B) {
dir := b.TempDir()
imagick.Initialize()
defer imagick.Terminate()
for b.Loop() {
func() {
mw := imagick.NewMagickWand()
defer mw.Destroy()
if err := mw.ReadImage("./test_media/real_media/png.png"); err != nil {
b.Fatal("read error:", err)
}
output := filepath.Join(dir, "test.jpg")
defer os.Remove(output)
if err := mw.SetFormat("JPEG"); err != nil {
b.Fatal("set format error:", err)
}
if err := mw.SetImageCompressionQuality(70); err != nil {
b.Fatal("set quality error:", err)
}
if err := mw.WriteImage(output); err != nil {
b.Fatal("write error:", err)
}
}()
}
}

View File

@@ -122,7 +122,7 @@ func TestFullScan(t *testing.T) {
slices.Sort(got)
if diff := cmp.Diff(got, want); diff != "" {
t.Errorf("all media diff:\n%s", diff)
t.Errorf("all media diff (-got, +want):\n%s", diff)
}
})
@@ -168,7 +168,7 @@ func TestFullScan(t *testing.T) {
slices.Sort(got)
if diff := cmp.Diff(got, want, cmp.Comparer(equalNameWithoutSuffix)); diff != "" {
t.Errorf("all media diff:\n%s", diff)
t.Errorf("all media diff (-got, +want):\n%s", diff)
}
})
@@ -202,7 +202,7 @@ func TestFullScan(t *testing.T) {
got := groupMediaWithFaces(allImageFaces)
if diff := cmp.Diff(got, wantFaceGroups); diff != "" {
t.Errorf("all media diff:\n%s", diff)
t.Errorf("all media diff (-got, +want):\n%s", diff)
}
})
}