cchat-gtk/internal/gts/httputil/image.go

237 lines
5.4 KiB
Go

package httputil
import (
"bufio"
"context"
"io"
"mime"
"net/http"
"net/url"
"path"
"strings"
"sync"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/cairo"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
// bufferPool provides a sync.Pool of *bufio.Writers. This is used to reduce the
// amount of cgo calls, by writing bytes in larger chunks.
//
// Technically, httpcache already wraps its cached reader around a bufio.Reader,
// but we have no control over the buffer size.
var bufferPool = sync.Pool{
New: func() interface{} {
// Allocate a 512KB buffer by default.
const defaultBufSz = 512 * 1024
return bufio.NewWriterSize(nil, defaultBufSz)
},
}
func bufferedWriter(w io.Writer) *bufio.Writer {
buf := bufferPool.Get().(*bufio.Writer)
buf.Reset(w)
return buf
}
func returnBufferedWriter(buf *bufio.Writer) {
// Unreference the internal reader.
buf.Reset(nil)
bufferPool.Put(buf)
}
type ImageContainer interface {
primitives.Connector
SetFromPixbuf(*gdk.Pixbuf)
SetFromAnimation(*gdk.PixbufAnimation)
GetSizeRequest() (w, h int)
}
type SurfaceContainer interface {
ImageContainer
GetScaleFactor() int
SetFromSurface(*cairo.Surface)
}
var (
_ ImageContainer = (*gtk.Image)(nil)
_ SurfaceContainer = (*gtk.Image)(nil)
)
type surfaceWrapper struct {
SurfaceContainer
scale int
}
func (wrapper *surfaceWrapper) SetFromPixbuf(pb *gdk.Pixbuf) {
surface, _ := gdk.CairoSurfaceCreateFromPixbuf(pb, wrapper.scale, nil)
wrapper.SetFromSurface(surface)
}
// AsyncImage loads an image. This method uses the cache. It prefers loading
// SetFromSurface over SetFromPixbuf, but will fallback if needed be.
func AsyncImage(ctx context.Context,
img ImageContainer, imageURL string, procs ...imgutil.Processor) {
if imageURL == "" {
return
}
w, h := img.GetSizeRequest()
scale := 1
surfaceContainer, canSurface := img.(SurfaceContainer)
if canSurface {
scale = surfaceContainer.GetScaleFactor()
}
ctx = primitives.HandleDestroyCtx(ctx, img)
go func() {
// Try and guess the MIME type from the URL.
mimeType := mime.TypeByExtension(urlExt(imageURL))
r, err := get(ctx, imageURL, true)
if err != nil {
log.Error(errors.Wrap(err, "failed to GET"))
return
}
defer r.Body.Close()
// Try and use the image type from the MIME header over the type from
// the URL, as it is more reliable.
if mime := mimeFromHeaders(r.Header); mime != "" {
mimeType = mime
}
_, fileType := path.Split(mimeType) // abuse split "a/b" to get b
isGIF := fileType == "gif"
if isGIF {
canSurface = false
scale = 1
}
// Only bother with this if we even have HiDPI. We also can't use a
// Surface for a GIF.
if canSurface && scale > 1 {
img = &surfaceWrapper{surfaceContainer, scale}
}
l, err := gdk.PixbufLoaderNewWithType(fileType)
if err != nil {
log.Error(errors.Wrapf(err, "failed to make PixbufLoader type %q", fileType))
return
}
l.Connect("size-prepared", func(l *gdk.PixbufLoader, imgW, imgH int) {
w, h = imgutil.MaxSize(imgW, imgH, w, h)
if w != imgW || h != imgH || scale > 1 {
l.SetSize(w*scale, h*scale)
}
})
load := loadFn(ctx, img, isGIF)
l.Connect("area-prepared", load)
l.Connect("area-updated", load)
// Borrow a buffered writer and return it at the end.
bufWriter := bufferedWriter(l)
defer returnBufferedWriter(bufWriter)
if err := downloadImage(r.Body, bufWriter, procs, isGIF); err != nil {
log.Error(errors.Wrapf(err, "failed to download %q", imageURL))
// Force close after downloading.
}
if err := bufWriter.Flush(); err != nil {
log.Error(errors.Wrapf(err, "failed to flush writer for %q", imageURL))
// Force close after downloading.
}
if err := l.Close(); err != nil {
log.Error(errors.Wrapf(err, "failed to close pixbuf loader for %q", imageURL))
}
}()
}
func urlExt(anyURL string) string {
u, err := url.Parse(anyURL)
if err != nil {
return path.Ext(strings.SplitN(anyURL, "?", 1)[0])
}
return path.Ext(u.Path)
}
func mimeFromHeaders(headers http.Header) string {
cType := headers.Get("Content-Type")
if cType == "" {
return ""
}
media, _, err := mime.ParseMediaType(cType)
if err != nil {
return ""
}
return media
}
func loadFn(ctx context.Context, img ImageContainer, isGIF bool) func(l *gdk.PixbufLoader) {
var pixbuf interface{}
return func(l *gdk.PixbufLoader) {
if pixbuf == nil {
if !isGIF {
pixbuf, _ = l.GetPixbuf()
} else {
pixbuf, _ = l.GetAnimation()
}
}
switch pixbuf := pixbuf.(type) {
case *gdk.Pixbuf:
execIfCtx(ctx, func() { img.SetFromPixbuf(pixbuf) })
case *gdk.PixbufAnimation:
execIfCtx(ctx, func() { img.SetFromAnimation(pixbuf) })
}
}
}
func execIfCtx(ctx context.Context, fn func()) {
gts.ExecLater(func() {
if ctx.Err() == nil {
fn()
}
})
}
func downloadImage(src io.Reader, dst io.Writer, p []imgutil.Processor, isGIF bool) error {
var err error
// If we have processors, then write directly in there.
if len(p) > 0 {
if !isGIF {
err = imgutil.ProcessStream(dst, src, p)
} else {
err = imgutil.ProcessAnimationStream(dst, src, p)
}
} else {
// Else, directly copy the body over.
_, err = io.Copy(dst, src)
}
if err != nil {
return errors.Wrap(err, "failed to process image")
}
return nil
}