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

@@ -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
}