video2gif 0.0.2 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9033455c4d54099067dd61428f3190e75bc52783ab0ee110effa5cb9efedf688
4
- data.tar.gz: fd1092a4ef5ffa4c280861fa084444ed2d3b260139062488824c71c3de0957ee
3
+ metadata.gz: b0bd93f4f2af0dfc06ae10a777b8b56ec7bdad7850ed56cb5c79602e47fd59db
4
+ data.tar.gz: b54ba2493d071e7e6e6dd818020400db7f30df8f4419d790d44405ae467d5342
5
5
  SHA512:
6
- metadata.gz: 19a4bfdfb4abd9f13c9c0b8f6a558ef9c1563c620c864e70b3ed96f59b2dd344e153c7900561b92298b18fdc3094ea8a8feb7f4fc2d1f67b2ba90adbc95f757a
7
- data.tar.gz: f7c4f43908ca23a75651a73c3cff2cd30407024296ad3fab227f6dba331d0276228e54032458cf7d80739c0b6b4bd8f440508ef101ce5d6edd788e9f0d680084
6
+ metadata.gz: 5342e92d956387f8ddaccee6d8e5cfdc9c64a55ff089b351ff996a84de9c0ba73a6b902062ea1a17fe0ab579a13b5a2fb3076ba3080a1ed931e4bfc259a89f1d
7
+ data.tar.gz: b4794ae1452e8f50ec09825003b5b6faf874e02a232fdf1a4a72e0d4c747b9de6f937af0be2d401ae589749781ac59806caf6976e98b30f091f199b203806761
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- video2gif (0.1.0)
4
+ video2gif (0.0.4)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -32,7 +32,7 @@ Usage
32
32
 
33
33
  The general syntax for the command follows.
34
34
 
35
- video2gif <input video> [-o <output filename>] [<options>]
35
+ video2gif <input video> [<output filename>] [<options>]
36
36
 
37
37
  Use `video2gif --help` to see all the options available. Given an input
38
38
  video, `video2gif` has a reasonable set of defaults to output a GIF of
data/exe/video2gif CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require 'video2gif'
3
+ require 'video2gif/cli'
4
4
 
5
- Video2gif.run
5
+
6
+ Video2gif::CLI.start
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'open3'
5
+ require 'video2gif/ffmpeg'
6
+ require 'video2gif/options'
7
+
8
+
9
+ module Video2gif
10
+ module CLI
11
+ def self.start
12
+ logger = Logger.new(STDOUT)
13
+ options = Video2gif::Options.parse(ARGV)
14
+
15
+ if options[:cropdetect]
16
+ Open3.popen3(*Video2gif::FFMpeg.cropdetect_command(options, logger)) do |stdin, stdout, stderr, thread|
17
+ stdin.close
18
+ stdout.close
19
+ stderr.each(chomp: true) do |line|
20
+ logger.info(line) if options[:verbose] unless options[:quiet]
21
+ if line.include?('Parsed_cropdetect')
22
+ options[:cropdetect] = line.match('crop=([0-9]+\:[0-9]+\:[0-9]+\:[0-9]+)')
23
+ end
24
+ end
25
+ stderr.close
26
+
27
+ unless thread.value.success?
28
+ raise "Process #{thread.pid} failed! Try again with --verbose to see error."
29
+ end
30
+ end
31
+ end
32
+
33
+ Open3.popen3(*Video2gif::FFMpeg.gif_command(options, logger)) do |stdin, stdout, stderr, thread|
34
+ stdin.close
35
+ stdout.close
36
+ stderr.each(chomp: true) do |line|
37
+ logger.info(line) if options[:verbose] unless options[:quiet]
38
+ end
39
+ stderr.close
40
+
41
+ unless thread.value.success?
42
+ raise "Process #{thread.pid} failed! Try again with --verbose to see error."
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Video2gif
5
+ module FFMpeg
6
+ def self.filter_complex(options)
7
+ fps = options[:fps] || 15
8
+ max_colors = options[:palette] ? "max_colors=#{options[:palette]}:" : ''
9
+ width = options[:width] # default is not to scale at all
10
+
11
+ # create filter elements
12
+ fps_filter = "fps=#{fps}"
13
+ crop_filter = options[:cropdetect] || 'crop=' + %W[
14
+ w=#{ options[:wregion] || 'in_w' }
15
+ h=#{ options[:hregion] || 'in_h' }
16
+ x=#{ options[:xoffset] || 0 }
17
+ y=#{ options[:yoffset] || 0 }
18
+ ].join(':')
19
+ scale_filter = "scale=#{width}:-1:flags=lanczos:sws_dither=none" if options[:width] unless options[:tonemap]
20
+ tonemap_filters = if options[:tonemap] # TODO: detect input format
21
+ %W[
22
+ zscale=w=#{width}:h=-1
23
+ zscale=t=linear:npl=100
24
+ format=yuv420p10le
25
+ zscale=p=bt709
26
+ tonemap=tonemap=#{options[:tonemap]}:desat=0
27
+ zscale=t=bt709:m=bt709:r=tv
28
+ format=yuv420p
29
+ ].join(',')
30
+ end
31
+ eq_filter = if options[:eq]
32
+ 'eq=' + %W[
33
+ contrast=#{ options[:contrast] || 1 }
34
+ brightness=#{ options[:brightness] || 0 }
35
+ saturation=#{ options[:saturation] || 1 }
36
+ gamma=#{ options[:gamma] || 1 }
37
+ gamma_r=#{ options[:gamma_r] || 1 }
38
+ gamma_g=#{ options[:gamma_g] || 1 }
39
+ gamma_b=#{ options[:gamma_b] || 1 }
40
+ ].join(':')
41
+ end
42
+ palettegen_filter = "palettegen=#{max_colors}stats_mode=diff"
43
+ paletteuse_filter = 'paletteuse=dither=floyd_steinberg:diff_mode=rectangle'
44
+ drawtext_filter = if options[:text]
45
+ count_of_lines = options[:text].scan(/\\n/).count + 1
46
+
47
+ x = options[:xpos] || '(main_w/2-text_w/2)'
48
+ y = options[:ypos] || "(main_h-line_h*1.5*#{count_of_lines})"
49
+ size = options[:textsize] || 32
50
+ color = options[:textcolor] || 'white'
51
+ border = options[:textborder] || 3
52
+ font = options[:textfont] || 'Arial'
53
+ style = options[:textvariant] || 'Bold'
54
+ text = options[:text]
55
+ .gsub(/\\n/, ' ')
56
+ .gsub(/([:])/, '\\\\\\\\\\1')
57
+ .gsub(/([,])/, '\\\\\\1')
58
+ .gsub(/\b'\b/, "\u2019")
59
+ .gsub(/\B"\b([^"\u201C\u201D\u201E\u201F\u2033\u2036\r\n]+)\b?"\B/, "\u201C\\1\u201D")
60
+ .gsub(/\B'\b([^'\u2018\u2019\u201A\u201B\u2032\u2035\r\n]+)\b?'\B/, "\u2018\\1\u2019")
61
+
62
+ 'drawtext=' + %W[
63
+ x='#{x}'
64
+ y='#{y}'
65
+ fontsize='#{size}'
66
+ fontcolor='#{color}'
67
+ borderw='#{border}'
68
+ fontfile='#{font}'\\\\:style='#{style}'
69
+ text='#{text}'
70
+ ].join(':')
71
+ end
72
+
73
+ filter_complex = []
74
+
75
+ # first, apply the same filters we'll use later in the same order
76
+ # before applying the palettegen so that we accurately predict the
77
+ # final palette
78
+ filter_complex << fps_filter
79
+ filter_complex << crop_filter if crop_filter
80
+ filter_complex << scale_filter if options[:width] unless options[:tonemap]
81
+ filter_complex << tonemap_filters if options[:tonemap]
82
+ filter_complex << eq_filter if options[:eq]
83
+ filter_complex << drawtext_filter if options[:text]
84
+
85
+ # then generate the palette (and label this filter stream)
86
+ filter_complex << palettegen_filter + '[palette]'
87
+
88
+ # then refer back to the first video input stream and the filter
89
+ # complex stream to apply the generated palette to the video stream
90
+ # along with the other filters (drawing text last so that it isn't
91
+ # affected by scaling)
92
+ filter_complex << '[0:v][palette]' + paletteuse_filter
93
+ filter_complex << fps_filter
94
+ filter_complex << crop_filter if crop_filter
95
+ filter_complex << scale_filter if options[:width] unless options[:tonemap]
96
+ filter_complex << tonemap_filters if options[:tonemap]
97
+ filter_complex << eq_filter if options[:eq]
98
+ filter_complex << drawtext_filter if options[:text]
99
+
100
+ filter_complex.join(',')
101
+ end
102
+
103
+ def self.cropdetect_command(options, logger)
104
+ command = ['ffmpeg']
105
+ command << '-analyzeduration' << '2147483647' << '-probesize' << '2147483647'
106
+ command << '-nostdin'
107
+ command << '-ss' << options[:seek] if options[:seek]
108
+ command << '-t' << options[:time] if options[:time]
109
+ command << '-i' << options[:input_filename]
110
+ command << '-filter_complex' << "cropdetect=limit=#{options[:cropdetect]}"
111
+ command << '-f' << 'null'
112
+ command << '-'
113
+
114
+ logger.info(command.join(' ')) if options[:verbose] unless options[:quiet]
115
+
116
+ command
117
+ end
118
+
119
+ def self.gif_command(options, logger)
120
+ command = ['ffmpeg']
121
+ command << '-y' # always overwrite
122
+ command << '-analyzeduration' << '2147483647' << '-probesize' << '2147483647'
123
+ command << '-nostdin'
124
+ command << '-ss' << options[:seek] if options[:seek]
125
+ command << '-t' << options[:time] if options[:time]
126
+ command << '-i' << options[:input_filename]
127
+ command << '-filter_complex' << filter_complex(options)
128
+ command << '-gifflags' << '+transdiff' # enabled by default
129
+ command << '-f' << 'gif'
130
+ command << options[:output_filename]
131
+
132
+ logger.info(command.join(' ')) if options[:verbose] unless options[:quiet]
133
+
134
+ command
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'video2gif/utils'
5
+ require 'video2gif/version'
6
+
7
+
8
+ module Video2gif
9
+ module Options
10
+ def self.parse(args)
11
+ options = {}
12
+
13
+ parser = OptionParser.new do |parser|
14
+ parser.banner = <<~BANNER
15
+ video2gif #{Video2gif::VERSION}
16
+
17
+ Usage: video2gif <video> [<output GIF filename>] [options]
18
+ BANNER
19
+
20
+ parser.separator ''
21
+ parser.separator 'General GIF options:'
22
+
23
+ parser.on('-s SEEK',
24
+ '--seek SEEK',
25
+ 'Set time to seek to in the input video (use a count of',
26
+ 'seconds or HH:MM:SS.SS format)') do |s|
27
+ options[:seek] = s
28
+ end
29
+
30
+ parser.on('-t TIME',
31
+ '--time TIME',
32
+ 'Set duration to use from the input video (use a count of',
33
+ 'seconds)') do |t|
34
+ options[:time] = t
35
+ end
36
+
37
+ parser.on('-f FRAMES',
38
+ '--fps FRAMES',
39
+ 'Set frames per second for the resulting GIF') do |f|
40
+ options[:fps] = f
41
+ end
42
+
43
+ parser.on('-w WIDTH',
44
+ '--width WIDTH',
45
+ 'Scale the width of the resulting GIF in pixels (aspect',
46
+ 'ratio is preserved)') do |w|
47
+ options[:width] = w
48
+ end
49
+
50
+ # parser.on('-hHEIGHT', '--height=HEIGHT', 'Scale the height of the resulting GIF') do |h|
51
+ # options[:height] = h
52
+ # end
53
+
54
+ parser.on('-p PALETTE',
55
+ '--palette PALETTE',
56
+ 'Set the palette size of the resulting GIF (maximum of 255',
57
+ 'colors)') do |p|
58
+ options[:palette] = p
59
+ end
60
+
61
+ parser.on('-c SIZE',
62
+ '--crop-size-w SIZE',
63
+ 'Pixel size of width to select from source video, before scaling') do |s|
64
+ options[:wregion] = s
65
+ end
66
+
67
+ parser.on('-h SIZE',
68
+ '--crop-size-h SIZE',
69
+ 'Pixel size of height to select from source video, before scaling') do |s|
70
+ options[:hregion] = s
71
+ end
72
+
73
+ parser.on('-x OFFSET',
74
+ '--crop-offset-x OFFSET',
75
+ 'Pixel offset from left to select from source video, before scaling') do |o|
76
+ options[:xoffset] = o
77
+ end
78
+
79
+ parser.on('-y OFFSET',
80
+ '--crop-offset-y OFFSET',
81
+ 'Pixel offset from top to select from source video, before scaling') do |o|
82
+ options[:yoffset] = o
83
+ end
84
+
85
+ parser.on('-d [THRESHOLD]',
86
+ '--crop-detect [THRESHOLD]',
87
+ 'Attempt automatic cropping based on black region, scaled',
88
+ 'from 0 (nothing) to 255 (everything), default threshold 24') do |c|
89
+ options[:cropdetect] = c || 24
90
+ end
91
+
92
+ parser.on('--contrast CONTRAST',
93
+ 'Apply contrast adjustment, scaled from -2.0 to 2.0 (default 1)') do |c|
94
+ options[:contrast] = c
95
+ options[:eq] = true
96
+ end
97
+
98
+ parser.on('--brightness BRIGHTNESS',
99
+ 'Apply brightness adjustment, scaled from -1.0 to 1.0 (default 0)') do |b|
100
+ options[:brightness] = b
101
+ options[:eq] = true
102
+ end
103
+
104
+ parser.on('--saturation SATURATION',
105
+ 'Apply saturation adjustment, scaled from 0.0 to 3.0 (default 1)') do |s|
106
+ options[:saturation] = s
107
+ options[:eq] = true
108
+ end
109
+
110
+ parser.on('--gamma GAMMA',
111
+ 'Apply gamma adjustment, scaled from 0.1 to 10.0 (default 1)') do |g|
112
+ options[:gamma] = g
113
+ options[:eq] = true
114
+ end
115
+
116
+ parser.on('--red-gamma GAMMA',
117
+ 'Apply red channel gamma adjustment, scaled from 0.1 to 10.0 (default 1)') do |g|
118
+ options[:gamma_r] = g
119
+ options[:eq] = true
120
+ end
121
+
122
+ parser.on('--green-gamma GAMMA',
123
+ 'Apply green channel gamma adjustment, scaled from 0.1 to 10.0 (default 1)') do |g|
124
+ options[:gamma_g] = g
125
+ options[:eq] = true
126
+ end
127
+
128
+ parser.on('--blue-gamma GAMMA',
129
+ 'Apply blue channel gamma adjustment, scaled from 0.1 to 10.0 (default 1)') do |g|
130
+ options[:gamma_b] = g
131
+ options[:eq] = true
132
+ end
133
+
134
+ parser.on('--tonemap [ALGORITHM]',
135
+ 'Attempt to force tonemapping from HDR (BT.2020) to SDR',
136
+ '(BT.709) using algorithm (experimental, requires ffmpeg with',
137
+ 'libzimg) (default "hable", "mobius" is a good alternative)') do |t|
138
+ options[:tonemap] = t || 'hable'
139
+ end
140
+
141
+ parser.separator ''
142
+ parser.separator 'Text overlay options (only used if text is defined):'
143
+
144
+ parser.on('-T TEXT',
145
+ '--text TEXT',
146
+ 'Set text to overlay on the GIF (use "\n" for line breaks)') do |p|
147
+ options[:text] = p
148
+ end
149
+
150
+ parser.on('-C TEXTCOLOR',
151
+ '--text-color TEXTCOLOR',
152
+ 'Set the color for text overlay') do |p|
153
+ options[:textcolor] = p
154
+ end
155
+
156
+ parser.on('-S TEXTSIZE',
157
+ '--text-size TEXTSIZE',
158
+ 'Set the point size for text overlay') do |p|
159
+ options[:textsize] = p
160
+ end
161
+
162
+ parser.on('-B TEXTBORDER',
163
+ '--text-border TEXTBORDER',
164
+ 'Set the width of the border for text overlay') do |p|
165
+ options[:textborder] = p
166
+ end
167
+
168
+ parser.on('-F TEXTFONT',
169
+ '--text-font TEXTFONT',
170
+ 'Set the font name for text overlay') do |p|
171
+ options[:textfont] = p
172
+ end
173
+
174
+ parser.on('-V TEXTSTYLE',
175
+ '--text-variant TEXTVARIANT',
176
+ 'Set the font variant for text overlay (e.g., "Semibold")') do |p|
177
+ options[:textvariant] = p
178
+ end
179
+
180
+ parser.on('-X TEXTXPOS',
181
+ '--text-x-position TEXTXPOS',
182
+ 'Set the X position for the text, starting from left (default is center)') do |p|
183
+ options[:xpos] = p
184
+ end
185
+
186
+ parser.on('-Y TEXTXPOS',
187
+ '--text-y-position TEXTYPOS',
188
+ 'Set the Y position for the text, starting from top (default is near bottom)') do |p|
189
+ options[:ypos] = p
190
+ end
191
+
192
+ parser.separator ''
193
+ parser.separator 'Other options:'
194
+
195
+ parser.on_tail('-v', '--verbose', 'Show ffmpeg command executed and output') do |p|
196
+ options[:verbose] = p
197
+ end
198
+
199
+ parser.on_tail('-q', '--quiet', 'Suppress all log output (overrides verbose)') do |p|
200
+ options[:quiet] = p
201
+ end
202
+
203
+ parser.on_tail('-h', '--help', 'Show this message') do
204
+ puts parser
205
+ exit
206
+ end
207
+
208
+ parser.parse!(args)
209
+ end
210
+
211
+ parser.parse!
212
+
213
+ unless Video2gif::Utils.is_executable?('ffmpeg')
214
+ puts 'ERROR: Requires FFmpeg to be installed!'
215
+ exit 1
216
+ end
217
+
218
+ if args.size < 1 || args.size > 2
219
+ puts 'ERROR: Specify one video to convert at a time!'
220
+ puts ''
221
+ puts parser.help
222
+ exit 1
223
+ end
224
+
225
+ unless File.exists?(args[0])
226
+ puts "ERROR: Specified video file does not exist: #{args[0]}!"
227
+ puts ''
228
+ puts parser.help
229
+ exit
230
+ end
231
+
232
+ options[:input_filename] = args[0]
233
+ options[:output_filename] = if args[1]
234
+ args[1].end_with?('.gif') ? args[1] : args[1] + '.gif'
235
+ else
236
+ File.join(File.dirname(args[0]),
237
+ File.basename(args[0], '.*') + '.gif')
238
+ end
239
+
240
+ options
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Video2gif
5
+ module Utils
6
+ def self.is_executable?(command)
7
+ ENV['PATH'].split(File::PATH_SEPARATOR).map do |path|
8
+ (ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']).map do |extension|
9
+ File.executable?(File.join(path, "#{command}#{extension}"))
10
+ end
11
+ end.flatten.any?
12
+ end
13
+ end
14
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Video2gif
4
- VERSION = '0.0.2'
4
+ VERSION = '0.0.4'
5
5
  end
data/lib/video2gif.rb CHANGED
@@ -1,407 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'optparse'
4
- require 'open3'
5
- require 'logger'
6
-
7
- require 'video2gif/version'
8
-
9
-
10
3
  module Video2gif
11
- def self.is_executable?(command)
12
- ENV['PATH'].split(File::PATH_SEPARATOR).map do |path|
13
- (ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']).map do |extension|
14
- File.executable?(File.join(path, "#{command}#{extension}"))
15
- end
16
- end.flatten.any?
17
- end
18
-
19
- def self.parse_args(args, logger)
20
- options = {}
21
-
22
- parser = OptionParser.new do |parser|
23
- parser.banner = 'Usage: video2gif <video> [options] [<output GIF filename>]'
24
- parser.separator ''
25
- parser.separator 'General GIF options:'
26
-
27
- parser.on('-s SEEK',
28
- '--seek SEEK',
29
- 'Set time to seek to in the input video (use a count of',
30
- 'seconds or HH:MM:SS.SS format)') do |s|
31
- options[:seek] = s
32
- end
33
-
34
- parser.on('-t TIME',
35
- '--time TIME',
36
- 'Set duration to use from the input video (use a count of',
37
- 'seconds)') do |t|
38
- options[:time] = t
39
- end
40
-
41
- parser.on('-f FRAMES',
42
- '--fps FRAMES',
43
- 'Set frames per second for the resulting GIF') do |f|
44
- options[:fps] = f
45
- end
46
-
47
- parser.on('-w WIDTH',
48
- '--width WIDTH',
49
- 'Scale the width of the resulting GIF in pixels (aspect',
50
- 'ratio is preserved)') do |w|
51
- options[:width] = w
52
- end
53
-
54
- # parser.on('-hHEIGHT', '--height=HEIGHT', 'Scale the height of the resulting GIF') do |h|
55
- # options[:height] = h
56
- # end
57
-
58
- parser.on('-p PALETTE',
59
- '--palette PALETTE',
60
- 'Set the palette size of the resulting GIF (maximum of 255',
61
- 'colors)') do |p|
62
- options[:palette] = p
63
- end
64
-
65
- parser.on('-c SIZE',
66
- '--crop-size-w SIZE',
67
- 'Pixel size of width to select from source video, before scaling') do |s|
68
- options[:wregion] = s
69
- end
70
-
71
- parser.on('-h SIZE',
72
- '--crop-size-h SIZE',
73
- 'Pixel size of height to select from source video, before scaling') do |s|
74
- options[:hregion] = s
75
- end
76
-
77
- parser.on('-x OFFSET',
78
- '--crop-offset-x OFFSET',
79
- 'Pixel offset from left to select from source video, before scaling') do |o|
80
- options[:xoffset] = o
81
- end
82
-
83
- parser.on('-y OFFSET',
84
- '--crop-offset-y OFFSET',
85
- 'Pixel offset from top to select from source video, before scaling') do |o|
86
- options[:yoffset] = o
87
- end
88
-
89
- parser.on('-d [THRESHOLD]',
90
- '--crop-detect [THRESHOLD]',
91
- 'Attempt automatic cropping based on black region, scaled',
92
- 'from 0 (nothing) to 255 (everything), default threshold 24') do |c|
93
- options[:cropdetect] = c || 24
94
- end
95
-
96
- parser.on('--contrast CONTRAST',
97
- 'Apply contrast adjustment, scaled from -2.0 to 2.0 (default 1)') do |c|
98
- options[:contrast] = c
99
- options[:eq] = true
100
- end
101
-
102
- parser.on('--brightness BRIGHTNESS',
103
- 'Apply brightness adjustment, scaled from -1.0 to 1.0 (default 0)') do |b|
104
- options[:brightness] = b
105
- options[:eq] = true
106
- end
107
-
108
- parser.on('--saturation SATURATION',
109
- 'Apply saturation adjustment, scaled from 0.0 to 3.0 (default 1)') do |s|
110
- options[:saturation] = s
111
- options[:eq] = true
112
- end
113
-
114
- parser.on('--gamma GAMMA',
115
- 'Apply gamma adjustment, scaled from 0.1 to 10.0 (default 1)') do |g|
116
- options[:gamma] = g
117
- options[:eq] = true
118
- end
119
-
120
- parser.on('--red-gamma GAMMA',
121
- 'Apply red channel gamma adjustment, scaled from 0.1 to 10.0 (default 1)') do |g|
122
- options[:gamma_r] = g
123
- options[:eq] = true
124
- end
125
-
126
- parser.on('--green-gamma GAMMA',
127
- 'Apply green channel gamma adjustment, scaled from 0.1 to 10.0 (default 1)') do |g|
128
- options[:gamma_g] = g
129
- options[:eq] = true
130
- end
131
-
132
- parser.on('--blue-gamma GAMMA',
133
- 'Apply blue channel gamma adjustment, scaled from 0.1 to 10.0 (default 1)') do |g|
134
- options[:gamma_b] = g
135
- options[:eq] = true
136
- end
137
-
138
- parser.on('--tonemap [ALGORITHM]',
139
- 'Attempt to force tonemapping from HDR (BT.2020) to SDR',
140
- '(BT.709) using algorithm (experimental, requires ffmpeg with',
141
- 'libzimg) (default "hable", "mobius" is a good alternative)') do |t|
142
- options[:tonemap] = t || 'hable'
143
- end
144
-
145
- parser.separator ''
146
- parser.separator 'Text overlay options (only used if text is defined):'
147
-
148
- parser.on('-T TEXT',
149
- '--text TEXT',
150
- 'Set text to overlay on the GIF (use "\n" for line breaks)') do |p|
151
- options[:text] = p
152
- end
153
-
154
- parser.on('-C TEXTCOLOR',
155
- '--text-color TEXTCOLOR',
156
- 'Set the color for text overlay') do |p|
157
- options[:textcolor] = p
158
- end
159
-
160
- parser.on('-S TEXTSIZE',
161
- '--text-size TEXTSIZE',
162
- 'Set the point size for text overlay') do |p|
163
- options[:textsize] = p
164
- end
165
-
166
- parser.on('-B TEXTBORDER',
167
- '--text-border TEXTBORDER',
168
- 'Set the width of the border for text overlay') do |p|
169
- options[:textborder] = p
170
- end
171
-
172
- parser.on('-F TEXTFONT',
173
- '--text-font TEXTFONT',
174
- 'Set the font name for text overlay') do |p|
175
- options[:textfont] = p
176
- end
177
-
178
- parser.on('-V TEXTSTYLE',
179
- '--text-variant TEXTVARIANT',
180
- 'Set the font variant for text overlay (e.g., "Semibold")') do |p|
181
- options[:textvariant] = p
182
- end
183
-
184
- parser.on('-X TEXTXPOS',
185
- '--text-x-position TEXTXPOS',
186
- 'Set the X position for the text, starting from left (default is center)') do |p|
187
- options[:xpos] = p
188
- end
189
-
190
- parser.on('-Y TEXTXPOS',
191
- '--text-y-position TEXTYPOS',
192
- 'Set the Y position for the text, starting from top (default is near bottom)') do |p|
193
- options[:ypos] = p
194
- end
195
-
196
- parser.separator ''
197
- parser.separator 'Other options:'
198
-
199
- parser.on_tail('-v', '--verbose', 'Show ffmpeg command executed and output') do |p|
200
- options[:verbose] = p
201
- end
202
-
203
- parser.on_tail('-q', '--quiet', 'Suppress all log output (overrides verbose)') do |p|
204
- options[:quiet] = p
205
- end
206
-
207
- parser.on_tail('-h', '--help', 'Show this message') do
208
- puts parser
209
- exit
210
- end
211
-
212
- parser.parse!(args)
213
- end
214
-
215
- parser.parse!
216
-
217
- unless is_executable?('ffmpeg')
218
- puts 'ERROR: Requires FFmpeg to be installed!'
219
- exit 1
220
- end
221
-
222
- if args.size < 1 || args.size > 2
223
- puts 'ERROR: Specify one video to convert at a time!'
224
- puts ''
225
- puts parser.help
226
- exit 1
227
- end
228
-
229
- unless File.exists?(args[0])
230
- puts "ERROR: Specified video file does not exist: #{args[0]}!"
231
- puts ''
232
- puts parser.help
233
- exit
234
- end
235
-
236
- options
237
- end
238
-
239
- def self.build_filter_complex(options)
240
- fps = options[:fps] || 15
241
- max_colors = options[:palette] ? "max_colors=#{options[:palette]}:" : ''
242
- width = options[:width] # default is not to scale at all
243
-
244
- # create filter elements
245
- fps_filter = "fps=#{fps}"
246
- crop_filter = options[:cropdetect] || 'crop=' + %W[
247
- w=#{ options[:wregion] || 'in_w' }
248
- h=#{ options[:hregion] || 'in_h' }
249
- x=#{ options[:xoffset] || 0 }
250
- y=#{ options[:yoffset] || 0 }
251
- ].join(':')
252
- scale_filter = "scale=#{width}:-1:flags=lanczos:sws_dither=none" if options[:width] unless options[:tonemap]
253
- tonemap_filters = if options[:tonemap] # TODO: detect input format
254
- %W[
255
- zscale=w=#{width}:h=-1
256
- zscale=t=linear:npl=100
257
- format=yuv420p10le
258
- zscale=p=bt709
259
- tonemap=tonemap=#{options[:tonemap]}:desat=0
260
- zscale=t=bt709:m=bt709:r=tv
261
- format=yuv420p
262
- ].join(',')
263
- end
264
- eq_filter = if options[:eq]
265
- 'eq=' + %W[
266
- contrast=#{ options[:contrast] || 1 }
267
- brightness=#{ options[:brightness] || 0 }
268
- saturation=#{ options[:saturation] || 1 }
269
- gamma=#{ options[:gamma] || 1 }
270
- gamma_r=#{ options[:gamma_r] || 1 }
271
- gamma_g=#{ options[:gamma_g] || 1 }
272
- gamma_b=#{ options[:gamma_b] || 1 }
273
- ].join(':')
274
- end
275
- palettegen_filter = "palettegen=#{max_colors}stats_mode=diff"
276
- paletteuse_filter = 'paletteuse=dither=floyd_steinberg:diff_mode=rectangle'
277
- drawtext_filter = if options[:text]
278
- count_of_lines = options[:text].scan(/\\n/).count + 1
279
-
280
- x = options[:xpos] || '(main_w/2-text_w/2)'
281
- y = options[:ypos] || "(main_h-line_h*1.5*#{count_of_lines})"
282
- size = options[:textsize] || 32
283
- color = options[:textcolor] || 'white'
284
- border = options[:textborder] || 3
285
- font = options[:textfont] || 'Arial'
286
- style = options[:textvariant] || 'Bold'
287
- text = options[:text]
288
- .gsub(/\\n/, ' ')
289
- .gsub(/([:])/, '\\\\\\\\\\1')
290
- .gsub(/([,])/, '\\\\\\1')
291
- .gsub(/\b'\b/, "\u2019")
292
- .gsub(/\B"\b([^"\u201C\u201D\u201E\u201F\u2033\u2036\r\n]+)\b?"\B/, "\u201C\\1\u201D")
293
- .gsub(/\B'\b([^'\u2018\u2019\u201A\u201B\u2032\u2035\r\n]+)\b?'\B/, "\u2018\\1\u2019")
294
-
295
- 'drawtext=' + %W[
296
- x='#{x}'
297
- y='#{y}'
298
- fontsize='#{size}'
299
- fontcolor='#{color}'
300
- borderw='#{border}'
301
- fontfile='#{font}'\\\\:style='#{style}'
302
- text='#{text}'
303
- ].join(':')
304
- end
305
-
306
- filter_complex = []
307
-
308
- # first, apply the same filters we'll use later in the same order
309
- # before applying the palettegen so that we accurately predict the
310
- # final palette
311
- filter_complex << fps_filter
312
- filter_complex << crop_filter if crop_filter
313
- filter_complex << scale_filter if options[:width] unless options[:tonemap]
314
- filter_complex << tonemap_filters if options[:tonemap]
315
- filter_complex << eq_filter if options[:eq]
316
- filter_complex << drawtext_filter if options[:text]
317
-
318
- # then generate the palette (and label this filter stream)
319
- filter_complex << palettegen_filter + '[palette]'
320
-
321
- # then refer back to the first video input stream and the filter
322
- # complex stream to apply the generated palette to the video stream
323
- # along with the other filters (drawing text last so that it isn't
324
- # affected by scaling)
325
- filter_complex << '[0:v][palette]' + paletteuse_filter
326
- filter_complex << fps_filter
327
- filter_complex << crop_filter if crop_filter
328
- filter_complex << scale_filter if options[:width] unless options[:tonemap]
329
- filter_complex << tonemap_filters if options[:tonemap]
330
- filter_complex << eq_filter if options[:eq]
331
- filter_complex << drawtext_filter if options[:text]
332
-
333
- filter_complex.join(',')
334
- end
335
-
336
- def self.build_output_filename(args)
337
- if args[1]
338
- args[1].end_with?('.gif') ? args[1] : args[1] + '.gif'
339
- else
340
- File.join(File.dirname(args[0]), File.basename(args[0], '.*') + '.gif')
341
- end
342
- end
343
-
344
- def self.build_ffmpeg_cropdetect_command(args, options, logger)
345
- command = ['ffmpeg']
346
- command << '-analyzeduration' << '2147483647' << '-probesize' << '2147483647'
347
- command << '-nostdin'
348
- command << '-ss' << options[:seek] if options[:seek]
349
- command << '-t' << options[:time] if options[:time]
350
- command << '-i' << args[0]
351
- command << '-filter_complex' << "cropdetect=limit=#{options[:cropdetect]}"
352
- command << '-f' << 'null'
353
- command << '-'
354
-
355
- logger.info(command.join(' ')) if options[:verbose] unless options[:quiet]
356
-
357
- command
358
- end
359
-
360
- def self.build_ffmpeg_gif_command(args, options, logger)
361
- command = ['ffmpeg']
362
- command << '-y' # always overwrite
363
- command << '-analyzeduration' << '2147483647' << '-probesize' << '2147483647'
364
- command << '-nostdin'
365
- command << '-ss' << options[:seek] if options[:seek]
366
- command << '-t' << options[:time] if options[:time]
367
- command << '-i' << args[0]
368
- command << '-filter_complex' << build_filter_complex(options)
369
- command << '-gifflags' << '+transdiff' # enabled by default
370
- command << '-f' << 'gif'
371
- command << build_output_filename(args)
372
-
373
- logger.info(command.join(' ')) if options[:verbose] unless options[:quiet]
374
-
375
- command
376
- end
377
-
378
- def self.run
379
- logger = Logger.new(STDOUT)
380
- options = parse_args(ARGV, logger)
381
-
382
- if options[:cropdetect]
383
- Open3.popen3(*build_ffmpeg_cropdetect_command(ARGV, options, logger)) do |stdin, stdout, stderr, thread|
384
- stdin.close
385
- stdout.close
386
- stderr.each(chomp: true) do |line|
387
- logger.info(line) if options[:verbose] unless options[:quiet]
388
- options[:cropdetect] = line.match('crop=([0-9]+\:[0-9]+\:[0-9]+\:[0-9]+)') if line.include?('Parsed_cropdetect')
389
- end
390
- stderr.close
391
-
392
- raise "Process #{thread.pid} failed! Try again with --verbose to see error." unless thread.value.success?
393
- end
394
- end
395
-
396
- Open3.popen3(*build_ffmpeg_gif_command(ARGV, options, logger)) do |stdin, stdout, stderr, thread|
397
- stdin.close
398
- stdout.close
399
- stderr.each(chomp: true) do |line|
400
- logger.info(line) if options[:verbose] unless options[:quiet]
401
- end
402
- stderr.close
403
4
 
404
- raise "Process #{thread.pid} failed! Try again with --verbose to see error." unless thread.value.success?
405
- end
406
- end
407
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: video2gif
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emily St.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2019-04-18 00:00:00.000000000 Z
11
+ date: 2019-04-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -87,6 +87,10 @@ files:
87
87
  - bin/setup
88
88
  - exe/video2gif
89
89
  - lib/video2gif.rb
90
+ - lib/video2gif/cli.rb
91
+ - lib/video2gif/ffmpeg.rb
92
+ - lib/video2gif/options.rb
93
+ - lib/video2gif/utils.rb
90
94
  - lib/video2gif/version.rb
91
95
  - video2gif.gemspec
92
96
  homepage: https://github.com/emilyst/video2gif