giffer

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

engine.go (6552B)


      1 package giffer
      2 
      3 import (
      4 	"fmt"
      5 	"io"
      6 	"io/ioutil"
      7 	"os"
      8 	"os/exec"
      9 	"path/filepath"
     10 	"strconv"
     11 	"strings"
     12 	"sync"
     13 
     14 	"github.com/pkg/errors"
     15 )
     16 
     17 // Engine implements video and image manipulation.
     18 type Engine struct {
     19 	Dir     string    // Directory to write temporary files.
     20 	FFmpeg  string    // Path to FFmpeg binary.
     21 	Convert string    // Path to imagemagick Convert binary.
     22 	Debug   bool      // Print commands used.
     23 	Out     io.Writer // Writer to use if debug is true.
     24 	Junk    []string  // Temporary files to cleanup.
     25 
     26 	once sync.Once
     27 }
     28 
     29 // Cut and merge the target file into the specified time slices.
     30 // Cuts is a slice of int pairs which are start and end times (in seconds)
     31 // respectively.
     32 // Returns a filepath to the merged file.
     33 func (eng *Engine) Cut(video string, cuts ...[2]int) (string, error) {
     34 	if err := eng.init(); err != nil {
     35 		return "", fmt.Errorf("initializing engine: %w", err)
     36 	}
     37 	var (
     38 		cutfiles []string
     39 		entries  []string
     40 		filelist = eng.path("tmp_file_list.txt")
     41 		merged   = eng.path(fmt.Sprintf("merged%s", filepath.Ext(video)))
     42 	)
     43 	defer func() {
     44 		eng.Junk = append(eng.Junk, append(cutfiles, filelist, merged)...)
     45 	}()
     46 	for ii, c := range cuts {
     47 		start, end := c[0], c[1]
     48 		if start > end {
     49 			return "", fmt.Errorf("start > end: %d > %d", start, end)
     50 		}
     51 		output := eng.path(fmt.Sprintf("tmp_%d%s", ii, filepath.Ext(video)))
     52 		cutSlice := eng.command(
     53 			eng.FFmpeg,
     54 			"-ss", fmt.Sprintf("%d", start),
     55 			"-t", fmt.Sprintf("%d", end-start),
     56 			"-i", video,
     57 			output,
     58 		)
     59 		if out, err := cutSlice.CombinedOutput(); err != nil {
     60 			return "", errors.Wrapf(err, "cutting video: %s", string(out))
     61 		}
     62 		cutfiles = append(cutfiles, output)
     63 	}
     64 	for _, f := range cutfiles {
     65 		entries = append(entries, fmt.Sprintf("file '%s'", f))
     66 	}
     67 	if err := ioutil.WriteFile(
     68 		filelist,
     69 		[]byte(strings.Join(entries, "\n")),
     70 		0644,
     71 	); err != nil {
     72 		return "", errors.Wrap(err, "creating file list for concatentation")
     73 	}
     74 	merge := eng.command(
     75 		eng.FFmpeg,
     76 		"-f", "concat",
     77 		"-i", filelist,
     78 		"-c", "copy",
     79 		merged,
     80 	)
     81 	if out, err := merge.CombinedOutput(); err != nil {
     82 		return "", errors.Wrapf(err, "merging cut files: %s", string(out))
     83 	}
     84 	return merged, nil
     85 }
     86 
     87 // Transcode the target video file into a gif.
     88 // Returns a filepath to the gif image.
     89 func (eng *Engine) Transcode(
     90 	video string,
     91 	start, end float64,
     92 	width, height int,
     93 	fps float64,
     94 ) (string, error) {
     95 	if err := eng.init(); err != nil {
     96 		return "", fmt.Errorf("initializing engine: %w", err)
     97 	}
     98 	var (
     99 		duration   = end - start
    100 		filters    string
    101 		palettegen string
    102 		palette    = eng.path("palette.png")
    103 		output     = eng.path(fmt.Sprintf("%s.gif", strings.Split(filepath.Base(video), ".")[0]))
    104 	)
    105 	if height < -2 {
    106 		height = -2
    107 	}
    108 	if width < -2 {
    109 		width = -2
    110 	}
    111 	if fps > 0.0 {
    112 		filters += fmt.Sprintf("fps=%2f", fps)
    113 	}
    114 	if width > 0.0 || height > 0.0 {
    115 		if filters != "" {
    116 			filters += ","
    117 		}
    118 		filters += fmt.Sprintf("scale=%d:%d:flags=lanczos", width, height)
    119 	}
    120 	if len(filters) > 0 {
    121 		palettegen = fmt.Sprintf("%s,palettegen", filters)
    122 	} else {
    123 		palettegen = "palettegen"
    124 	}
    125 	defer func() {
    126 		eng.Junk = append(eng.Junk, palette, output)
    127 	}()
    128 	// TODO(jfm): make these structured, with omission as a field.
    129 	genPalette := eng.command(
    130 		eng.FFmpeg,
    131 		"-ss", fmt.Sprintf("%2f;omitempty", start),
    132 		"-t", fmt.Sprintf("%2f;omitempty", duration),
    133 		"-i", video,
    134 		"-vf", palettegen,
    135 		"-y", palette,
    136 	)
    137 	if out, err := genPalette.CombinedOutput(); err != nil {
    138 		return "", errors.Wrapf(err, "generating palette: %s", string(out))
    139 	}
    140 	makeGif := eng.command(
    141 		eng.FFmpeg,
    142 		"-ss", fmt.Sprintf("%2f;omitempty", start),
    143 		"-t", fmt.Sprintf("%2f;omitempty", duration),
    144 		"-i", video, "-i", palette,
    145 		"-lavfi", fmt.Sprintf("%s [x]; [x][1:v] paletteuse", filters),
    146 		"-y", output,
    147 	)
    148 	if out, err := makeGif.CombinedOutput(); err != nil {
    149 		return "", errors.Wrapf(err, "making gif: %s", string(out))
    150 	}
    151 	return output, nil
    152 }
    153 
    154 // Crush reduces the file size of a gif image.
    155 // Accepts a filepath to the gif image and replaces it with the crushed gif.
    156 // Fuzz is a percentage value between 0 and 100, where 0 is best quality, 100 is
    157 // smallest file size. Optimal is typically 2-5.
    158 func (eng *Engine) Crush(gif string, fuzz int) error {
    159 	if eng.Convert == "" {
    160 		return nil
    161 	}
    162 	args := []string{gif}
    163 	if fuzz > 0 {
    164 		args = append(args, "-fuzz", fmt.Sprintf("%d%%", fuzz))
    165 	}
    166 	args = append(args, "-layers", "Optimize", gif)
    167 	crushGif := eng.command(eng.Convert, args...)
    168 	if out, err := crushGif.CombinedOutput(); err != nil {
    169 		return errors.Wrap(err, string(out))
    170 	}
    171 	return nil
    172 }
    173 
    174 // Clean the temporary files.
    175 func (eng *Engine) Clean() {
    176 	for _, f := range eng.Junk {
    177 		if err := os.Remove(f); err != nil {
    178 			eng.logf("clean: %v\n", err)
    179 		}
    180 	}
    181 }
    182 
    183 // command creates a new exec.Cmd after removing empty arguments.
    184 // If an argument value contains "<value>;omitempty" and <value> is a zero
    185 // value, the argument value and it's corresponding argument specifier are
    186 // considered "empty" and omitted.
    187 func (eng *Engine) command(cmd string, args ...string) *exec.Cmd {
    188 	var a []string
    189 	for ii := 0; ii < len(args); ii++ {
    190 		arg := args[ii]
    191 		if arg[0] == '-' { // This is an argument specifier eg "-h".
    192 			if v := args[ii+1]; v == "" {
    193 				ii++
    194 				continue
    195 			} else if strings.Contains(v, ";omitempty") {
    196 				// Handle special ;omitempty directive.
    197 				v = strings.Split(v, ";omitempty")[0]
    198 				if v == "" {
    199 					ii++
    200 					continue
    201 				}
    202 				x, _ := strconv.Atoi(v)
    203 				if x == 0 {
    204 					ii++
    205 					continue
    206 				}
    207 				fl, _ := strconv.ParseFloat(v, 64)
    208 				if fl <= 0.0 {
    209 					ii++
    210 					continue
    211 				}
    212 				args[ii+1] = v
    213 			}
    214 		}
    215 		a = append(a, arg)
    216 	}
    217 	if eng.Debug {
    218 		eng.logf("%s %s\n", cmd, strings.Join(a, " "))
    219 	}
    220 	return exec.Command(cmd, a...)
    221 }
    222 
    223 func (eng *Engine) logf(f string, v ...interface{}) (int, error) {
    224 	if eng.Debug && eng.Out != nil {
    225 		return fmt.Fprintf(eng.Out, f, v...)
    226 	}
    227 	return 0, nil
    228 }
    229 
    230 // path resolves the given path segments against the configured directory.
    231 func (eng *Engine) path(s ...string) string {
    232 	p := filepath.Join(s...)
    233 	eng.logf("dir: %s, p: %s\n", eng.Dir, p)
    234 	p = strings.Replace(p, eng.Dir, "", 1)
    235 	return filepath.Join(eng.Dir, p)
    236 }
    237 
    238 func (eng *Engine) init() (err error) {
    239 	eng.once.Do(func() {
    240 		if eng.FFmpeg == "" {
    241 			eng.FFmpeg = "ffmpeg"
    242 		}
    243 		if eng.Dir == "" {
    244 			return
    245 		}
    246 		err = os.MkdirAll(eng.Dir, 0755)
    247 		if err != nil && !os.IsExist(err) {
    248 			err = errors.Wrap(err, "preparing directories")
    249 		} else if os.IsExist(err) {
    250 			err = nil
    251 		}
    252 	})
    253 	return err
    254 }