video2gif 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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