From fce722555a405f21bd85cb19f85e1627d94ba7b7 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Sun, 16 Mar 2025 12:56:35 -0700 Subject: [PATCH] Add fallback adaptors for decoding images --- internal/fetch/fetch.go | 17 +++----- internal/image/adaptor.go | 86 +++++++++++++++++++++++++++++++++++++++ internal/image/image.go | 21 +++++++++- 3 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 internal/image/adaptor.go diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index 289fe73..c58abfa 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -156,10 +156,10 @@ func fetch(ctx context.Context, r *Request) (int, error) { req = req.WithContext(ctx) } - return makeRequest(r, c, req) + return makeRequest(ctx, r, c, req) } -func makeRequest(r *Request, c *client.Client, req *http.Request) (int, error) { +func makeRequest(ctx context.Context, r *Request, c *client.Client, req *http.Request) (int, error) { resp, err := c.Do(req) if err != nil { return 0, err @@ -177,7 +177,7 @@ func makeRequest(r *Request, c *client.Client, req *http.Request) (int, error) { p.Flush() } - body, err := formatResponse(r, resp, r.PrinterHandle.Stdout()) + body, err := formatResponse(ctx, r, resp, r.PrinterHandle.Stdout()) if err != nil { return 0, err } @@ -193,7 +193,7 @@ func makeRequest(r *Request, c *client.Client, req *http.Request) (int, error) { return exitCode, nil } -func formatResponse(r *Request, resp *http.Response, p *core.Printer) (io.Reader, error) { +func formatResponse(ctx context.Context, r *Request, resp *http.Response, p *core.Printer) (io.Reader, error) { if r.Output != "" && r.Output != "-" { f, err := os.Create(r.Output) if err != nil { @@ -251,7 +251,7 @@ func formatResponse(r *Request, resp *http.Response, p *core.Printer) (io.Reader switch contentType { case TypeImage: - return nil, image.Render(buf) + return nil, image.Render(ctx, buf) case TypeJSON: if format.FormatJSON(buf, p) == nil { buf = p.Bytes() @@ -278,12 +278,7 @@ func getContentType(headers http.Header) ContentType { if typ, subtype, ok := strings.Cut(mediaType, "/"); ok { switch typ { case "image": - switch subtype { - case "jpeg", "png", "tiff", "webp": - return TypeImage - default: - return TypeUnknown - } + return TypeImage case "application": switch subtype { case "json": diff --git a/internal/image/adaptor.go b/internal/image/adaptor.go new file mode 100644 index 0000000..f2f25df --- /dev/null +++ b/internal/image/adaptor.go @@ -0,0 +1,86 @@ +package image + +import ( + "bytes" + "context" + "errors" + "image" + "os" + "os/exec" + "path/filepath" + "slices" +) + +const imagePathArg = "IMAGE_PATH" + +type adaptor struct { + name string + args []string +} + +var adaptors = []adaptor{ + { + name: "vips", + args: []string{"copy", imagePathArg, ".jpeg"}, + }, + { + name: "magick", + args: []string{imagePathArg, "-flatten", "-auto-orient", "jpeg:-"}, + }, + { + name: "ffmpeg", + args: []string{"-i", imagePathArg, "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1"}, + }, +} + +func decodeWithAdaptors(ctx context.Context, b []byte) (image.Image, error) { + // Write the image to a temporary file. + dir, err := os.MkdirTemp("", "") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + + imgPath := filepath.Join(dir, "fetch-temp-image") + err = os.WriteFile(imgPath, b, 0666) + if err != nil { + return nil, err + } + + // Attempt each adaptor, stopping at the first successful one. + for _, a := range adaptors { + img, err := decodeAdaptor(ctx, imgPath, a.name, a.args) + if err == nil { + return img, nil + } + } + return nil, errors.New("unable to decode image") +} + +func decodeAdaptor(ctx context.Context, imgPath, name string, args []string) (image.Image, error) { + path, err := exec.LookPath(name) + if err != nil { + // Adaptor not found locally, exit. + return nil, err + } + + // Replace "IMAGE_PATH" argument with the actual image path. + args = slices.Clone(args) + for i, arg := range args { + if arg == imagePathArg { + args[i] = imgPath + } + } + + // Run the command, collecting the result on stdout. + var stdout bytes.Buffer + cmd := exec.CommandContext(ctx, path, args...) + cmd.Stdout = &stdout + if err = cmd.Run(); err != nil { + return nil, err + } + + // Attempt to decode the adaptor output. + img, _, err := image.Decode(&stdout) + return img, err +} diff --git a/internal/image/image.go b/internal/image/image.go index 14fd57d..e560273 100644 --- a/internal/image/image.go +++ b/internal/image/image.go @@ -2,6 +2,7 @@ package image import ( "bytes" + "context" "encoding/base64" "image" _ "image/jpeg" @@ -15,8 +16,8 @@ import ( // Render renders the provided raw image to stdout based on what protocol the // current terminal emulator supports. -func Render(b []byte) error { - img, _, err := image.Decode(bytes.NewReader(b)) +func Render(ctx context.Context, b []byte) error { + img, err := decodeImage(ctx, b) if err != nil { return err } @@ -48,6 +49,22 @@ func Render(b []byte) error { } } +func decodeImage(ctx context.Context, b []byte) (image.Image, error) { + img, _, err := image.Decode(bytes.NewReader(b)) + if err == nil { + return img, nil + } + + // Unable to decode the image ourselves, attempt with the adaptors. + var errAdaptor error + img, errAdaptor = decodeWithAdaptors(ctx, b) + if errAdaptor == nil { + return img, nil + } + + return nil, err +} + // resizeForTerm returns a new image that has been resized to fit in less than // 80% of the terminal height. func resizeForTerm(img image.Image, termWidthPx, termHeightPx int) image.Image {