giffer

Create .gif images from sites like youtube.com
Log | Files | Refs | LICENSE

commit 6f3a6ec2c0ad25d67bdcbe0d997faedc8d288f50
parent d5920d8e1ac804d553c386cedc0800aa1a2a7ba9
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date:   Fri,  9 Nov 2018 13:39:01 +1300

[~] Persist the produced gif images to a Store.

Diffstat:
Mcmd/desktop/giffer.go | 55+++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcmd/desktop/main.go | 13+++++++++----
Mcmd/desktop/server.go | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Acmd/desktop/store.go | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 332 insertions(+), 49 deletions(-)

diff --git a/cmd/desktop/giffer.go b/cmd/desktop/giffer.go @@ -2,13 +2,16 @@ package main import ( "bytes" + "fmt" "image" "image/gif" + "io" "log" "path/filepath" "strings" "sync" + "github.com/OneOfOne/xxhash" "github.com/disintegration/imaging" "github.com/jackmordaunt/giffer" "github.com/pkg/errors" @@ -18,6 +21,13 @@ import ( type Giffer struct { *giffer.Downloader *giffer.FFMpeg + Store GifStore +} + +// GifStore contains Gif files. +type GifStore interface { + Lookup(key string) (*RenderedGif, bool, error) + Insert(key string, img *RenderedGif) error } // GififyURL downloads the video at url and creates a .gif based on the spcified @@ -28,6 +38,43 @@ func (g Giffer) GififyURL( width, height int, q giffer.Quality, ) (*RenderedGif, error) { + if g.Store == nil { + return g.make(url, start, end, fps, width, height, q) + } + hasher := xxhash.New64() + input := fmt.Sprintf("%s_%f_%f_%f_%d_%d_%d", url, start, end, fps, width, height, q) + _, err := hasher.WriteString(input) + if err != nil { + return nil, errors.Wrap(err, "hashing input") + } + hash := fmt.Sprintf("%d", hasher.Sum64()) + img, ok, err := g.Store.Lookup(hash) + if err != nil { + return nil, errors.Wrap(err, "store lookup") + } + if ok && img != nil { + return img, nil + } + img, err = g.make(url, start, end, fps, width, height, q) + if err != nil { + return nil, err + } + dup := &RenderedGif{ + Reader: bytes.NewBuffer([]byte(img.Reader.(*bytes.Buffer).String())), + FileName: img.FileName, + } + if err := g.Store.Insert(hash, dup); err != nil { + return nil, errors.Wrap(err, "inserting gif into store") + } + return img, nil +} + +func (g Giffer) make( + url string, + start, end, fps float64, + width, height int, + q giffer.Quality, +) (*RenderedGif, error) { videofile, err := g.Download(url, q) if err != nil { return nil, errors.Wrap(err, "downloading") @@ -89,17 +136,17 @@ func (g Giffer) GififyURL( if err := gif.EncodeAll(buf, cfg); err != nil { return nil, errors.Wrap(err, "encoding animated gif") } - r := &RenderedGif{ - Buffer: buf, + img := &RenderedGif{ + Reader: buf, // Keep the title but replace the .mp4 extension with .gif FileName: sanitiseFilepath(strings.Split(filepath.Base(videofile), ".")[0] + ".gif"), } - return r, nil + return img, nil } // RenderedGif wraps the gif data with some metadata. type RenderedGif struct { - *bytes.Buffer + io.Reader // FileName is <title>.<ext> FileName string } diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go @@ -20,12 +20,14 @@ import ( var ( port string devServer string + verbose bool static http.Handler // responsible for serving UI files. ) func init() { flag.StringVar(&port, "p", "8080", "port to serve on") flag.StringVar(&devServer, "dev-proxy", "", "proxy to forward to (eg, yarn run serve)") + flag.BoolVar(&verbose, "v", false, "verbose mode") flag.Parse() if devServer != "" { t, err := url.Parse(devServer) @@ -45,12 +47,15 @@ func main() { Dir: "tmp/download", }, FFMpeg: &giffer.FFMpeg{ - Dir: "tmp/ffmpeg", - LeaveMess: true, + Dir: "tmp/ffmpeg", + }, + Store: &gifdb{ + Dir: "tmp/gifs", }, }, - Router: mux.NewRouter(), - Static: static, + Router: mux.NewRouter(), + Static: static, + Verbose: verbose, } svr := &http.Server{ Addr: fmt.Sprintf(":%s", port), diff --git a/cmd/desktop/server.go b/cmd/desktop/server.go @@ -9,7 +9,6 @@ import ( "os" "strings" "sync" - "time" "github.com/OneOfOne/xxhash" "github.com/jackmordaunt/giffer" @@ -22,9 +21,10 @@ import ( // UI serves the user interface over http. type UI struct { - App *Giffer - Router *mux.Router - Static http.Handler + App *Giffer + Router *mux.Router + Static http.Handler + Verbose bool gifmap map[string]http.Handler init sync.Once @@ -41,11 +41,13 @@ func (ui *UI) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (ui *UI) routes() { - log := Log{ - Logger: log.New(LogWriteHeaderErrors{Out: os.Stdout}, "", 0), - ShowBody: true, + if ui.Verbose { + log := Log{ + Logger: log.New(LogWriteHeaderErrors{Out: os.Stdout}, "", 0), + ShowBody: true, + } + ui.Router.Use(log.Middleware) } - ui.Router.Use(log.Middleware) ui.Router.Handle("/gifify", ui.gifify()) ui.Router.Handle("/gifs/{key}", ui.gifs()) ui.Router.Handle("/gifs/{key}/info", ui.gifs()) @@ -86,6 +88,7 @@ func (ui *UI) gifify() http.HandlerFunc { WriteBufferSize: 1024, }, } + g.Init() go g.Process(func() (*RenderedGif, error) { return ui.App.GififyURL( req.URL, @@ -123,40 +126,46 @@ func (ui *UI) gifs() http.HandlerFunc { } } -// Gif handles the serving of a gif file. -// There are two enpoints: +// Gif handles the serving of a gif file and a websocket endpoint to push out +// "gif is ready" messages. +// +// 3 parts: +// 1. A closure that produces the rendered gif and an error (to decouple how we +// get a hold of the gif from the serving of it). +// 2. A websocket endpoint that pushes a single message to each connection when +// the gif is ready to download. If the gif is ready before the connection +// comes in, the connection receives the message immediately. Otherwise +// the connection is stored in a map until the gif is ready. +// 3. A static file endpoint that serves the gif image as an attachment. If the +// gif isn't ready to be downloaded, an appropriate message is returned. type Gif struct { Upgrader *websocket.Upgrader - Tick time.Duration - file *RenderedGif - subs map[*websocket.Conn]struct{} - subMutex sync.Mutex - err error - once sync.Once + file *RenderedGif + subs map[*websocket.Conn]struct{} + done chan *RenderedGif + failed chan error + connections chan *websocket.Conn +} + +// Init the Gif server. +func (g *Gif) Init() { + g.subs = make(map[*websocket.Conn]struct{}) + g.done = make(chan *RenderedGif) + g.failed = make(chan error) + g.connections = make(chan *websocket.Conn) + go g.run() } // Process runs the specified function and sends a websocket message when it // completes. func (g *Gif) Process(fn func() (*RenderedGif, error)) { - type done struct { - Err string `json:"error,omitempty"` - } - g.file, g.err = fn() - g.subMutex.Lock() - var d done - if g.err != nil { - d.Err = g.err.Error() - } - for s := range g.subs { - err := s.WriteJSON(d) - if err != nil { - s.Close() - delete(g.subs, s) - log.Printf("writing json to websocket: %v", err) - } + img, err := fn() + if err != nil { + g.failed <- err + } else { + g.done <- img } - g.subMutex.Unlock() } func (g *Gif) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -173,7 +182,7 @@ func (g *Gif) subscribe(w http.ResponseWriter, r *http.Request) { log.Printf("upgrading websocket: %v", err) return } - g.append(c) + g.connections <- c } func (g *Gif) serveFile(w http.ResponseWriter, r *http.Request) { @@ -189,11 +198,44 @@ func (g *Gif) serveFile(w http.ResponseWriter, r *http.Request) { } } -func (g *Gif) append(c *websocket.Conn) { - g.once.Do(func() { - g.subs = make(map[*websocket.Conn]struct{}) - }) - g.subMutex.Lock() - g.subs[c] = struct{}{} - g.subMutex.Unlock() +func (g *Gif) run() { + type msg struct { + Err string `json:"error,omitempty"` + } + for { + select { + case img := <-g.done: + g.file = img + for s := range g.subs { + if err := s.WriteJSON(msg{}); err != nil { + log.Printf("writing json to websocket: %v", err) + } + if err := s.Close(); err != nil { + log.Printf("closing websocket: %v", err) + } + delete(g.subs, s) + } + case err := <-g.failed: + for s := range g.subs { + if err := s.WriteJSON(msg{Err: err.Error()}); err != nil { + log.Printf("writing json to websocket: %v", err) + } + if err := s.Close(); err != nil { + log.Printf("closing websocket: %v", err) + } + delete(g.subs, s) + } + case conn := <-g.connections: + if g.file != nil { + if err := conn.WriteJSON(msg{}); err != nil { + log.Printf("writing json to websocket: %v", err) + } + if err := conn.Close(); err != nil { + log.Printf("closing websocket: %v", err) + } + } else { + g.subs[conn] = struct{}{} + } + } + } } diff --git a/cmd/desktop/store.go b/cmd/desktop/store.go @@ -0,0 +1,189 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sync" + + "github.com/hashicorp/go-multierror" + + "github.com/pkg/errors" +) + +type gifdb struct { + Dir string + init sync.Once +} + +// Lookup loads the rendered gif from disk. +func (db *gifdb) Lookup(key string) (*RenderedGif, bool, error) { + var ( + info os.FileInfo + err error + ) + db.init.Do(func() { + err = os.MkdirAll(db.Dir, 0755) + }) + if err != nil && !os.IsExist(err) { + return nil, false, errors.Wrap(err, "initialising") + } + meta := filepath.Join(db.Dir, key+".json") + info, err = os.Stat(meta) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if info.IsDir() { + return nil, false, fmt.Errorf("key leads to a directory, not a json file") + } + img := filepath.Join(db.Dir, key+".gif") + info, err = os.Stat(img) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if info.IsDir() { + return nil, false, fmt.Errorf("key leads to a directory, not a gif file") + } + var ( + failed = make(chan error) + done = make(chan interface{}) + wg = sync.WaitGroup{} + ) + wg.Add(2) + go func() { + defer wg.Done() + if name, err := func() (string, error) { + metaf, err := os.Open(meta) + if err != nil { + return "", errors.Wrap(err, "opening metadata file") + } + defer metaf.Close() + type metadata struct { + FileName string `json:"filename"` + } + var md metadata + if err := json.NewDecoder(metaf).Decode(&md); err != nil { + return "", errors.Wrap(err, "decoding metadata") + } + return md.FileName, nil + }(); err != nil { + failed <- err + } else { + done <- name + } + }() + go func() { + defer wg.Done() + if buf, err := func() (*bytes.Buffer, error) { + buf := bytes.NewBuffer(nil) + file, err := os.Open(img) + if err != nil { + return nil, errors.Wrap(err, "opening gif file") + } + defer file.Close() + if _, err := io.Copy(buf, file); err != nil { + return nil, errors.Wrap(err, "reading gif file") + } + return buf, nil + }(); err != nil { + failed <- err + } else { + done <- buf + } + }() + go func() { + wg.Wait() + close(failed) + }() + var failure error + go func() { + for err := range failed { + failure = multierror.Append(failure, err) + } + close(done) + }() + r := &RenderedGif{} + for v := range done { + switch v := v.(type) { + case string: + r.FileName = v + case *bytes.Buffer: + r.Reader = v + } + } + if failure != nil { + return nil, false, failure + } + return r, true, nil +} + +// Insert stores the rendered gif on disk. +func (db *gifdb) Insert(key string, img *RenderedGif) (err error) { + db.init.Do(func() { + err = os.MkdirAll(db.Dir, 0755) + }) + if err != nil && !os.IsExist(err) { + return errors.Wrap(err, "initialising") + } + var ( + failed = make(chan error) + wg = sync.WaitGroup{} + ) + wg.Add(2) + go func() { + defer wg.Done() + if err := func() error { + imgpath := filepath.Join(db.Dir, key+".gif") + imgf, err := os.Create(imgpath) + if err != nil { + return errors.Wrap(err, "creating gif file") + } + defer imgf.Close() + if _, err := io.Copy(imgf, img); err != nil { + return errors.Wrap(err, "persisting gif to disk") + } + return nil + }(); err != nil { + failed <- err + } + }() + go func() { + defer wg.Done() + if err := func() error { + meta := filepath.Join(db.Dir, key+".json") + metaf, err := os.Create(meta) + if err != nil { + return errors.Wrap(err, "creating metadata file") + } + defer metaf.Close() + type metadata struct { + FileName string `json:"filename"` + } + if err := json.NewEncoder(metaf).Encode(metadata{ + FileName: img.FileName, + }); err != nil { + return errors.Wrap(err, "writing to metadata file") + } + return nil + }(); err != nil { + failed <- err + } + }() + go func() { + wg.Wait() + close(failed) + }() + for failure := range failed { + err = multierror.Append(err, failure) + } + return err +}