wgif 0.0.1.pre
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.ruby-version +1 -0
- data/.travis.yml +11 -0
- data/Brewfile +2 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +90 -0
- data/Rakefile +5 -0
- data/bin/wgif +4 -0
- data/lib/wgif.rb +9 -0
- data/lib/wgif/cli.rb +125 -0
- data/lib/wgif/download_bar.rb +28 -0
- data/lib/wgif/downloader.rb +84 -0
- data/lib/wgif/exceptions.rb +24 -0
- data/lib/wgif/gif_maker.rb +19 -0
- data/lib/wgif/installer.rb +34 -0
- data/lib/wgif/version.rb +3 -0
- data/lib/wgif/video.rb +58 -0
- data/lib/wgif/video_cache.rb +18 -0
- data/spec/spec_helper.rb +8 -0
- data/spec/wgif/cli_spec.rb +191 -0
- data/spec/wgif/download_bar_spec.rb +38 -0
- data/spec/wgif/downloader_spec.rb +72 -0
- data/spec/wgif/gif_maker_spec.rb +26 -0
- data/spec/wgif/installer_spec.rb +82 -0
- data/spec/wgif/video_cache_spec.rb +16 -0
- data/spec/wgif/video_spec.rb +76 -0
- data/wgif.gemspec +34 -0
- metadata +222 -0
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'RMagick'
|
2
|
+
|
3
|
+
module WGif
|
4
|
+
class GifMaker
|
5
|
+
def make_gif(frames_dir, filename, dimensions)
|
6
|
+
image = Magick::ImageList.new(*frames_dir)
|
7
|
+
resize(image, dimensions)
|
8
|
+
image.coalesce
|
9
|
+
image.optimize_layers Magick::OptimizeLayer
|
10
|
+
image.write(filename)
|
11
|
+
end
|
12
|
+
|
13
|
+
def resize(image, dimensions)
|
14
|
+
image.each do |frame|
|
15
|
+
frame.change_geometry(dimensions) { |cols, rows, img| img.resize!(cols, rows) }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module WGif
|
2
|
+
class Installer
|
3
|
+
|
4
|
+
DEPENDENCIES = [['ffmpeg', 'ffmpeg'], ['imagemagick', 'convert']]
|
5
|
+
|
6
|
+
def run
|
7
|
+
if homebrew_installed?
|
8
|
+
DEPENDENCIES.each do |dependency, binary|
|
9
|
+
install(dependency, binary)
|
10
|
+
end
|
11
|
+
else
|
12
|
+
puts "WGif can't find Homebrew. Visit http://brew.sh/ to get it."
|
13
|
+
Kernel.exit 0
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def homebrew_installed?
|
18
|
+
Kernel.system 'brew info > /dev/null'
|
19
|
+
end
|
20
|
+
|
21
|
+
def install(dependency, binary)
|
22
|
+
unless installed?(binary)
|
23
|
+
puts "Installing #{dependency}..."
|
24
|
+
Kernel.system "brew install #{dependency} > /dev/null"
|
25
|
+
puts "Successfully installed #{dependency}."
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def installed?(binary)
|
30
|
+
Kernel.system "which #{binary} > /dev/null"
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
end
|
data/lib/wgif/version.rb
ADDED
data/lib/wgif/video.rb
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'streamio-ffmpeg'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
module WGif
|
5
|
+
class Video
|
6
|
+
attr_accessor :name, :clip, :logger
|
7
|
+
|
8
|
+
def initialize name, filepath
|
9
|
+
@name = name
|
10
|
+
@clip = FFMPEG::Movie.new(filepath)
|
11
|
+
FileUtils.mkdir_p "/tmp/wgif/"
|
12
|
+
@logger = Logger.new("/tmp/wgif/#{name}.log")
|
13
|
+
FFMPEG.logger = @logger
|
14
|
+
end
|
15
|
+
|
16
|
+
def trim start_timestamp, duration
|
17
|
+
options = {
|
18
|
+
audio_codec: "copy",
|
19
|
+
video_codec: "copy",
|
20
|
+
custom: "-ss #{start_timestamp} -t 00:00:#{'%06.3f' % duration}"
|
21
|
+
}
|
22
|
+
trimmed = transcode(@clip, "/tmp/wgif/#{@name}-clip.mov", options)
|
23
|
+
WGif::Video.new "#{@name}-clip", "/tmp/wgif/#{@name}-clip.mov"
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_frames(options={})
|
27
|
+
make_frame_dir
|
28
|
+
if options[:frames]
|
29
|
+
framerate = options[:frames] / @clip.duration
|
30
|
+
else
|
31
|
+
framerate = 24
|
32
|
+
end
|
33
|
+
transcode(@clip, "/tmp/wgif/frames/\%2d.png", "-vf fps=#{framerate}")
|
34
|
+
open_frame_dir
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def make_frame_dir
|
40
|
+
FileUtils.rm Dir.glob("/tmp/wgif/frames/*.png")
|
41
|
+
FileUtils.mkdir_p "/tmp/wgif/frames"
|
42
|
+
end
|
43
|
+
|
44
|
+
def open_frame_dir
|
45
|
+
Dir.glob("/tmp/wgif/frames/*.png")
|
46
|
+
end
|
47
|
+
|
48
|
+
def transcode(clip, file, options)
|
49
|
+
begin
|
50
|
+
clip.transcode(file, options)
|
51
|
+
rescue FFMPEG::Error => error
|
52
|
+
raise WGif::ClipEncodingException unless error.message.include? "no output file created"
|
53
|
+
raise WGif::ClipEncodingException if error.message.include? "Invalid data found when processing input"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
|
3
|
+
module WGif
|
4
|
+
class VideoCache
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@cache = {}
|
8
|
+
end
|
9
|
+
|
10
|
+
def get(video_id)
|
11
|
+
path = "/tmp/wgif/#{video_id}"
|
12
|
+
if Pathname.new(path).exist?
|
13
|
+
WGif::Video.new(video_id, path)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,191 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'wgif/cli'
|
3
|
+
|
4
|
+
describe WGif::CLI do
|
5
|
+
let(:cli) { described_class.new }
|
6
|
+
|
7
|
+
it 'parses a URL from command line args' do
|
8
|
+
args = cli.parse_args ["http://example.com"]
|
9
|
+
args[:url].should eq("http://example.com")
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'starts at 0s by default' do
|
13
|
+
args = cli.parse_args ["http://example.com"]
|
14
|
+
args[:trim_from].should eq("00:00:00")
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'trims clips to 1s by default' do
|
18
|
+
args = cli.parse_args ["http://example.com"]
|
19
|
+
args[:duration].should eq(1)
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'parses the short frame count option' do
|
23
|
+
options = cli.parse_options ["-f", "40"]
|
24
|
+
options[:frames].should eq(40)
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'parses the long frame count option' do
|
28
|
+
options = cli.parse_options ["--frames", "40"]
|
29
|
+
options[:frames].should eq(40)
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'parses the short start time option' do
|
33
|
+
options = cli.parse_options ["-s", "00:00:05"]
|
34
|
+
options[:trim_from].should eq("00:00:05")
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'parses the long start time option' do
|
38
|
+
options = cli.parse_options ["--start", "00:00:05"]
|
39
|
+
options[:trim_from].should eq("00:00:05")
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'parses the short duration option' do
|
43
|
+
options = cli.parse_options ["-d", "1.43"]
|
44
|
+
options[:duration].should eq(1.43)
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'parses the long duration option' do
|
48
|
+
options = cli.parse_options ["--duration", "5.3"]
|
49
|
+
options[:duration].should eq(5.3)
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'parses the short dimensions option' do
|
53
|
+
options = cli.parse_options ["-w", "400"]
|
54
|
+
expect(options[:dimensions]).to eq("400")
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'parses the long dimensions option' do
|
58
|
+
options = cli.parse_options ["--width", "300"]
|
59
|
+
expect(options[:dimensions]).to eq("300")
|
60
|
+
end
|
61
|
+
|
62
|
+
it 'handles args in wacky order' do
|
63
|
+
args = cli.parse_args([
|
64
|
+
"-d",
|
65
|
+
"1.5",
|
66
|
+
"http://example.com",
|
67
|
+
"--frames",
|
68
|
+
"60",
|
69
|
+
"my-great-gif.gif",
|
70
|
+
"-s",
|
71
|
+
"00:00:05"])
|
72
|
+
|
73
|
+
expect(args).to eq(url: "http://example.com",
|
74
|
+
trim_from: "00:00:05",
|
75
|
+
duration: 1.5,
|
76
|
+
frames: 60,
|
77
|
+
output: "my-great-gif.gif",
|
78
|
+
dimensions: "480")
|
79
|
+
end
|
80
|
+
|
81
|
+
context 'validating args' do
|
82
|
+
|
83
|
+
it 'checks for a missing output file' do
|
84
|
+
args = cli.parse_args([
|
85
|
+
"http://example.com",
|
86
|
+
])
|
87
|
+
expect{ cli.validate_args args }.to raise_error(WGif::MissingOutputFileException)
|
88
|
+
end
|
89
|
+
|
90
|
+
it 'checks for an invalid URL' do
|
91
|
+
args = cli.parse_args([
|
92
|
+
"crazy nonsense",
|
93
|
+
"output.gif"
|
94
|
+
])
|
95
|
+
expect{ cli.validate_args args }.to raise_error(WGif::InvalidUrlException)
|
96
|
+
end
|
97
|
+
|
98
|
+
it 'checks for an invalid timestamp' do
|
99
|
+
args = cli.parse_args([
|
100
|
+
"http://lol.wut",
|
101
|
+
"output.gif",
|
102
|
+
"-s",
|
103
|
+
"rofl"
|
104
|
+
])
|
105
|
+
expect{ cli.validate_args args }.to raise_error(WGif::InvalidTimestampException)
|
106
|
+
end
|
107
|
+
|
108
|
+
it 'returns true when args are OK' do
|
109
|
+
args = cli.parse_args([
|
110
|
+
"https://crazynonsense.info",
|
111
|
+
"output.gif"
|
112
|
+
])
|
113
|
+
expect{ cli.validate_args args }.not_to raise_error
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
context 'error handling' do
|
118
|
+
|
119
|
+
before do
|
120
|
+
@mock_stdout = StringIO.new
|
121
|
+
@real_stdout, $stdout = $stdout, @mock_stdout
|
122
|
+
end
|
123
|
+
|
124
|
+
after do
|
125
|
+
$stdout = @real_stdout
|
126
|
+
end
|
127
|
+
|
128
|
+
def expect_help_with_message(out, message)
|
129
|
+
expect(out).to include(message)
|
130
|
+
expect(out).to include('Usage: wgif [YouTube URL] [output file] [options]')
|
131
|
+
cli.parser.summarize.each do |help_info|
|
132
|
+
expect(out).to include(help_info)
|
133
|
+
end
|
134
|
+
expect(out).to include('Example:')
|
135
|
+
end
|
136
|
+
|
137
|
+
it 'catches invalid URLs' do
|
138
|
+
OptionParser.any_instance.stub(:parse!).and_raise(WGif::InvalidUrlException)
|
139
|
+
expect{ cli.make_gif([]) }.to raise_error(SystemExit)
|
140
|
+
expect_help_with_message(@mock_stdout.string, 'That looks like an invalid URL. Check the syntax.')
|
141
|
+
end
|
142
|
+
|
143
|
+
it 'catches invalid timestamps' do
|
144
|
+
OptionParser.any_instance.stub(:parse!).and_raise(WGif::InvalidTimestampException)
|
145
|
+
expect{ cli.make_gif([]) }.to raise_error(SystemExit)
|
146
|
+
expect_help_with_message(@mock_stdout.string, 'That looks like an invalid timestamp. Check the syntax.')
|
147
|
+
end
|
148
|
+
|
149
|
+
it 'catches missing output args' do
|
150
|
+
OptionParser.any_instance.stub(:parse!).and_raise(WGif::MissingOutputFileException)
|
151
|
+
expect{ cli.make_gif([]) }.to raise_error(SystemExit)
|
152
|
+
expect_help_with_message(@mock_stdout.string, 'Please specify an output file.')
|
153
|
+
end
|
154
|
+
|
155
|
+
it 'catches missing videos' do
|
156
|
+
OptionParser.any_instance.stub(:parse!).and_raise(WGif::VideoNotFoundException)
|
157
|
+
expect{ cli.make_gif([]) }.to raise_error(SystemExit)
|
158
|
+
expect_help_with_message(@mock_stdout.string, "WGif can't find a valid YouTube video at that URL.")
|
159
|
+
end
|
160
|
+
|
161
|
+
it 'catches encoding exceptions' do
|
162
|
+
OptionParser.any_instance.stub(:parse!).and_raise(WGif::ClipEncodingException)
|
163
|
+
expect{ cli.make_gif([]) }.to raise_error(SystemExit)
|
164
|
+
expect_help_with_message(@mock_stdout.string, "WGif encountered an error transcoding the video.")
|
165
|
+
end
|
166
|
+
|
167
|
+
it 'raises SystemExit when thrown' do
|
168
|
+
OptionParser.any_instance.stub(:parse!).and_raise(SystemExit)
|
169
|
+
expect{ cli.make_gif([]) }.to raise_error(SystemExit)
|
170
|
+
end
|
171
|
+
|
172
|
+
it 'Prints the backtrace for all other exceptions' do
|
173
|
+
exception = Exception.new 'crazy error'
|
174
|
+
OptionParser.any_instance.stub(:parse!).and_raise(exception)
|
175
|
+
expect{ cli.make_gif([]) }.to raise_error(SystemExit)
|
176
|
+
expect_help_with_message(@mock_stdout.string, 'Something went wrong creating your GIF. The details:')
|
177
|
+
expect(@mock_stdout.string).to include('Please open an issue')
|
178
|
+
expect(@mock_stdout.string).to include("#{exception}")
|
179
|
+
expect(@mock_stdout.string).to include(exception.backtrace.join("\n"))
|
180
|
+
end
|
181
|
+
|
182
|
+
it 'prints help information' do
|
183
|
+
expect{ cli.make_gif(['-h']) }.to raise_error(SystemExit)
|
184
|
+
cli.parser.summarize.each do |help_info|
|
185
|
+
expect(@mock_stdout.string).to include(help_info)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
end
|
190
|
+
|
191
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'wgif/download_bar'
|
3
|
+
|
4
|
+
describe WGif::DownloadBar do
|
5
|
+
|
6
|
+
context 'setup' do
|
7
|
+
|
8
|
+
let(:download_bar) { described_class.new }
|
9
|
+
let(:mock_progress_bar) { double(ProgressBar) }
|
10
|
+
|
11
|
+
before do
|
12
|
+
ProgressBar.stub(:create).and_return(mock_progress_bar)
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'creates a ProgressBar with the correct format, smoothing, and size' do
|
16
|
+
progress_bar_params = {
|
17
|
+
format: '==> %p%% |%B|',
|
18
|
+
smoothing: 0.8,
|
19
|
+
total: nil
|
20
|
+
}
|
21
|
+
expect(ProgressBar).to receive(:create).with(progress_bar_params)
|
22
|
+
described_class.new
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'updates the total size' do
|
26
|
+
expect(mock_progress_bar).to receive(:total=).with(500)
|
27
|
+
download_bar.update_total(500)
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'increments the current progress' do
|
31
|
+
expect(mock_progress_bar).to receive(:progress).and_return(1)
|
32
|
+
expect(mock_progress_bar).to receive(:progress=)
|
33
|
+
download_bar.increment_progress(100)
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'wgif/downloader'
|
3
|
+
require 'wgif/video'
|
4
|
+
|
5
|
+
describe WGif::Downloader do
|
6
|
+
let(:downloader) { described_class.new }
|
7
|
+
let(:clip_url) { 'http://lol.wut/watch?v=roflcopter' }
|
8
|
+
|
9
|
+
before do
|
10
|
+
FileUtils.rm_rf('/tmp/wgif')
|
11
|
+
download_bar = double(WGif::DownloadBar).as_null_object
|
12
|
+
WGif::DownloadBar.stub(:new).and_return(download_bar)
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'retrieves a YouTube download URL' do
|
16
|
+
ViddlRb.should_receive(:get_urls).with(clip_url).and_return(['clip url'])
|
17
|
+
downloader.video_url(clip_url).should eq('clip url')
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'retrieves a YouTube video ID' do
|
21
|
+
downloader.video_id(clip_url).should eq('roflcopter')
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'throws an error if the video is not found' do
|
25
|
+
ViddlRb.should_receive(:get_urls).with(clip_url).and_return(['http://lol.wut'])
|
26
|
+
expect{ downloader.get_video(clip_url) }.to raise_error(WGif::VideoNotFoundException)
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'extracts a YouTube ID from a URL' do
|
30
|
+
downloader.video_id("https://www.youtube.com/watch?v=tmNXKqeUtJM").should eq("tmNXKqeUtJM")
|
31
|
+
end
|
32
|
+
|
33
|
+
context 'downloading videos' do
|
34
|
+
|
35
|
+
before do
|
36
|
+
ViddlRb.stub(:get_urls).and_return([clip_url])
|
37
|
+
fake_request = double('Typhoeus::Request')
|
38
|
+
fake_response = double('Typhoeus::Response')
|
39
|
+
Typhoeus::Request.should_receive(:new).once.with(clip_url).and_return(fake_request)
|
40
|
+
fake_request.should_receive(:on_headers)
|
41
|
+
fake_request.should_receive(:on_body)
|
42
|
+
fake_request.should_receive(:run).and_return(fake_response)
|
43
|
+
fake_response.should_receive(:response_code).and_return(200)
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'downloads a clip' do
|
47
|
+
video = double(name: 'video')
|
48
|
+
WGif::Video.should_receive(:new).with('roflcopter', "/tmp/wgif/roflcopter").
|
49
|
+
and_return(video)
|
50
|
+
video = downloader.get_video(clip_url)
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'does not download the clip when already cached' do
|
54
|
+
downloader.get_video(clip_url)
|
55
|
+
downloader.get_video(clip_url)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
context 'errors' do
|
60
|
+
|
61
|
+
it 'throws an exception when the download URL is not found' do
|
62
|
+
ViddlRb.stub(:get_urls).and_raise(RuntimeError)
|
63
|
+
expect{ downloader.video_url('invalid url') }.to raise_error(WGif::VideoNotFoundException)
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'throws an exception when the download URL is invalid' do
|
67
|
+
expect{ downloader.video_id(nil) }.to raise_error(WGif::InvalidUrlException)
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
71
|
+
|
72
|
+
end
|