From c317c934f3ab363bd196e8251fddd2b2310032ab Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 3 Jan 2023 21:14:29 +0530 Subject: [PATCH] More work on ImageMagick --- kittens/icat/main.py | 12 +- tools/cmd/icat/magick.go | 335 ++++++++++++++++++++++++++++++- tools/cmd/icat/main.go | 5 +- tools/cmd/icat/native.go | 58 ++++-- tools/cmd/icat/process_images.go | 65 ++++-- tools/tui/graphics/command.go | 6 +- tools/utils/images/to_rgb.go | 4 + 7 files changed, 442 insertions(+), 43 deletions(-) diff --git a/kittens/icat/main.py b/kittens/icat/main.py index 1a0bb1144..f4cc8d7ef 100644 --- a/kittens/icat/main.py +++ b/kittens/icat/main.py @@ -120,7 +120,17 @@ --silent type=bool-set -Do not print out anything to STDOUT during operation. +Not used, present for legacy compatibility. + + +--engine +type=choices +choices=auto,builtin,magick +default=auto +The engine used for decoding and processing of images. The default is to use +the most appropriate engine. The :code:`builtin` engine uses Go's native +imaging libraries. The :code:`magick` engine uses ImageMagick which requires +it to be installed on the system. --z-index -z diff --git a/tools/cmd/icat/magick.go b/tools/cmd/icat/magick.go index 40c8bf8d2..d69a2cc0d 100644 --- a/tools/cmd/icat/magick.go +++ b/tools/cmd/icat/magick.go @@ -3,11 +3,344 @@ package icat import ( + "bytes" + "encoding/json" + "errors" "fmt" + "image" + "image/gif" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + + "kitty/tools/tui/graphics" + "kitty/tools/utils" + "kitty/tools/utils/images" + "kitty/tools/utils/shm" ) var _ = fmt.Print -func render_image_with_magick(imgd *image_data, src *opened_input) error { +var find_exe_lock sync.Once +var magick_exe string = "" + +func find_magick_exe() { + magick_exe = utils.Which("magick") +} + +func run_magick(path string, cmd []string) ([]byte, error) { + 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 { + 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 := utils.Cut(raw.Canvas, "+") + ok := false + if found { + w, h, found := utils.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 := utils.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 := utils.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 := utils.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) { + find_exe_lock.Do(find_magick_exe) + cmd := []string{"identify"} + if magick_exe != "" { + cmd = []string{magick_exe, 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 Render(path string, ro *RenderOptions, frames []IdentifyRecord) (ans []*image_frame, err error) { + find_exe_lock.Do(find_magick_exe) + cmd := []string{"convert"} + if magick_exe != "" { + cmd = []string{magick_exe, cmd[0]} + } + ans = make([]*image_frame, 0, len(frames)) + 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 { + return nil, fmt.Errorf("Failed to create temporary directory to hold ImageMagick output with error: %w", err) + } + 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 { + return nil, fmt.Errorf("Failed to read temp dir used to store ImageMagick output with error: %w", err) + } + base_dir := filepath.Dir(tdir) + defer func() { + if err != nil && ans != nil { + for _, frame := range ans { + if frame.filename_is_temporary { + os.Remove(frame.filename) + } + } + ans = nil + } + }() + 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, _, _ := utils.Cut(fname, ".") + parts := strings.Split(p, "-") + if len(parts) < 1 { + continue + } + index, cerr := strconv.Atoi(parts[len(parts)-1]) + if cerr != nil || index < 0 || index >= len(frames) { + continue + } + identify_data := frames[index] + df, err := os.CreateTemp(base_dir, graphics.TempTemplate+"."+mode) + if err != nil { + return nil, fmt.Errorf("Failed to create a temporary file in %s with error: %w", base_dir, err) + } + err = os.Rename(filepath.Join(tdir, fname), df.Name()) + if err != nil { + return nil, fmt.Errorf("Failed to rename a temporary file in %s with error: %w", tdir, err) + } + df.Close() + frame := image_frame{ + number: index + 1, width: identify_data.Width, height: identify_data.Height, + left: identify_data.Canvas.Left, top: identify_data.Canvas.Top, + transmission_format: identify_data.Mode, filename_is_temporary: true, + filename: df.Name(), + } + frame.set_delay(identify_data.Gap, min_gap) + ans = append(ans, &frame) + } + if len(ans) < len(frames) { + return nil, fmt.Errorf("Failed to render %d out of %d frames", len(frames)-len(ans), len(frames)) + } + 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) { + err = src.PutOnFilesystem() + if err != nil { + return err + } + frames, err := Identify(src.FileSystemName()) + if err != nil { + return err + } + imgd.format_uppercase = frames[0].FmtUppercase + imgd.canvas_width, imgd.canvas_height = frames[0].Canvas.Width, frames[0].Canvas.Height + set_basic_metadata(imgd) + if !imgd.needs_conversion { + make_output_from_input(imgd, src) + return nil + } + ro := RenderOptions{RemoveAlpha: remove_alpha, Flip: flip, Flop: flop} + if scale_image(imgd) { + ro.ResizeTo.X, ro.ResizeTo.Y = imgd.canvas_width, imgd.canvas_height + } + imgd.frames, err = Render(src.FileSystemName(), &ro, frames) + if err != nil { + return err + } return nil } diff --git a/tools/cmd/icat/main.go b/tools/cmd/icat/main.go index b8255ecb7..b3beceed3 100644 --- a/tools/cmd/icat/main.go +++ b/tools/cmd/icat/main.go @@ -134,8 +134,9 @@ func print_error(format string, args ...any) { } else { lp.QueueWriteString("\r") lp.ClearToEndOfLine() - lp.QueueWriteString(fmt.Sprintf(format, args...)) - lp.QueueWriteString("\r\n") + for _, line := range utils.Splitlines(fmt.Sprintf(format, args...)) { + lp.Println(line) + } } } diff --git a/tools/cmd/icat/native.go b/tools/cmd/icat/native.go index f041af27a..92e8fcf0f 100644 --- a/tools/cmd/icat/native.go +++ b/tools/cmd/icat/native.go @@ -27,6 +27,8 @@ func resize_frame(imgd *image_data, img image.Image) (image.Image, image.Rectang return img, image.Rect(newleft, newtop, newleft+new_width, newtop+new_height) } +const shm_template = "kitty-icat-*" + func add_frame(ctx *images.Context, imgd *image_data, img image.Image) *image_frame { is_opaque := false if imgd.format_uppercase == "JPEG" { @@ -42,7 +44,6 @@ func add_frame(ctx *images.Context, imgd *image_data, img image.Image) *image_fr f := image_frame{width: b.Dx(), height: b.Dy(), number: len(imgd.frames) + 1, left: b.Min.X, top: b.Min.Y} dest_rect := image.Rect(0, 0, f.width, f.height) var final_img image.Image - const shm_template = "kitty-icat-*" bytes_per_pixel := 4 if is_opaque || remove_alpha != nil { @@ -88,7 +89,7 @@ func add_frame(ctx *images.Context, imgd *image_data, img image.Image) *image_fr return &f } -func scale_image(imgd *image_data) { +func scale_image(imgd *image_data) bool { if imgd.needs_scaling { width, height := imgd.canvas_width, imgd.canvas_height if imgd.canvas_width < imgd.available_width && opts.ScaleUp && place != nil { @@ -101,7 +102,9 @@ func scale_image(imgd *image_data) { imgd.scaled_frac.y = float64(newh) / float64(height) imgd.canvas_width = int(imgd.scaled_frac.x * float64(width)) imgd.canvas_height = int(imgd.scaled_frac.y * float64(height)) + return true } + return false } func load_one_frame_image(ctx *images.Context, imgd *image_data, src *opened_input) (img image.Image, err error) { @@ -118,38 +121,51 @@ func load_one_frame_image(ctx *images.Context, imgd *image_data, src *opened_inp return } -func add_gif_frames(ctx *images.Context, imgd *image_data, gf *gif.GIF) error { +func calc_min_gap(gaps []int) int { // Some broken GIF images have all zero gaps, browsers with their usual // idiot ideas render these with a default 100ms gap https://bugzilla.mozilla.org/show_bug.cgi?id=125137 // Browsers actually force a 100ms gap at any zero gap frame, but that // just means it is impossible to deliberately use zero gap frames for // sophisticated blending, so we dont do that. - max_gap := utils.Max(0, gf.Delay...) + max_gap := utils.Max(0, gaps...) min_gap := 0 if max_gap <= 0 { min_gap = 10 } - min_gap *= 1 + return min_gap +} + +func (frame *image_frame) set_disposal(anchor_frame int, disposal byte) int { + if frame.number > 1 { + switch disposal { + case gif.DisposalNone: + frame.compose_onto = frame.number - 1 + anchor_frame = frame.number + case gif.DisposalBackground: + // see https://github.com/golang/go/issues/20694 + anchor_frame = frame.number + case gif.DisposalPrevious: + frame.compose_onto = anchor_frame + } + } + return anchor_frame +} + +func (frame *image_frame) set_delay(gap, min_gap int) { + frame.delay_ms = utils.Max(min_gap, gap) * 10 + if frame.delay_ms == 0 { + frame.delay_ms = -1 + } +} + +func add_gif_frames(ctx *images.Context, imgd *image_data, gf *gif.GIF) error { + min_gap := calc_min_gap(gf.Delay) scale_image(imgd) anchor_frame := 1 for i, paletted_img := range gf.Image { frame := add_frame(ctx, imgd, paletted_img) - frame.delay_ms = utils.Max(min_gap, gf.Delay[i]) * 10 - if frame.delay_ms == 0 { - frame.delay_ms = -1 - } - if i > 0 { - switch gf.Disposal[i] { - case gif.DisposalNone: - frame.compose_onto = frame.number - 1 - anchor_frame = frame.number - case gif.DisposalBackground: - // see https://github.com/golang/go/issues/20694 - anchor_frame = frame.number - case gif.DisposalPrevious: - frame.compose_onto = anchor_frame - } - } + frame.set_delay(gf.Delay[i], min_gap) + anchor_frame = frame.set_disposal(anchor_frame, gf.Disposal[i]) } return nil } diff --git a/tools/cmd/icat/process_images.go b/tools/cmd/icat/process_images.go index f1f49eacf..c2c052663 100644 --- a/tools/cmd/icat/process_images.go +++ b/tools/cmd/icat/process_images.go @@ -138,6 +138,28 @@ func (self *opened_input) Release() { } } +func (self *opened_input) PutOnFilesystem() (err error) { + if self.name_to_unlink != "" { + return + } + f, err := graphics.CreateTempInRAM() + if err != nil { + return fmt.Errorf("Failed to create a temporary file to store input data with error: %w", err) + } + self.Rewind() + _, err = io.Copy(f, self.file) + if err != nil { + f.Close() + return fmt.Errorf("Failed to copy input data to temporary file with error: %w", err) + } + self.Release() + self.file = f + self.name_to_unlink = f.Name() + return +} + +func (self *opened_input) FileSystemName() string { return self.name_to_unlink } + type image_frame struct { filename string shm shm.MMap @@ -250,31 +272,42 @@ func process_arg(arg input_arg) { f.file = q } defer f.Release() - c, format, err := image.DecodeConfig(f.file) - f.Rewind() + can_use_go := false + var c image.Config + var format string + var err error imgd := image_data{source_name: arg.value} + if opts.Engine == "auto" || opts.Engine == "native" { + c, format, err = image.DecodeConfig(f.file) + f.Rewind() + can_use_go = err == nil + } if !keep_going.Load() { return } - if err != nil { + if can_use_go { + imgd.canvas_width = c.Width + imgd.canvas_height = c.Height + imgd.format_uppercase = strings.ToUpper(format) + set_basic_metadata(&imgd) + if !imgd.needs_conversion { + make_output_from_input(&imgd, &f) + send_output(&imgd) + return + } + err = render_image_with_go(&imgd, &f) + if err != nil { + report_error(arg.value, "Could not render image to RGB", err) + return + } + } else { err = render_image_with_magick(&imgd, &f) if err != nil { report_error(arg.value, "ImageMagick failed", err) + return } - return } - imgd.canvas_width = c.Width - imgd.canvas_height = c.Height - imgd.format_uppercase = strings.ToUpper(format) - set_basic_metadata(&imgd) - if !imgd.needs_conversion { - make_output_from_input(&imgd, &f) - send_output(&imgd) - return - } - err = render_image_with_go(&imgd, &f) - if err != nil { - report_error(arg.value, "Could not render image to RGB", err) + if !keep_going.Load() { return } send_output(&imgd) diff --git a/tools/tui/graphics/command.go b/tools/tui/graphics/command.go index b72009a7d..56ff52115 100644 --- a/tools/tui/graphics/command.go +++ b/tools/tui/graphics/command.go @@ -19,13 +19,15 @@ import ( var _ = fmt.Print +const TempTemplate = "kitty-tty-graphics-protocol-*" + func CreateTemp() (*os.File, error) { - return os.CreateTemp("", "tty-graphics-protocol-*") + return os.CreateTemp("", TempTemplate) } func CreateTempInRAM() (*os.File, error) { if shm.SHM_DIR != "" { - f, err := os.CreateTemp(shm.SHM_DIR, "tty-graphics-protocol-*") + f, err := os.CreateTemp(shm.SHM_DIR, TempTemplate) if err == nil { return f, err } diff --git a/tools/utils/images/to_rgb.go b/tools/utils/images/to_rgb.go index b8f1e85f2..df15544d0 100644 --- a/tools/utils/images/to_rgb.go +++ b/tools/utils/images/to_rgb.go @@ -14,6 +14,10 @@ type NRGBColor struct { R, G, B uint8 } +func (c NRGBColor) AsSharp() string { + return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) +} + func (c NRGBColor) RGBA() (r, g, b, a uint32) { r = uint32(c.R) r |= r << 8