Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
victpork committed Jul 15, 2018
1 parent 87195cc commit c9adafc
Show file tree
Hide file tree
Showing 5 changed files with 345 additions and 0 deletions.
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# gif2cast - Convert animated gif into asciicast session

## What it does
Basically does the opposite of [asciicast2gif](https://github.com/asciinema/asciicast2gif) - turning an animated gif into asciicast file, which also uploading to the [asciinema](http://asciinema.org) site.

## Preview
[![asciicast](https://asciinema.org/a/191865.png)](https://asciinema.org/a/191865)

[Original file](https://en.wikipedia.org/wiki/GIF#/media/File:Rotating_earth_(large).gif)

## Usage
### Bundled tool
```
> AC_APIKEY=abcdef-123445 gif2cast animated.gif
or
> gif2cast -o out.cast animated.gif
```
### Library
```go
fp, err := os.Open(fileName)
...
gifImage, err := gif.DecodeAll(fp)
...
width := 80
height := 24
gc := gif2cast.NewGif2Cast(gifImage, 80, 24, "title")
out, err := os.OpenFile(outFilename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666)
gc.Write(out)
//or
url, err := gc.Upload(APIKEY)
if err != nil {
fmt.Println("Cannot upload:", err)
os.Exit(1)
}
fmt.Println(url)
```

## Note

This library is some hobby project I wrote over the weekend so it has a lot of rough edges, e.g.
- No support on partial frame refresh, it takes every frame in the gif as a full refresh.
- Does not support disposal methods
- Does not support loop
- Bundled tool does not find API automatically
- Rudimentary optimization to reduce asciicast file size
117 changes: 117 additions & 0 deletions asciicast/virtual_screen.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package asciicast

import (
"encoding/json"
"fmt"
"image"
"io"
"os"
"strings"
"time"
)

type asciiCastHeader struct {
Version int `json:"version"`
Width int `json:"width"`
Height int `json:"height"`
Timestamp int64 `json:"timestamp"`
Command string `json:"command"`
Title string `json:"title"`
Env map[string]string `json:"env"`
}

type VirtualScreen struct {
header asciiCastHeader
accumulatedDelay float32
textBuffer strings.Builder
}

const (
LogTimeFormat string = "20060102-150405"
)

// NewVirtualScreen creates new virtual screen struct, provide
// it with console size and title that would in the asciicast
// file header
func NewVirtualScreen(w, h int, title string) *VirtualScreen {
header := asciiCastHeader{
Version: 2,
Width: w,
Height: h,
Timestamp: time.Now().Unix(),
Command: "gif2cast",
Title: title,
Env: map[string]string{"TERM": "gif2cast", "ENV": "gif2cast"},
}
return &VirtualScreen{
header: header,
accumulatedDelay: 0,
textBuffer: strings.Builder{},
}
}

func ansiString(frame *image.RGBA) string {
strBuf := strings.Builder{}
fmt.Fprintf(&strBuf, "\033[1;1H")
for y := 0; y < frame.Rect.Dy(); y += 2 {
for x := 0; x < frame.Rect.Dx(); x++ {
i := frame.PixOffset(x, y)
//48 is background, upper part of cell
if i < 3 || i > 3 && (frame.Pix[i] != frame.Pix[i-3] ||
frame.Pix[i+1] != frame.Pix[i-2] ||
frame.Pix[i+2] != frame.Pix[i-1]) {
fmt.Fprintf(&strBuf, "\033[48;2;%d;%d;%dm", frame.Pix[i], frame.Pix[i+1], frame.Pix[i+2])
}
lPix := i + frame.Stride
//38 is foreground, lower part
if i < 3 || i > 3 && (frame.Pix[lPix] != frame.Pix[lPix-3] ||
frame.Pix[lPix+1] != frame.Pix[lPix-2] ||
frame.Pix[lPix+2] != frame.Pix[lPix-1]) {
fmt.Fprintf(&strBuf, "\033[38;2;%d;%d;%dm",
frame.Pix[i+frame.Stride], frame.Pix[i+frame.Stride+1], frame.Pix[i+frame.Stride+2])
}
//u2584 is the fill lowerblock char
fmt.Fprint(&strBuf, "\u2584")
}
fmt.Fprint(&strBuf, "\r\n")
}
return strBuf.String()
}

// WriteFrame write a image.RGBA into the string builder in asciicast format
func (vs *VirtualScreen) WriteFrame(frame *image.RGBA, delay int) {
escStr, _ := json.Marshal(ansiString(frame))
fmt.Fprintf(&vs.textBuffer, "[%f, \"o\", %s]\r\n", vs.accumulatedDelay, escStr)
vs.accumulatedDelay += float32(delay) / 100
}

// Write writes the asciicast file in buffer to the file
func (vs *VirtualScreen) WriteToFile(filename string) error {
fp, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0666)
if err != nil {
return err
}
defer fp.Close()
return vs.Write(fp)
}

// Write writes the asciicast file in buffer to the writer
func (vs *VirtualScreen) Write(w io.Writer) error {
bheader, err := json.Marshal(vs.header)
if err != nil {
return err
}
_, err = w.Write(bheader)
if err != nil {
return err
}
_, err = w.Write([]byte("\r\n"))
if err != nil {
return err
}
_, err = w.Write([]byte(vs.textBuffer.String()))
if err != nil {
return err
}
return nil
}
50 changes: 50 additions & 0 deletions cmd/gif2cast/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package main

import (
"flag"
"fmt"
"image/gif"
"os"

"github.com/mkishere/gif2cast"
)

func main() {
outFilename := ""
flag.StringVar(&outFilename, "o", "", "Output file name")
flag.Parse()

if flag.Arg(0) == "" {
fmt.Println("Input file expected")
os.Exit(1)
}
inFilename := flag.Arg(0)
fp, err := os.Open(inFilename)
if err != nil {
fmt.Println("Cannot open file:", err)
os.Exit(1)
}
gifImage, err := gif.DecodeAll(fp)
if err != nil {
fmt.Println("Cannot open file:", err)
os.Exit(1)
}
gc := gif2cast.NewGif2Cast(gifImage, 80, 24, flag.Arg(0))
if outFilename != "" {
out, err := os.OpenFile(outFilename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666)
if err != nil {
fmt.Println("Cannot create output file:", err)
os.Exit(1)
}
defer out.Close()
gc.Write(out)
} else {
//TODO: Get APIkey
str, err := gc.Upload(os.Getenv("AC_APIKEY"))
if err != nil {
fmt.Println("Cannot upload:", err)
os.Exit(1)
}
fmt.Println(str)
}
}
71 changes: 71 additions & 0 deletions gif2cast.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package gif2cast

import (
"bytes"
"image"
"image/gif"
"io"
"mime/multipart"
"net/http"

"github.com/mkishere/gif2cast/asciicast"
"github.com/mkishere/gif2cast/imgutil"
)

const (
endpoint = "https://asciinema.org/api/asciicasts"
)

// Gif2Cast helps with image conversion(resize) and controlling the virtual screen
type Gif2Cast struct {
oriImg *gif.GIF
cw, ch int
rgbImg []*image.RGBA
title string
}

// NewGif2Cast creates a new Gif2Cast object
func NewGif2Cast(gif *gif.GIF, w, h int, title string) *Gif2Cast {
return &Gif2Cast{
oriImg: gif,
cw: w,
ch: h,
title: title,
}
}

// Write writes file content to writer
func (gc *Gif2Cast) Write(w io.Writer) (err error) {
gc.rgbImg, err = imgutil.Resize(gc.oriImg, gc.cw, gc.ch*2)
if err != nil {
return err
}
vs := asciicast.NewVirtualScreen(gc.cw, gc.ch, gc.title)
for i := range gc.rgbImg {
vs.WriteFrame(gc.rgbImg[i], gc.oriImg.Delay[i])
}

return vs.Write(w)
}

// Upload the written file to asciinema server
func (gc *Gif2Cast) Upload(apiKey string) (string, error) {
buf := &bytes.Buffer{}
writer := multipart.NewWriter(buf)
filePart, _ := writer.CreateFormFile("asciicast", "ascii.cast")
_ = gc.Write(filePart)
writer.Close()
req, _ := http.NewRequest("POST", endpoint, buf)
req.SetBasicAuth("gif2cast", apiKey)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Add("User-Agent", "gif2cast/1.0.0")
client := &http.Client{}
rsp, err := client.Do(req)
if err != nil {
return "", err
}
body := &bytes.Buffer{}
_, err = body.ReadFrom(rsp.Body)
rsp.Body.Close()
return string(body.Bytes()), err
}
62 changes: 62 additions & 0 deletions imgutil/convert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package imgutil

import (
"image"
"image/color"
"image/gif"

"github.com/bamiaux/rez"
)

// ConvertPalettedImgToRGBA converts a paletted image format (e.g. GIF)
// into RGBA
func ConvertPalettedImgToRGBA(src *image.Paletted) (dst *image.RGBA) {
dst = &image.RGBA{
Pix: make([]uint8, 0, len(src.Pix)*4),
Rect: src.Rect,
Stride: src.Stride * 4,
}

for i := range src.Pix {
c := src.Palette[src.Pix[i]].(color.RGBA)
dst.Pix = append(dst.Pix, c.R, c.G, c.B, c.A)
}
return
}

// Resize takes a GIF image and resize it with given width and height
// Output is RGBA format in a stack.
func Resize(gifImg *gif.GIF, w, h int) (dst []*image.RGBA, err error) {
dst = make([]*image.RGBA, 0, len(gifImg.Image))

converter, err := rez.NewConverter(&rez.ConverterConfig{
Input: rez.Descriptor{
Width: gifImg.Image[0].Rect.Dx(),
Height: gifImg.Image[0].Rect.Dy(),
Interlaced: false,
Ratio: rez.Ratio444,
Pack: 4,
Planes: 1,
},
Output: rez.Descriptor{
Width: w,
Height: h,
Interlaced: false,
Ratio: rez.Ratio444,
Pack: 4,
Planes: 1,
},
}, rez.NewBilinearFilter())
if err != nil {
return nil, err
}
for i := range gifImg.Image {
dstFrame := image.NewRGBA(image.Rect(0, 0, w, h))
err = converter.Convert(dstFrame, ConvertPalettedImgToRGBA(gifImg.Image[i]))
if err != nil {
return nil, err
}
dst = append(dst, dstFrame)
}
return dst, nil
}

0 comments on commit c9adafc

Please sign in to comment.