meme_captain 0.0.8 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore CHANGED
@@ -1 +1,2 @@
1
- img_cache/*
1
+ img_cache/*
2
+ *~
data/ChangeLog CHANGED
@@ -1,3 +1,36 @@
1
+ 0.0.9 2012-01-19
2
+
3
+ Design and markup improvements on the site.
4
+
5
+ Allow use of all type metrics when comparing caption choices.
6
+
7
+ Add ability to use the ids in the URLs of generated images to make
8
+ new images from the same source by using the id as the source image
9
+ URL (an example id is abcdef.jpg).
10
+
11
+ Add a 404 page to the site.
12
+
13
+ Deprecate concept of temporary URLs and permanent URLs for images
14
+ generated from the site.
15
+
16
+ Use MongoDB for the site datastore.
17
+
18
+ Add "memecaptain" executable for creating memes using the gem from the
19
+ command line.
20
+
21
+ Add a watermark to images created on the site.
22
+
23
+ Shrink large source images to a maximum of 800 pixels per side for
24
+ images created on the site.
25
+
26
+ Define local source images in JSON instead of in the template and load
27
+ them with Ajax.
28
+
29
+ Add Google Image Search to the site.
30
+
31
+ Add new source images to the site: all the things 2, cool story bro,
32
+ aw yeah, Boromir, Ned Stark.
33
+
1
34
  0.0.8
2
35
  2011-11-23
3
36
 
data/README.md CHANGED
@@ -47,6 +47,10 @@ Example:
47
47
  http://memecaptain.com/g?u=http%3A%2F%2Fmemecaptain.com%2Fyao_ming.jpg&tt=sure+i%27ll+test&tb=the+api
48
48
  ```
49
49
 
50
+ Note: tempUrl is deprecated and will now always be the same as permUrl. It is
51
+ left for compability with older clients.
52
+
53
+
50
54
  ```json
51
55
  {
52
56
  permUrl: "http://memecaptain.com/i?u=http%3A%2F%2Fmemecaptain.com%2Fyao_ming.jpg&tt=sure+i%27ll+test&tb=the+api"
data/bin/memecaptain ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'mime/types'
4
+ require 'open-uri'
5
+ require 'optparse'
6
+
7
+ require 'meme_captain'
8
+
9
+ options = {}
10
+
11
+ option_parser = OptionParser.new do |opts|
12
+ opts.banner = <<-eos
13
+ Usage: memecaptain INPUT_IMAGE [OPTIONS]
14
+
15
+ INPUT_IMAGE can be a file path, URL or - for stdin.
16
+
17
+ eos
18
+
19
+ opts.on('-t', '--top-text TEXT', 'top text') do |text|
20
+ options[:top_text] = text
21
+ end
22
+
23
+ opts.on('-b', '--bottom-text TEXT', 'bottom text') do |text|
24
+ options[:bottom_text] = text
25
+ end
26
+
27
+ opts.on('-o', '--output PATH', 'output path (- for stdout)') do |path|
28
+ options[:output] = path
29
+ end
30
+
31
+ opts.on('-f', '--font FONT', 'font') do |font|
32
+ options[:font] = font
33
+ end
34
+ end
35
+
36
+ option_parser.parse!
37
+
38
+ unless ARGV.empty?
39
+ input = ARGV[0]
40
+
41
+ input_io = if input == '-'
42
+ $stdin
43
+ else
44
+ open input, 'rb'
45
+ end
46
+
47
+ meme_options = options.select do |k,v|
48
+ [
49
+ :font,
50
+ ].include? k
51
+ end
52
+
53
+ output_image = MemeCaptain.meme(
54
+ input_io, options[:top_text], options[:bottom_text], meme_options)
55
+
56
+ input_io.close
57
+
58
+ output = if options[:output]
59
+ options[:output] == '-' ? $stdout : options[:output]
60
+ else
61
+ "meme.#{MIME::Types[output_image.mime_type][0].extensions[0]}"
62
+ end
63
+
64
+ output_image.write(output) { self.quality = 100 }
65
+ else
66
+ $stderr.puts option_parser
67
+ exit 1
68
+ end
data/config.ru CHANGED
@@ -1,5 +1,7 @@
1
1
  $:.unshift(File.join(File.dirname(__FILE__), 'lib'))
2
2
 
3
+ require 'mongo'
4
+ require 'mongo_mapper'
3
5
  require 'rack'
4
6
 
5
7
  require 'meme_captain'
@@ -9,4 +11,15 @@ use Rack::Sendfile
9
11
 
10
12
  use Rack::Static, :urls => %w{/tmp}, :root => 'public'
11
13
 
14
+ MongoMapper.connection = Mongo::Connection.new
15
+ MongoMapper.database = 'memecaptain'
16
+
17
+ MemeCaptain::MemeData.ensure_index :meme_id
18
+
19
+ MemeCaptain::MemeData.ensure_index [
20
+ [:source_url, 1],
21
+ [:top_text, 1],
22
+ [:bottom_text, 1],
23
+ ]
24
+
12
25
  run MemeCaptain::Server
@@ -4,18 +4,24 @@ module MemeCaptain
4
4
  class CaptionChoice
5
5
  include Comparable
6
6
 
7
- def initialize(fits, pointsize, text)
8
- @fits = fits
7
+ def initialize(pointsize, metrics, text, bound_width, bound_height)
9
8
  @pointsize = pointsize
9
+ @metrics = metrics
10
10
  @text = text
11
+ @bound_width = bound_width
12
+ @bound_height = bound_height
11
13
  end
12
14
 
13
15
  def num_lines
14
16
  text.count("\n") + 1
15
17
  end
16
18
 
19
+ def fits
20
+ metrics.width <= bound_width and metrics.height <= bound_height
21
+ end
22
+
17
23
  def fits_i
18
- fits ? 1: 0
24
+ fits ? 1 : 0
19
25
  end
20
26
 
21
27
  def <=>(other)
@@ -24,10 +30,11 @@ module MemeCaptain
24
30
  other.fits ? -other.num_lines : other.num_lines]
25
31
  end
26
32
 
27
- attr_accessor :fits
28
33
  attr_accessor :pointsize
34
+ attr_accessor :metrics
29
35
  attr_accessor :text
30
-
36
+ attr_accessor :bound_width
37
+ attr_accessor :bound_height
31
38
  end
32
39
 
33
40
  end
@@ -6,20 +6,22 @@ module MemeCaptain
6
6
  # Calculate the largest pointsize for text that will be in a width x
7
7
  # height box.
8
8
  #
9
- # Return [pointsize, fits] where pointsize is the largest pointsize and
10
- # fits is true if that pointsize will fit in the box.
9
+ # Return [pointsize, metrics] where pointsize is the largest pointsize and
10
+ # metrics is the RMagick multiline type metrics of the best fit.
11
11
  def calc_pointsize(width, height, text, min_pointsize)
12
12
  current_pointsize = min_pointsize
13
13
 
14
- fits = false
14
+ metrics = nil
15
15
 
16
16
  loop {
17
17
  self.pointsize = current_pointsize
18
+ last_metrics = metrics
18
19
  metrics = get_multiline_type_metrics(text)
20
+
19
21
  if metrics.width > width or metrics.height > height
20
22
  if current_pointsize > min_pointsize
21
23
  current_pointsize -= 1
22
- fits = true
24
+ metrics = last_metrics
23
25
  end
24
26
  break
25
27
  else
@@ -27,7 +29,7 @@ module MemeCaptain
27
29
  end
28
30
  }
29
31
 
30
- [current_pointsize, fits]
32
+ [current_pointsize, metrics]
31
33
  end
32
34
 
33
35
  end
@@ -0,0 +1,49 @@
1
+ require 'digest/sha1'
2
+ require 'fileutils'
3
+ require 'mime/types'
4
+
5
+ module MemeCaptain
6
+
7
+ module ImageList
8
+
9
+ # Mix-in for Magick::ImageList to add saving to the filesystem based on a
10
+ # hash.
11
+ module Cache
12
+
13
+ # Get the extension for this image.
14
+ def extension
15
+ {
16
+ 'image/jpeg' => 'jpg',
17
+ }[mime_type] || MIME::Types[mime_type][0].extensions[0]
18
+ end
19
+
20
+ # Store this image in the filesystem and return its path.
21
+ def cache(hash_base, dir)
22
+ hashe = Digest::SHA1.hexdigest(hash_base)
23
+
24
+ cache_dir = File.join(dir, hashe[0,3])
25
+ FileUtils.mkdir_p cache_dir
26
+
27
+ file_part = hashe[3..-1]
28
+ fs_path = File.join(cache_dir, "#{file_part}.#{extension}")
29
+
30
+ # If there is a collision add 0's until the filename is unique.
31
+ zeroes = 0
32
+ while File.exist? fs_path
33
+ zeroes += 1
34
+ fs_path = File.join(cache_dir,
35
+ "#{file_part}#{'0' * zeroes}.#{extension}")
36
+ end
37
+
38
+ write(fs_path) {
39
+ self.quality = 100
40
+ }
41
+
42
+ fs_path
43
+ end
44
+
45
+ end
46
+
47
+ end
48
+
49
+ end
@@ -0,0 +1,26 @@
1
+ require 'curb'
2
+
3
+ module MemeCaptain
4
+
5
+ module ImageList
6
+
7
+ # Mix-in for Magick::ImageList to add loading from a URL.
8
+ module Fetch
9
+
10
+ # Load this image from a URL.
11
+ def fetch!(url)
12
+ curl = Curl::Easy.perform(url) do |c|
13
+ c.useragent = 'Meme Captain http://memecaptain.com/'
14
+ end
15
+ unless curl.response_code == 200
16
+ raise "Error loading source image url #{url}"
17
+ end
18
+
19
+ from_blob curl.body_str
20
+ end
21
+
22
+ end
23
+
24
+ end
25
+
26
+ end
@@ -0,0 +1,28 @@
1
+ require 'RMagick'
2
+
3
+ module MemeCaptain
4
+
5
+ module ImageList
6
+
7
+ # Source image for meme generation.
8
+ class SourceImage < Magick::ImageList
9
+ include Cache
10
+ include Fetch
11
+ include Watermark
12
+
13
+ # Shrink image if necessary and add watermark.
14
+ def prepare!(max_side, watermark_img)
15
+ if size == 1 and (columns > max_side or rows > max_side)
16
+ resize_to_fit! max_side
17
+ end
18
+
19
+ watermark_mc watermark_img
20
+
21
+ strip!
22
+ end
23
+
24
+ end
25
+
26
+ end
27
+
28
+ end
@@ -0,0 +1,24 @@
1
+ require 'RMagick'
2
+
3
+ module MemeCaptain
4
+
5
+ module ImageList
6
+
7
+ # Mix-in for Magick::ImageList to add watermark.
8
+ module Watermark
9
+
10
+ # Watermark this image using another image.
11
+ def watermark_mc(watermark_img)
12
+ self.each do |frame|
13
+ frame.composite!(watermark_img, Magick::SouthEastGravity,
14
+ -frame.page.width + frame.columns + frame.page.x,
15
+ -frame.page.height + frame.rows + frame.page.y,
16
+ Magick::OverCompositeOp)
17
+ end
18
+ end
19
+
20
+ end
21
+
22
+ end
23
+
24
+ end
@@ -0,0 +1,5 @@
1
+ require 'meme_captain/image_list/cache'
2
+ require 'meme_captain/image_list/fetch'
3
+ require 'meme_captain/image_list/watermark'
4
+
5
+ require 'meme_captain/image_list/source_image'
@@ -69,10 +69,11 @@ module MemeCaptain
69
69
  }.uniq
70
70
 
71
71
  choices = wrap_tries.map do |wrap_try|
72
- pointsize, fits = draw.calc_pointsize(
72
+ pointsize, metrics = draw.calc_pointsize(
73
73
  text_width, text_height, wrap_try, min_pointsize)
74
74
 
75
- CaptionChoice.new(fits, pointsize, wrap_try)
75
+ CaptionChoice.new(pointsize, metrics, wrap_try, text_width,
76
+ text_height)
76
77
  end
77
78
 
78
79
  choice = choices.max
@@ -0,0 +1,34 @@
1
+ require 'mongo_mapper'
2
+
3
+ module MemeCaptain
4
+
5
+ class MemeData
6
+ include MongoMapper::Document
7
+
8
+ set_collection_name 'meme'
9
+
10
+ key :meme_id, String
11
+ key :fs_path, String
12
+ key :mime_type, String
13
+ key :size, Integer
14
+
15
+ key :source_url, String
16
+ key :source_fs_path, String
17
+ key :top_text, String
18
+ key :bottom_text, String
19
+
20
+ key :request_count, Integer
21
+ key :last_request, Time
22
+
23
+ key :creator_ip, String
24
+
25
+ timestamps!
26
+
27
+ def requested!
28
+ increment :request_count => 1
29
+ set :last_request => Time.now
30
+ end
31
+
32
+ end
33
+
34
+ end
@@ -1,84 +1,167 @@
1
1
  require 'digest/sha1'
2
- require 'uri'
3
2
 
4
- require 'curb'
5
3
  require 'json'
4
+ require 'rack'
6
5
  require 'sinatra/base'
7
6
 
8
- require 'meme_captain'
9
-
10
7
  module MemeCaptain
11
8
 
12
9
  class Server < Sinatra::Base
13
10
 
14
- ImageExts = %w{.jpeg .gif .png}
15
-
16
- set :root, File.join(File.dirname(__FILE__), '..', '..')
11
+ set :root, File.expand_path(File.join('..', '..'), File.dirname(__FILE__))
12
+ set :source_img_max_side, 800
13
+ set :watermark, Magick::ImageList.new(File.expand_path(
14
+ File.join('..', '..', 'watermark.png'), File.dirname(__FILE__)))
17
15
 
18
16
  get '/' do
19
17
  @u = params[:u]
20
18
  @tt= params[:tt]
21
19
  @tb = params[:tb]
22
20
 
21
+ @root_url = url('/')
22
+
23
23
  erb :index
24
24
  end
25
25
 
26
- def gen(params)
27
- @processed_cache ||= MemeCaptain::FilesystemCache.new('public/tmp')
28
- @source_cache ||= MemeCaptain::FilesystemCache.new('img_cache/source')
26
+ def normalize_params(p)
27
+ result = {
28
+ 'u' => p[:u],
29
+ # convert to empty string if null
30
+ 'tt' => p[:tt].to_s,
31
+ 'tb' => p[:tb].to_s,
32
+ }
29
33
 
30
- processed_id = Digest::SHA1.hexdigest(params.sort.map(&:join).join)
31
- @processed_cache.get_path(processed_id, ImageExts) {
32
- source_id = Digest::SHA1.hexdigest(params[:u])
33
- source_img_data = @source_cache.get_data(source_id, ImageExts) {
34
- curl = Curl::Easy.perform(params[:u]) do |c|
35
- c.useragent = 'Meme Captain http://memecaptain.com/'
36
- end
37
- unless curl.response_code == 200
38
- raise "Error loading source image url #{params[:u]}"
39
- end
40
- curl.body_str
41
- }
34
+ # if the id of an existing meme is passed in as the source url, use the
35
+ # source image of that meme for the source image
36
+ if result['u'][%r{^[a-f0-9]+\.(?:gif|jpg|png)$}]
37
+ if existing_as_source = MemeData.find_by_meme_id(result['u'])
38
+ result['u'] = existing_as_source.source_url
39
+ end
40
+ end
41
+
42
+ # hash with string keys that can be accessed by symbol
43
+ Hash.new { |hash,key| hash[key.to_s] if Symbol === key }.merge(result)
44
+ end
42
45
 
43
- meme_img = MemeCaptain.meme(source_img_data, params[:tt], params[:tb])
44
- current_format = meme_img.format
46
+ def gen(p)
47
+ norm_params = normalize_params(p)
48
+
49
+ if existing = MemeData.first(
50
+ :source_url => norm_params[:u],
51
+ :top_text => norm_params[:tt],
52
+ :bottom_text => norm_params[:tb]
53
+ )
54
+ existing
55
+ else
56
+ if same_source = MemeData.find_by_source_url(norm_params[:u])
57
+ source_fs_path = same_source.source_fs_path
58
+ else
59
+ source_img = ImageList::SourceImage.new
60
+ source_img.fetch! norm_params[:u]
61
+ source_img.prepare! settings.source_img_max_side, settings.watermark
62
+ source_fs_path = source_img.cache(norm_params[:u], 'source_cache')
63
+ end
64
+
65
+ open(source_fs_path, 'rb') do |source_io|
66
+ meme_img = MemeCaptain.meme(source_io, norm_params[:tt],
67
+ norm_params[:tb])
68
+ meme_img.extend ImageList::Cache
45
69
 
46
- meme_img.to_blob {
47
- self.quality = 100
48
70
  # convert non-animated gifs to png
49
- if current_format == 'GIF' and meme_img.size == 1
50
- self.format = 'PNG'
71
+ if meme_img.format == 'GIF' and meme_img.size == 1
72
+ meme_img.format = 'PNG'
51
73
  end
52
- }
53
- }
74
+
75
+ params_s = norm_params.sort.map(&:join).join
76
+ meme_hash = Digest::SHA1.hexdigest(params_s)
77
+
78
+ meme_id = nil
79
+ (6..meme_hash.size).each do |len|
80
+ meme_id = "#{meme_hash[0,len]}.#{meme_img.extension}"
81
+ break unless MemeData.where(:meme_id => meme_id).count > 0
82
+ end
83
+
84
+ meme_fs_path = meme_img.cache(params_s, File.join('public', 'meme'))
85
+
86
+ meme_img.write(meme_fs_path) {
87
+ self.quality = 100
88
+ }
89
+
90
+ meme_data = MemeData.new(
91
+ :meme_id => meme_id,
92
+ :fs_path => meme_fs_path,
93
+ :mime_type => meme_img.mime_type,
94
+ :size => File.size(meme_fs_path),
95
+
96
+ :source_url => norm_params[:u],
97
+ :source_fs_path => source_fs_path,
98
+ :top_text => norm_params[:tt],
99
+ :bottom_text => norm_params[:tb],
100
+
101
+ :request_count => 0,
102
+
103
+ :creator_ip => request.ip
104
+ )
105
+
106
+ meme_data.save! :safe => true
107
+
108
+ meme_data
109
+ end
110
+
111
+ end
54
112
  end
55
113
 
56
114
  get '/g' do
115
+ raise Sinatra::NotFound if params[:u].to_s.empty?
116
+
57
117
  begin
58
- processed_cache_path = gen(params)
118
+ meme_data = gen(params)
59
119
 
60
- temp_url = URI(request.url)
61
- temp_url.path = processed_cache_path.sub('public', '')
62
- temp_url.query = nil
120
+ meme_url = url("/#{meme_data.meme_id}")
63
121
 
64
- perm_url = URI(request.url)
65
- perm_url.path = '/i'
122
+ template_query = [
123
+ [:u, meme_data.meme_id],
124
+ [:tt, meme_data.top_text],
125
+ [:tb, meme_data.bottom_text],
126
+ ].map { |k,v|
127
+ "#{Rack::Utils.escape(k)}=#{Rack::Utils.escape(v)}" }.join('&')
66
128
 
67
129
  [200, { 'Content-Type' => 'application/json' }, {
68
- 'tempUrl' => temp_url.to_s,
69
- 'permUrl' => perm_url.to_s,
130
+ 'tempUrl' => meme_url,
131
+ 'permUrl' => meme_url,
132
+ 'templateUrl' => url("/?#{template_query}"),
70
133
  }.to_json]
71
134
  rescue => error
72
135
  [500, { 'Content-Type' => 'text/plain' }, error.to_s]
73
136
  end
74
137
  end
75
138
 
139
+ def serve_img(meme_data)
140
+ meme_data.requested!
141
+
142
+ content_type meme_data.mime_type
143
+
144
+ FileBody.new meme_data.fs_path
145
+ end
146
+
76
147
  get '/i' do
77
- processed_cache_path = gen(params)
148
+ raise Sinatra::NotFound if params[:u].to_s.empty?
149
+
150
+ serve_img(gen(params))
151
+ end
152
+
153
+ get %r{^/([a-f0-9]+\.(?:gif|jpg|png))$} do
154
+ if meme_data = MemeData.find_by_meme_id(params[:captures][0])
155
+ serve_img meme_data
156
+ else
157
+ raise Sinatra::NotFound
158
+ end
159
+ end
78
160
 
79
- content_type MIME::Types.type_for(processed_cache_path)[0].to_s
161
+ not_found do
162
+ @root_url = url('/')
80
163
 
81
- MemeCaptain::FileBody.new(processed_cache_path)
164
+ erb :'404'
82
165
  end
83
166
 
84
167
  helpers do
@@ -1,3 +1,3 @@
1
1
  module MemeCaptain
2
- VERSION = '0.0.8'
2
+ VERSION = '0.0.9'
3
3
  end
data/lib/meme_captain.rb CHANGED
@@ -2,8 +2,8 @@ require 'meme_captain/caption'
2
2
  require 'meme_captain/caption_choice'
3
3
  require 'meme_captain/draw'
4
4
  require 'meme_captain/file_body'
5
- require 'meme_captain/filesystem_cache'
5
+ require 'meme_captain/image_list'
6
6
  require 'meme_captain/meme'
7
- require 'meme_captain/mime_type'
7
+ require 'meme_captain/meme_data'
8
8
  require 'meme_captain/server'
9
9
  require 'meme_captain/version'
data/meme_captain.gemspec CHANGED
@@ -15,13 +15,17 @@ Gem::Specification.new do |s|
15
15
  s.email = %w{matthewm@boedicker.org}
16
16
 
17
17
  %w{
18
+ bson_ext
18
19
  curb
19
20
  json
20
21
  mime-types
22
+ mongo
23
+ mongo_mapper
21
24
  rack
22
25
  rmagick
23
26
  sinatra
24
27
  }.each { |g| s.add_dependency g }
25
28
 
26
29
  s.files = `git ls-files`.split("\n")
30
+ s.executables = %w{memecaptain}
27
31
  end