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 }