Make code for loading images with ImageMagick re-useable
This commit is contained in:
parent
404a775f4b
commit
be886f9bf9
@ -3,372 +3,31 @@
|
|||||||
package icat
|
package icat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
|
||||||
"image/gif"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"kitty/tools/tui/graphics"
|
"kitty/tools/tui/graphics"
|
||||||
"kitty/tools/utils"
|
|
||||||
"kitty/tools/utils/images"
|
"kitty/tools/utils/images"
|
||||||
"kitty/tools/utils/shm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = fmt.Print
|
var _ = fmt.Print
|
||||||
|
|
||||||
var MagickExe = (&utils.Once[string]{Run: func() string {
|
func Render(path string, ro *images.RenderOptions, frames []images.IdentifyRecord) (ans []*image_frame, err error) {
|
||||||
ans := utils.Which("magick")
|
ro.TempfilenameTemplate = shm_template
|
||||||
if ans == "" {
|
image_frames, filenames, err := images.RenderWithMagick(path, ro, frames)
|
||||||
ans = utils.Which("magick", "/usr/local/bin", "/opt/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin")
|
if err == nil {
|
||||||
}
|
ans = make([]*image_frame, len(image_frames))
|
||||||
return ans
|
for i, x := range image_frames {
|
||||||
}}).Get
|
ans[i] = &image_frame{
|
||||||
|
filename: filenames[x.Number], filename_is_temporary: true,
|
||||||
func run_magick(path string, cmd []string) ([]byte, error) {
|
number: x.Number, width: x.Width, height: x.Height, left: x.Left, top: x.Top,
|
||||||
c := exec.Command(cmd[0], cmd[1:]...)
|
transmission_format: graphics.GRT_format_rgba, delay_ms: int(x.Delay_ms), compose_onto: x.Compose_onto,
|
||||||
output, err := c.Output()
|
}
|
||||||
if err != nil {
|
if x.Is_opaque {
|
||||||
var exit_err *exec.ExitError
|
ans[i].transmission_format = graphics.GRT_format_rgb
|
||||||
if errors.As(err, &exit_err) {
|
|
||||||
return nil, fmt.Errorf("Running the command: %s\nFailed with error:\n%s", strings.Join(cmd, " "), string(exit_err.Stderr))
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("Could not find the program: %#v. Is ImageMagick installed and in your PATH?", cmd[0])
|
|
||||||
}
|
|
||||||
return output, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type IdentifyOutput struct {
|
|
||||||
Fmt, Canvas, Transparency, Gap, Index, Size, Dpi, Dispose, Orientation string
|
|
||||||
}
|
|
||||||
|
|
||||||
type IdentifyRecord struct {
|
|
||||||
FmtUppercase string
|
|
||||||
Gap int
|
|
||||||
Canvas struct{ Width, Height, Left, Top int }
|
|
||||||
Width, Height int
|
|
||||||
Dpi struct{ X, Y float64 }
|
|
||||||
Index int
|
|
||||||
Mode graphics.GRT_f
|
|
||||||
NeedsBlend bool
|
|
||||||
Disposal int
|
|
||||||
DimensionsSwapped bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func parse_identify_record(ans *IdentifyRecord, raw *IdentifyOutput) (err error) {
|
|
||||||
ans.FmtUppercase = strings.ToUpper(raw.Fmt)
|
|
||||||
if raw.Gap != "" {
|
|
||||||
ans.Gap, err = strconv.Atoi(raw.Gap)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Invalid gap value in identify output: %s", raw.Gap)
|
|
||||||
}
|
|
||||||
ans.Gap = utils.Max(0, ans.Gap)
|
|
||||||
}
|
|
||||||
area, pos, found := strings.Cut(raw.Canvas, "+")
|
|
||||||
ok := false
|
|
||||||
if found {
|
|
||||||
w, h, found := strings.Cut(area, "x")
|
|
||||||
if found {
|
|
||||||
ans.Canvas.Width, err = strconv.Atoi(w)
|
|
||||||
if err == nil {
|
|
||||||
ans.Canvas.Height, err = strconv.Atoi(h)
|
|
||||||
if err == nil {
|
|
||||||
x, y, found := strings.Cut(pos, "+")
|
|
||||||
if found {
|
|
||||||
ans.Canvas.Left, err = strconv.Atoi(x)
|
|
||||||
if err == nil {
|
|
||||||
ans.Canvas.Top, err = strconv.Atoi(y)
|
|
||||||
ok = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !ok {
|
return ans, err
|
||||||
return fmt.Errorf("Invalid canvas value in identify output: %s", raw.Canvas)
|
|
||||||
}
|
|
||||||
w, h, found := strings.Cut(raw.Size, "x")
|
|
||||||
ok = false
|
|
||||||
if found {
|
|
||||||
ans.Width, err = strconv.Atoi(w)
|
|
||||||
if err == nil {
|
|
||||||
ans.Height, err = strconv.Atoi(h)
|
|
||||||
ok = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("Invalid size value in identify output: %s", raw.Size)
|
|
||||||
}
|
|
||||||
x, y, found := strings.Cut(raw.Dpi, "x")
|
|
||||||
ok = false
|
|
||||||
if found {
|
|
||||||
ans.Dpi.X, err = strconv.ParseFloat(x, 64)
|
|
||||||
if err == nil {
|
|
||||||
ans.Dpi.Y, err = strconv.ParseFloat(y, 64)
|
|
||||||
ok = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("Invalid dpi value in identify output: %s", raw.Dpi)
|
|
||||||
}
|
|
||||||
ans.Index, err = strconv.Atoi(raw.Index)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Invalid index value in identify output: %s", raw.Index)
|
|
||||||
}
|
|
||||||
q := strings.ToLower(raw.Transparency)
|
|
||||||
if q == "blend" || q == "true" {
|
|
||||||
ans.Mode = graphics.GRT_format_rgba
|
|
||||||
} else {
|
|
||||||
ans.Mode = graphics.GRT_format_rgb
|
|
||||||
}
|
|
||||||
ans.NeedsBlend = q == "blend"
|
|
||||||
switch strings.ToLower(raw.Dispose) {
|
|
||||||
case "undefined":
|
|
||||||
ans.Disposal = 0
|
|
||||||
case "none":
|
|
||||||
ans.Disposal = gif.DisposalNone
|
|
||||||
case "background":
|
|
||||||
ans.Disposal = gif.DisposalBackground
|
|
||||||
case "previous":
|
|
||||||
ans.Disposal = gif.DisposalPrevious
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("Invalid value for dispose: %s", raw.Dispose)
|
|
||||||
}
|
|
||||||
switch raw.Orientation {
|
|
||||||
case "5", "6", "7", "8":
|
|
||||||
ans.DimensionsSwapped = true
|
|
||||||
}
|
|
||||||
if ans.DimensionsSwapped {
|
|
||||||
ans.Canvas.Width, ans.Canvas.Height = ans.Canvas.Height, ans.Canvas.Width
|
|
||||||
ans.Width, ans.Height = ans.Height, ans.Width
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func Identify(path string) (ans []IdentifyRecord, err error) {
|
|
||||||
cmd := []string{"identify"}
|
|
||||||
if MagickExe() != "" {
|
|
||||||
cmd = []string{MagickExe(), cmd[0]}
|
|
||||||
}
|
|
||||||
q := `{"fmt":"%m","canvas":"%g","transparency":"%A","gap":"%T","index":"%p","size":"%wx%h",` +
|
|
||||||
`"dpi":"%xx%y","dispose":"%D","orientation":"%[EXIF:Orientation]"},`
|
|
||||||
cmd = append(cmd, "-format", q, "--", path)
|
|
||||||
output, err := run_magick(path, cmd)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
output = bytes.TrimRight(bytes.TrimSpace(output), ",")
|
|
||||||
raw_json := make([]byte, 0, len(output)+2)
|
|
||||||
raw_json = append(raw_json, '[')
|
|
||||||
raw_json = append(raw_json, output...)
|
|
||||||
raw_json = append(raw_json, ']')
|
|
||||||
var records []IdentifyOutput
|
|
||||||
err = json.Unmarshal(raw_json, &records)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("The ImageMagick identify program returned malformed output, with error: %w", err)
|
|
||||||
}
|
|
||||||
ans = make([]IdentifyRecord, len(records))
|
|
||||||
for i, rec := range records {
|
|
||||||
err = parse_identify_record(&ans[i], &rec)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ans, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type RenderOptions struct {
|
|
||||||
RemoveAlpha *images.NRGBColor
|
|
||||||
Flip, Flop bool
|
|
||||||
ResizeTo image.Point
|
|
||||||
OnlyFirstFrame bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func make_temp_dir() (ans string, err error) {
|
|
||||||
if shm.SHM_DIR != "" {
|
|
||||||
ans, err = os.MkdirTemp(shm.SHM_DIR, shm_template)
|
|
||||||
if err == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return os.MkdirTemp("", shm_template)
|
|
||||||
}
|
|
||||||
|
|
||||||
func check_resize(frame *image_frame) error {
|
|
||||||
// ImageMagick sometimes generates RGBA images smaller than the specified
|
|
||||||
// size. See https://github.com/kovidgoyal/kitty/issues/276 for examples
|
|
||||||
s, err := os.Stat(frame.filename)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sz := int(s.Size())
|
|
||||||
bytes_per_pixel := 4
|
|
||||||
if frame.transmission_format == graphics.GRT_format_rgb {
|
|
||||||
bytes_per_pixel = 3
|
|
||||||
}
|
|
||||||
expected_size := bytes_per_pixel * frame.width * frame.height
|
|
||||||
if sz < expected_size {
|
|
||||||
missing := expected_size - sz
|
|
||||||
if missing%(bytes_per_pixel*frame.width) != 0 {
|
|
||||||
return fmt.Errorf("ImageMagick failed to resize correctly. It generated %d < %d of data (w=%d h=%d bpp=%d)", sz, expected_size, frame.width, frame.height, bytes_per_pixel)
|
|
||||||
}
|
|
||||||
frame.height -= missing / (bytes_per_pixel * frame.width)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Render(path string, ro *RenderOptions, frames []IdentifyRecord) (ans []*image_frame, err error) {
|
|
||||||
cmd := []string{"convert"}
|
|
||||||
if MagickExe() != "" {
|
|
||||||
cmd = []string{MagickExe(), cmd[0]}
|
|
||||||
}
|
|
||||||
ans = make([]*image_frame, 0, len(frames))
|
|
||||||
defer func() {
|
|
||||||
if err != nil && ans != nil {
|
|
||||||
for _, frame := range ans {
|
|
||||||
if frame.filename_is_temporary {
|
|
||||||
os.Remove(frame.filename)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ans = nil
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if ro.RemoveAlpha != nil {
|
|
||||||
cmd = append(cmd, "-background", ro.RemoveAlpha.AsSharp(), "-alpha", "remove")
|
|
||||||
} else {
|
|
||||||
cmd = append(cmd, "-background", "none")
|
|
||||||
}
|
|
||||||
if ro.Flip {
|
|
||||||
cmd = append(cmd, "-flip")
|
|
||||||
}
|
|
||||||
if ro.Flop {
|
|
||||||
cmd = append(cmd, "-flop")
|
|
||||||
}
|
|
||||||
cpath := path
|
|
||||||
if ro.OnlyFirstFrame {
|
|
||||||
cpath += "[0]"
|
|
||||||
}
|
|
||||||
has_multiple_frames := len(frames) > 1
|
|
||||||
get_multiple_frames := has_multiple_frames && !ro.OnlyFirstFrame
|
|
||||||
cmd = append(cmd, "--", cpath, "-auto-orient")
|
|
||||||
if ro.ResizeTo.X > 0 {
|
|
||||||
rcmd := []string{"-resize", fmt.Sprintf("%dx%d!", ro.ResizeTo.X, ro.ResizeTo.Y)}
|
|
||||||
if get_multiple_frames {
|
|
||||||
cmd = append(cmd, "-coalesce")
|
|
||||||
cmd = append(cmd, rcmd...)
|
|
||||||
cmd = append(cmd, "-deconstruct")
|
|
||||||
} else {
|
|
||||||
cmd = append(cmd, rcmd...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cmd = append(cmd, "-depth", "8", "-set", "filename:f", "%w-%h-%g-%p")
|
|
||||||
if get_multiple_frames {
|
|
||||||
cmd = append(cmd, "+adjoin")
|
|
||||||
}
|
|
||||||
tdir, err := make_temp_dir()
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("Failed to create temporary directory to hold ImageMagick output with error: %w", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tdir)
|
|
||||||
mode := "rgba"
|
|
||||||
if frames[0].Mode == graphics.GRT_format_rgb {
|
|
||||||
mode = "rgb"
|
|
||||||
}
|
|
||||||
cmd = append(cmd, filepath.Join(tdir, "im-%[filename:f]."+mode))
|
|
||||||
_, err = run_magick(path, cmd)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entries, err := os.ReadDir(tdir)
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("Failed to read temp dir used to store ImageMagick output with error: %w", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
base_dir := filepath.Dir(tdir)
|
|
||||||
gaps := make([]int, len(frames))
|
|
||||||
for i, frame := range frames {
|
|
||||||
gaps[i] = frame.Gap
|
|
||||||
}
|
|
||||||
min_gap := calc_min_gap(gaps)
|
|
||||||
for _, entry := range entries {
|
|
||||||
fname := entry.Name()
|
|
||||||
p, _, _ := strings.Cut(fname, ".")
|
|
||||||
parts := strings.Split(p, "-")
|
|
||||||
if len(parts) < 5 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
index, cerr := strconv.Atoi(parts[len(parts)-1])
|
|
||||||
if cerr != nil || index < 0 || index >= len(frames) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
width, cerr := strconv.Atoi(parts[1])
|
|
||||||
if cerr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
height, cerr := strconv.Atoi(parts[2])
|
|
||||||
if cerr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, pos, found := strings.Cut(parts[3], "+")
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
px, py, found := strings.Cut(pos, "+")
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
x, cerr := strconv.Atoi(px)
|
|
||||||
if cerr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
y, cerr := strconv.Atoi(py)
|
|
||||||
if cerr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
identify_data := frames[index]
|
|
||||||
df, cerr := os.CreateTemp(base_dir, graphics.TempTemplate+"."+mode)
|
|
||||||
if cerr != nil {
|
|
||||||
err = fmt.Errorf("Failed to create a temporary file in %s with error: %w", base_dir, cerr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = os.Rename(filepath.Join(tdir, fname), df.Name())
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("Failed to rename a temporary file in %s with error: %w", tdir, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
df.Close()
|
|
||||||
frame := image_frame{
|
|
||||||
number: index + 1, width: width, height: height, left: x, top: y,
|
|
||||||
transmission_format: identify_data.Mode, filename_is_temporary: true,
|
|
||||||
filename: df.Name(),
|
|
||||||
}
|
|
||||||
frame.set_delay(identify_data.Gap, min_gap)
|
|
||||||
err = check_resize(&frame)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ans = append(ans, &frame)
|
|
||||||
}
|
|
||||||
if len(ans) < len(frames) {
|
|
||||||
err = fmt.Errorf("Failed to render %d out of %d frames", len(frames)-len(ans), len(frames))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ans = utils.Sort(ans, func(a, b *image_frame) bool { return a.number < b.number })
|
|
||||||
anchor_frame := 1
|
|
||||||
for i, frame := range ans {
|
|
||||||
anchor_frame = frame.set_disposal(anchor_frame, byte(frames[i].Disposal))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func render_image_with_magick(imgd *image_data, src *opened_input) (err error) {
|
func render_image_with_magick(imgd *image_data, src *opened_input) (err error) {
|
||||||
@ -376,18 +35,18 @@ func render_image_with_magick(imgd *image_data, src *opened_input) (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
frames, err := Identify(src.FileSystemName())
|
frames, err := images.IdentifyWithMagick(src.FileSystemName())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
imgd.format_uppercase = frames[0].FmtUppercase
|
imgd.format_uppercase = frames[0].Fmt_uppercase
|
||||||
imgd.canvas_width, imgd.canvas_height = frames[0].Canvas.Width, frames[0].Canvas.Height
|
imgd.canvas_width, imgd.canvas_height = frames[0].Canvas.Width, frames[0].Canvas.Height
|
||||||
set_basic_metadata(imgd)
|
set_basic_metadata(imgd)
|
||||||
if !imgd.needs_conversion {
|
if !imgd.needs_conversion {
|
||||||
make_output_from_input(imgd, src)
|
make_output_from_input(imgd, src)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ro := RenderOptions{RemoveAlpha: remove_alpha, Flip: flip, Flop: flop}
|
ro := images.RenderOptions{RemoveAlpha: remove_alpha, Flip: flip, Flop: flop}
|
||||||
if scale_image(imgd) {
|
if scale_image(imgd) {
|
||||||
ro.ResizeTo.X, ro.ResizeTo.Y = imgd.canvas_width, imgd.canvas_height
|
ro.ResizeTo.X, ro.ResizeTo.Y = imgd.canvas_width, imgd.canvas_height
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,17 +3,26 @@
|
|||||||
package images
|
package images
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/gif"
|
"image/gif"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"kitty/tools/tui/graphics"
|
||||||
"kitty/tools/utils"
|
"kitty/tools/utils"
|
||||||
|
"kitty/tools/utils/shm"
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = fmt.Print
|
var _ = fmt.Print
|
||||||
@ -64,6 +73,49 @@ func SetGIFFrameDisposal(number, anchor_frame int, disposal byte) (int, int) {
|
|||||||
return anchor_frame, compose_onto
|
return anchor_frame, compose_onto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func MakeTempDir(template string) (ans string, err error) {
|
||||||
|
if template == "" {
|
||||||
|
template = "kitty-img-*"
|
||||||
|
}
|
||||||
|
if shm.SHM_DIR != "" {
|
||||||
|
ans, err = os.MkdirTemp(shm.SHM_DIR, template)
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.MkdirTemp("", template)
|
||||||
|
}
|
||||||
|
|
||||||
|
func check_resize(frame *ImageFrame, filename string) error {
|
||||||
|
// ImageMagick sometimes generates RGBA images smaller than the specified
|
||||||
|
// size. See https://github.com/kovidgoyal/kitty/issues/276 for examples
|
||||||
|
s, err := os.Stat(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sz := int(s.Size())
|
||||||
|
bytes_per_pixel := 4
|
||||||
|
if frame.Is_opaque {
|
||||||
|
bytes_per_pixel = 3
|
||||||
|
}
|
||||||
|
expected_size := bytes_per_pixel * frame.Width * frame.Height
|
||||||
|
if sz < expected_size {
|
||||||
|
missing := expected_size - sz
|
||||||
|
if missing%(bytes_per_pixel*frame.Width) != 0 {
|
||||||
|
return fmt.Errorf("ImageMagick failed to resize correctly. It generated %d < %d of data (w=%d h=%d bpp=%d)", sz, expected_size, frame.Width, frame.Height, bytes_per_pixel)
|
||||||
|
}
|
||||||
|
frame.Height -= missing / (bytes_per_pixel * frame.Width)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (frame *ImageFrame) set_delay(min_gap, delay int) {
|
||||||
|
frame.Delay_ms = int32(utils.Max(min_gap, delay) * 10)
|
||||||
|
if frame.Delay_ms == 0 {
|
||||||
|
frame.Delay_ms = -1 // gapless frame in the graphics protocol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func open_native_gif(f io.Reader, ans *ImageData) error {
|
func open_native_gif(f io.Reader, ans *ImageData) error {
|
||||||
gif_frames, err := gif.DecodeAll(f)
|
gif_frames, err := gif.DecodeAll(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -74,10 +126,7 @@ func open_native_gif(f io.Reader, ans *ImageData) error {
|
|||||||
for i, paletted_img := range gif_frames.Image {
|
for i, paletted_img := range gif_frames.Image {
|
||||||
b := paletted_img.Bounds()
|
b := paletted_img.Bounds()
|
||||||
frame := ImageFrame{Img: paletted_img, Left: b.Min.X, Top: b.Min.Y, Width: b.Dx(), Height: b.Dy(), Number: len(ans.Frames) + 1, Is_opaque: paletted_img.Opaque()}
|
frame := ImageFrame{Img: paletted_img, Left: b.Min.X, Top: b.Min.Y, Width: b.Dx(), Height: b.Dy(), Number: len(ans.Frames) + 1, Is_opaque: paletted_img.Opaque()}
|
||||||
frame.Delay_ms = int32(utils.Max(min_gap, gif_frames.Delay[i]) * 10)
|
frame.set_delay(min_gap, gif_frames.Delay[i])
|
||||||
if frame.Delay_ms == 0 {
|
|
||||||
frame.Delay_ms = -1 // gapless frame
|
|
||||||
}
|
|
||||||
anchor_frame, frame.Compose_onto = SetGIFFrameDisposal(frame.Number, anchor_frame, gif_frames.Disposal[i])
|
anchor_frame, frame.Compose_onto = SetGIFFrameDisposal(frame.Number, anchor_frame, gif_frames.Disposal[i])
|
||||||
ans.Frames = append(ans.Frames, &frame)
|
ans.Frames = append(ans.Frames, &frame)
|
||||||
}
|
}
|
||||||
@ -109,11 +158,347 @@ func OpenNativeImageFromReader(f io.ReadSeeker) (ans *ImageData, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenMagickImageFromPath(path string) (ans *ImageData, err error) {
|
var MagickExe = (&utils.Once[string]{Run: func() string {
|
||||||
// TODO: Implement this
|
return utils.FindExe("magick")
|
||||||
|
}}).Get
|
||||||
|
|
||||||
|
func RunMagick(path string, cmd []string) ([]byte, error) {
|
||||||
|
if MagickExe() != "magick" {
|
||||||
|
cmd = append([]string{MagickExe()}, cmd...)
|
||||||
|
}
|
||||||
|
c := exec.Command(cmd[0], cmd[1:]...)
|
||||||
|
output, err := c.Output()
|
||||||
|
if err != nil {
|
||||||
|
var exit_err *exec.ExitError
|
||||||
|
if errors.As(err, &exit_err) {
|
||||||
|
return nil, fmt.Errorf("Running the command: %s\nFailed with error:\n%s", strings.Join(cmd, " "), string(exit_err.Stderr))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("Could not find the program: %#v. Is ImageMagick installed and in your PATH?", cmd[0])
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdentifyOutput struct {
|
||||||
|
Fmt, Canvas, Transparency, Gap, Index, Size, Dpi, Dispose, Orientation string
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdentifyRecord struct {
|
||||||
|
Fmt_uppercase string
|
||||||
|
Gap int
|
||||||
|
Canvas struct{ Width, Height, Left, Top int }
|
||||||
|
Width, Height int
|
||||||
|
Dpi struct{ X, Y float64 }
|
||||||
|
Index int
|
||||||
|
Mode graphics.GRT_f
|
||||||
|
Needs_blend bool
|
||||||
|
Disposal int
|
||||||
|
Dimensions_swapped bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func parse_identify_record(ans *IdentifyRecord, raw *IdentifyOutput) (err error) {
|
||||||
|
ans.Fmt_uppercase = strings.ToUpper(raw.Fmt)
|
||||||
|
if raw.Gap != "" {
|
||||||
|
ans.Gap, err = strconv.Atoi(raw.Gap)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Invalid gap value in identify output: %s", raw.Gap)
|
||||||
|
}
|
||||||
|
ans.Gap = utils.Max(0, ans.Gap)
|
||||||
|
}
|
||||||
|
area, pos, found := strings.Cut(raw.Canvas, "+")
|
||||||
|
ok := false
|
||||||
|
if found {
|
||||||
|
w, h, found := strings.Cut(area, "x")
|
||||||
|
if found {
|
||||||
|
ans.Canvas.Width, err = strconv.Atoi(w)
|
||||||
|
if err == nil {
|
||||||
|
ans.Canvas.Height, err = strconv.Atoi(h)
|
||||||
|
if err == nil {
|
||||||
|
x, y, found := strings.Cut(pos, "+")
|
||||||
|
if found {
|
||||||
|
ans.Canvas.Left, err = strconv.Atoi(x)
|
||||||
|
if err == nil {
|
||||||
|
ans.Canvas.Top, err = strconv.Atoi(y)
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("Invalid canvas value in identify output: %s", raw.Canvas)
|
||||||
|
}
|
||||||
|
w, h, found := strings.Cut(raw.Size, "x")
|
||||||
|
ok = false
|
||||||
|
if found {
|
||||||
|
ans.Width, err = strconv.Atoi(w)
|
||||||
|
if err == nil {
|
||||||
|
ans.Height, err = strconv.Atoi(h)
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("Invalid size value in identify output: %s", raw.Size)
|
||||||
|
}
|
||||||
|
x, y, found := strings.Cut(raw.Dpi, "x")
|
||||||
|
ok = false
|
||||||
|
if found {
|
||||||
|
ans.Dpi.X, err = strconv.ParseFloat(x, 64)
|
||||||
|
if err == nil {
|
||||||
|
ans.Dpi.Y, err = strconv.ParseFloat(y, 64)
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("Invalid dpi value in identify output: %s", raw.Dpi)
|
||||||
|
}
|
||||||
|
ans.Index, err = strconv.Atoi(raw.Index)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Invalid index value in identify output: %s", raw.Index)
|
||||||
|
}
|
||||||
|
q := strings.ToLower(raw.Transparency)
|
||||||
|
if q == "blend" || q == "true" {
|
||||||
|
ans.Mode = graphics.GRT_format_rgba
|
||||||
|
} else {
|
||||||
|
ans.Mode = graphics.GRT_format_rgb
|
||||||
|
}
|
||||||
|
ans.Needs_blend = q == "blend"
|
||||||
|
switch strings.ToLower(raw.Dispose) {
|
||||||
|
case "undefined":
|
||||||
|
ans.Disposal = 0
|
||||||
|
case "none":
|
||||||
|
ans.Disposal = gif.DisposalNone
|
||||||
|
case "background":
|
||||||
|
ans.Disposal = gif.DisposalBackground
|
||||||
|
case "previous":
|
||||||
|
ans.Disposal = gif.DisposalPrevious
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("Invalid value for dispose: %s", raw.Dispose)
|
||||||
|
}
|
||||||
|
switch raw.Orientation {
|
||||||
|
case "5", "6", "7", "8":
|
||||||
|
ans.Dimensions_swapped = true
|
||||||
|
}
|
||||||
|
if ans.Dimensions_swapped {
|
||||||
|
ans.Canvas.Width, ans.Canvas.Height = ans.Canvas.Height, ans.Canvas.Width
|
||||||
|
ans.Width, ans.Height = ans.Height, ans.Width
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IdentifyWithMagick(path string) (ans []IdentifyRecord, err error) {
|
||||||
|
cmd := []string{"identify"}
|
||||||
|
q := `{"fmt":"%m","canvas":"%g","transparency":"%A","gap":"%T","index":"%p","size":"%wx%h",` +
|
||||||
|
`"dpi":"%xx%y","dispose":"%D","orientation":"%[EXIF:Orientation]"},`
|
||||||
|
cmd = append(cmd, "-format", q, "--", path)
|
||||||
|
output, err := RunMagick(path, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
output = bytes.TrimRight(bytes.TrimSpace(output), ",")
|
||||||
|
raw_json := make([]byte, 0, len(output)+2)
|
||||||
|
raw_json = append(raw_json, '[')
|
||||||
|
raw_json = append(raw_json, output...)
|
||||||
|
raw_json = append(raw_json, ']')
|
||||||
|
var records []IdentifyOutput
|
||||||
|
err = json.Unmarshal(raw_json, &records)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("The ImageMagick identify program returned malformed output, with error: %w", err)
|
||||||
|
}
|
||||||
|
ans = make([]IdentifyRecord, len(records))
|
||||||
|
for i, rec := range records {
|
||||||
|
err = parse_identify_record(&ans[i], &rec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ans, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenderOptions struct {
|
||||||
|
RemoveAlpha *NRGBColor
|
||||||
|
Flip, Flop bool
|
||||||
|
ResizeTo image.Point
|
||||||
|
OnlyFirstFrame bool
|
||||||
|
TempfilenameTemplate string
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderWithMagick(path string, ro *RenderOptions, frames []IdentifyRecord) (ans []*ImageFrame, fmap map[int]string, err error) {
|
||||||
|
cmd := []string{"convert"}
|
||||||
|
ans = make([]*ImageFrame, 0, len(frames))
|
||||||
|
fmap = make(map[int]string, len(frames))
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
for _, f := range fmap {
|
||||||
|
os.Remove(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if ro.RemoveAlpha != nil {
|
||||||
|
cmd = append(cmd, "-background", ro.RemoveAlpha.AsSharp(), "-alpha", "remove")
|
||||||
|
} else {
|
||||||
|
cmd = append(cmd, "-background", "none")
|
||||||
|
}
|
||||||
|
if ro.Flip {
|
||||||
|
cmd = append(cmd, "-flip")
|
||||||
|
}
|
||||||
|
if ro.Flop {
|
||||||
|
cmd = append(cmd, "-flop")
|
||||||
|
}
|
||||||
|
cpath := path
|
||||||
|
if ro.OnlyFirstFrame {
|
||||||
|
cpath += "[0]"
|
||||||
|
}
|
||||||
|
has_multiple_frames := len(frames) > 1
|
||||||
|
get_multiple_frames := has_multiple_frames && !ro.OnlyFirstFrame
|
||||||
|
cmd = append(cmd, "--", cpath, "-auto-orient")
|
||||||
|
if ro.ResizeTo.X > 0 {
|
||||||
|
rcmd := []string{"-resize", fmt.Sprintf("%dx%d!", ro.ResizeTo.X, ro.ResizeTo.Y)}
|
||||||
|
if get_multiple_frames {
|
||||||
|
cmd = append(cmd, "-coalesce")
|
||||||
|
cmd = append(cmd, rcmd...)
|
||||||
|
cmd = append(cmd, "-deconstruct")
|
||||||
|
} else {
|
||||||
|
cmd = append(cmd, rcmd...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd = append(cmd, "-depth", "8", "-set", "filename:f", "%w-%h-%g-%p")
|
||||||
|
if get_multiple_frames {
|
||||||
|
cmd = append(cmd, "+adjoin")
|
||||||
|
}
|
||||||
|
tdir, err := MakeTempDir(ro.TempfilenameTemplate)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("Failed to create temporary directory to hold ImageMagick output with error: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tdir)
|
||||||
|
mode := "rgba"
|
||||||
|
if frames[0].Mode == graphics.GRT_format_rgb {
|
||||||
|
mode = "rgb"
|
||||||
|
}
|
||||||
|
cmd = append(cmd, filepath.Join(tdir, "im-%[filename:f]."+mode))
|
||||||
|
_, err = RunMagick(path, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(tdir)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("Failed to read temp dir used to store ImageMagick output with error: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base_dir := filepath.Dir(tdir)
|
||||||
|
gaps := make([]int, len(frames))
|
||||||
|
for i, frame := range frames {
|
||||||
|
gaps[i] = frame.Gap
|
||||||
|
}
|
||||||
|
min_gap := CalcMinimumGIFGap(gaps)
|
||||||
|
for _, entry := range entries {
|
||||||
|
fname := entry.Name()
|
||||||
|
p, _, _ := strings.Cut(fname, ".")
|
||||||
|
parts := strings.Split(p, "-")
|
||||||
|
if len(parts) < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
index, cerr := strconv.Atoi(parts[len(parts)-1])
|
||||||
|
if cerr != nil || index < 0 || index >= len(frames) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
width, cerr := strconv.Atoi(parts[1])
|
||||||
|
if cerr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
height, cerr := strconv.Atoi(parts[2])
|
||||||
|
if cerr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, pos, found := strings.Cut(parts[3], "+")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
px, py, found := strings.Cut(pos, "+")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x, cerr := strconv.Atoi(px)
|
||||||
|
if cerr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
y, cerr := strconv.Atoi(py)
|
||||||
|
if cerr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
identify_data := frames[index]
|
||||||
|
df, cerr := os.CreateTemp(base_dir, graphics.TempTemplate+"."+mode)
|
||||||
|
if cerr != nil {
|
||||||
|
err = fmt.Errorf("Failed to create a temporary file in %s with error: %w", base_dir, cerr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = os.Rename(filepath.Join(tdir, fname), df.Name())
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("Failed to rename a temporary file in %s with error: %w", tdir, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
df.Close()
|
||||||
|
fmap[index+1] = df.Name()
|
||||||
|
frame := ImageFrame{
|
||||||
|
Number: index + 1, Width: width, Height: height, Left: x, Top: y, Is_opaque: identify_data.Mode == graphics.GRT_format_rgb,
|
||||||
|
}
|
||||||
|
frame.set_delay(min_gap, identify_data.Gap)
|
||||||
|
err = check_resize(&frame, df.Name())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ans = append(ans, &frame)
|
||||||
|
}
|
||||||
|
if len(ans) < len(frames) {
|
||||||
|
err = fmt.Errorf("Failed to render %d out of %d frames", len(frames)-len(ans), len(frames))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slices.SortFunc(ans, func(a, b *ImageFrame) bool { return a.Number < b.Number })
|
||||||
|
anchor_frame := 1
|
||||||
|
for i, frame := range ans {
|
||||||
|
anchor_frame, frame.Compose_onto = SetGIFFrameDisposal(frame.Number, anchor_frame, byte(frames[i].Disposal))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenMagickImageFromPath(path string) (ans *ImageData, err error) {
|
||||||
|
identify_records, err := IdentifyWithMagick(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed to identify image at %#v with error: %w", path, err)
|
||||||
|
}
|
||||||
|
frames, filenames, err := RenderWithMagick(path, &RenderOptions{}, identify_records)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
for _, f := range filenames {
|
||||||
|
os.Remove(f)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for _, frame := range frames {
|
||||||
|
filename := filenames[frame.Number]
|
||||||
|
data, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dest_rect := image.Rect(0, 0, frame.Width, frame.Height)
|
||||||
|
if frame.Is_opaque {
|
||||||
|
frame.Img = &NRGB{Pix: data, Stride: frame.Width * 3, Rect: dest_rect}
|
||||||
|
} else {
|
||||||
|
frame.Img = &image.NRGBA{Pix: data, Stride: frame.Width * 4, Rect: dest_rect}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ans = &ImageData{
|
||||||
|
Width: frames[0].Width, Height: frames[0].Height, Format_uppercase: identify_records[0].Fmt_uppercase, Frames: frames,
|
||||||
|
}
|
||||||
|
return ans, nil
|
||||||
|
}
|
||||||
|
|
||||||
func OpenImageFromPath(path string) (ans *ImageData, err error) {
|
func OpenImageFromPath(path string) (ans *ImageData, err error) {
|
||||||
mt := utils.GuessMimeType(path)
|
mt := utils.GuessMimeType(path)
|
||||||
if DecodableImageTypes[mt] {
|
if DecodableImageTypes[mt] {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user