mirror of
https://github.com/kovidgoyal/kitty
synced 2026-07-19 23:14:55 +02:00
Switch over to the new imaging backend for icat
Greatly simplifies a whole bunch of code. The new backend takes care of falling back to ImageMagick efficiently itself.
This commit is contained in:
@@ -3,24 +3,14 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/kovidgoyal/imaging/nrgb"
|
||||
"github.com/kovidgoyal/imaging/prism/meta/gifmeta"
|
||||
"github.com/kovidgoyal/kitty/tools/utils"
|
||||
"github.com/kovidgoyal/kitty/tools/utils/shm"
|
||||
|
||||
@@ -78,71 +68,16 @@ func (s *ImageFrame) Serialize() SerializableImageFrame {
|
||||
}
|
||||
|
||||
func (self *ImageFrame) DataAsSHM(pattern string) (ans shm.MMap, err error) {
|
||||
bytes_per_pixel := 4
|
||||
if self.Is_opaque {
|
||||
bytes_per_pixel = 3
|
||||
}
|
||||
ans, err = shm.CreateTemp(pattern, uint64(self.Width*self.Height*bytes_per_pixel))
|
||||
if err != nil {
|
||||
d := self.Data()
|
||||
if ans, err = shm.CreateTemp(pattern, uint64(len(d))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch img := self.Img.(type) {
|
||||
case *imaging.NRGB:
|
||||
if bytes_per_pixel == 3 {
|
||||
copy(ans.Slice(), img.Pix)
|
||||
return
|
||||
}
|
||||
case *image.NRGBA:
|
||||
if bytes_per_pixel == 4 {
|
||||
copy(ans.Slice(), img.Pix)
|
||||
return
|
||||
}
|
||||
}
|
||||
dest_rect := image.Rect(0, 0, self.Width, self.Height)
|
||||
var final_img image.Image
|
||||
switch bytes_per_pixel {
|
||||
case 3:
|
||||
rgb := &imaging.NRGB{Pix: ans.Slice(), Stride: bytes_per_pixel * self.Width, Rect: dest_rect}
|
||||
final_img = rgb
|
||||
case 4:
|
||||
rgba := &image.NRGBA{Pix: ans.Slice(), Stride: bytes_per_pixel * self.Width, Rect: dest_rect}
|
||||
final_img = rgba
|
||||
}
|
||||
ctx := Context{}
|
||||
ctx.PasteCenter(final_img, self.Img, nil)
|
||||
copy(ans.Slice(), d)
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
func (self *ImageFrame) Data() (ans []byte) {
|
||||
bytes_per_pixel := 4
|
||||
if self.Is_opaque {
|
||||
bytes_per_pixel = 3
|
||||
}
|
||||
switch img := self.Img.(type) {
|
||||
case *imaging.NRGB:
|
||||
if bytes_per_pixel == 3 {
|
||||
return img.Pix
|
||||
}
|
||||
case *image.NRGBA:
|
||||
if bytes_per_pixel == 4 {
|
||||
return img.Pix
|
||||
}
|
||||
}
|
||||
dest_rect := image.Rect(0, 0, self.Width, self.Height)
|
||||
var final_img image.Image
|
||||
switch bytes_per_pixel {
|
||||
case 3:
|
||||
rgb := nrgb.NewNRGB(dest_rect)
|
||||
final_img = rgb
|
||||
ans = rgb.Pix
|
||||
case 4:
|
||||
rgba := image.NewNRGBA(dest_rect)
|
||||
final_img = rgba
|
||||
ans = rgba.Pix
|
||||
}
|
||||
ctx := Context{}
|
||||
ctx.PasteCenter(final_img, self.Img, nil)
|
||||
_, ans = imaging.AsRGBData8(self.Img)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -257,15 +192,14 @@ func MakeTempDir(template string) (ans string, err error) {
|
||||
return os.MkdirTemp("", template)
|
||||
}
|
||||
|
||||
// Native {{{
|
||||
func OpenNativeImageFromReader(f io.ReadSeeker) (ans *ImageData, err error) {
|
||||
ic, _, err := imaging.DecodeAll(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func NewImageData(ic *imaging.Image) (ans *ImageData) {
|
||||
b := ic.Bounds()
|
||||
ans = &ImageData{
|
||||
Width: b.Dx(), Height: b.Dy(),
|
||||
}
|
||||
if ic.Metadata != nil {
|
||||
ans.Format_uppercase = strings.ToUpper(ic.Metadata.Format.String())
|
||||
}
|
||||
_, _ = f.Seek(0, io.SeekStart)
|
||||
|
||||
ans = &ImageData{Width: int(ic.Metadata.PixelWidth), Height: int(ic.Metadata.PixelHeight), Format_uppercase: strings.ToUpper(ic.Metadata.Format.String())}
|
||||
|
||||
for _, f := range ic.Frames {
|
||||
fr := ImageFrame{
|
||||
@@ -281,413 +215,18 @@ func OpenNativeImageFromReader(f io.ReadSeeker) (ans *ImageData, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// }}}
|
||||
|
||||
// ImageMagick {{{
|
||||
var MagickExe = sync.OnceValue(func() string {
|
||||
return utils.FindExe("magick")
|
||||
})
|
||||
|
||||
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)
|
||||
func OpenImageFromPath(path string, opts ...imaging.DecodeOption) (ans *ImageData, err error) {
|
||||
ic, err := imaging.OpenAll(path, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, 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 {
|
||||
if bytes_per_pixel == 4 && sz == 3*frame.Width*frame.Height {
|
||||
frame.Is_opaque = true
|
||||
return nil
|
||||
}
|
||||
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 frame-number: %d)", sz, expected_size, frame.Width, frame.Height, bytes_per_pixel, frame.Number)
|
||||
}
|
||||
frame.Height -= missing / (bytes_per_pixel * frame.Width)
|
||||
}
|
||||
return nil
|
||||
return NewImageData(ic), nil
|
||||
}
|
||||
|
||||
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()
|
||||
func OpenImageFromReader(r io.Reader, opts ...imaging.DecodeOption) (ans *ImageData, s io.Reader, err error) {
|
||||
ic, s, err := imaging.DecodeAll(r, opts...)
|
||||
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 nil, nil, err
|
||||
}
|
||||
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
|
||||
Is_opaque bool
|
||||
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 = 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 {
|
||||
if ans.Canvas.Top, err = strconv.Atoi(y); err == nil {
|
||||
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 {
|
||||
if ans.Height, err = strconv.Atoi(h); err == nil {
|
||||
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 {
|
||||
if ans.Dpi.Y, err = strconv.ParseFloat(y, 64); err == nil {
|
||||
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.Is_opaque = false
|
||||
} else {
|
||||
ans.Is_opaque = true
|
||||
}
|
||||
ans.Needs_blend = q == "blend"
|
||||
switch strings.ToLower(raw.Dispose) {
|
||||
case "none", "undefined":
|
||||
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
|
||||
}
|
||||
|
||||
func IdentifyWithMagick(path, original_file_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]"},`
|
||||
ext := filepath.Ext(original_file_path)
|
||||
ipath := path
|
||||
if strings.ToLower(ext) == ".apng" {
|
||||
ipath = "APNG:" + path
|
||||
}
|
||||
cmd = append(cmd, "-format", q, "--", ipath)
|
||||
output, err := RunMagick(path, cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to identify image at path: %s with error: %w", path, 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 for the image at path: %s, with error: %w", path, 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 *imaging.NRGBColor
|
||||
Flip, Flop bool
|
||||
ResizeTo image.Point
|
||||
OnlyFirstFrame bool
|
||||
TempfilenameTemplate string
|
||||
}
|
||||
|
||||
func RenderWithMagick(path, original_file_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))
|
||||
ext := filepath.Ext(original_file_path)
|
||||
ipath := path
|
||||
if strings.ToLower(ext) == ".apng" {
|
||||
ipath = "APNG:" + path
|
||||
}
|
||||
|
||||
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 := ipath
|
||||
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].Is_opaque {
|
||||
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
|
||||
}
|
||||
// although ImageMagick *might* be already taking care of this adjustment,
|
||||
// I dont know for sure, so do it anyway.
|
||||
min_gap := gifmeta.CalcMinimumGap(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, 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.Is_opaque,
|
||||
}
|
||||
frame.Delay_ms = int32(max(min_gap, identify_data.Gap) * 10)
|
||||
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) int { return a.Number - b.Number })
|
||||
prev_disposal := gif.DisposalBackground
|
||||
prev_compose_onto := 0
|
||||
for i, frame := range ans {
|
||||
switch prev_disposal {
|
||||
case gif.DisposalNone:
|
||||
frame.Compose_onto = frame.Number - 1
|
||||
case gif.DisposalPrevious:
|
||||
frame.Compose_onto = prev_compose_onto
|
||||
}
|
||||
prev_disposal, prev_compose_onto = frames[i].Disposal, frame.Compose_onto
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func OpenImageFromPathWithMagick(path string) (ans *ImageData, err error) {
|
||||
identify_records, err := IdentifyWithMagick(path, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to identify image at %#v with error: %w", path, err)
|
||||
}
|
||||
frames, filenames, err := RenderWithMagick(path, path, &RenderOptions{}, identify_records)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to render image at %#v with error: %w", path, 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, fmt.Errorf("Failed to read temp file for image %#v at %#v with error: %w", path, filename, err)
|
||||
}
|
||||
dest_rect := image.Rect(0, 0, frame.Width, frame.Height)
|
||||
if frame.Is_opaque {
|
||||
frame.Img = &imaging.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) {
|
||||
mt := utils.GuessMimeType(path)
|
||||
if DecodableImageTypes[mt] {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
ans, err = OpenNativeImageFromReader(f)
|
||||
if err != nil {
|
||||
return OpenImageFromPathWithMagick(path)
|
||||
}
|
||||
} else {
|
||||
return OpenImageFromPathWithMagick(path)
|
||||
}
|
||||
return
|
||||
return NewImageData(ic), s, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user