wgif 0.0.1.pre
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 +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
|