coryodaniel-milton 0.3.7
Sign up to get free protection for your applications and to get access to all the features.
- data/MIT-LICENSE +20 -0
- data/README.markdown +350 -0
- data/Rakefile +9 -0
- data/init.rb +1 -0
- data/lib/milton.rb +111 -0
- data/lib/milton/attachment.rb +202 -0
- data/lib/milton/core/file.rb +22 -0
- data/lib/milton/core/tempfile.rb +44 -0
- data/lib/milton/derivatives/derivative.rb +106 -0
- data/lib/milton/derivatives/thumbnail.rb +39 -0
- data/lib/milton/derivatives/thumbnail/crop_calculator.rb +64 -0
- data/lib/milton/derivatives/thumbnail/image.rb +49 -0
- data/lib/milton/storage/disk_file.rb +84 -0
- data/lib/milton/storage/s3_file.rb +103 -0
- data/lib/milton/storage/stored_file.rb +46 -0
- data/lib/milton/uploading.rb +82 -0
- data/test/fixtures/big-milton.jpg +0 -0
- data/test/fixtures/milton.jpg +0 -0
- data/test/fixtures/mini-milton.jpg +0 -0
- data/test/fixtures/unsanitary .milton.jpg +0 -0
- data/test/milton/attachment_test.rb +359 -0
- data/test/milton/milton_test.rb +21 -0
- data/test/milton/resizing_test.rb +70 -0
- data/test/s3_helper.rb +59 -0
- data/test/schema.rb +13 -0
- data/test/test_helper.rb +78 -0
- metadata +87 -0
@@ -0,0 +1,202 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'milton/derivatives/derivative'
|
3
|
+
|
4
|
+
module Milton
|
5
|
+
module Attachment
|
6
|
+
# Call is_attachment with your options in order to add attachment
|
7
|
+
# functionality to your ActiveRecord model.
|
8
|
+
#
|
9
|
+
# TODO: list options
|
10
|
+
def is_attachment(options={})
|
11
|
+
# Check to see that it hasn't already been extended so that subclasses
|
12
|
+
# can redefine is_attachment from their superclasses and overwrite
|
13
|
+
# options w/o losing the superclass options.
|
14
|
+
unless respond_to?(:has_attachment_methods)
|
15
|
+
extend Milton::Attachment::AttachmentMethods
|
16
|
+
class_inheritable_accessor :milton_options
|
17
|
+
end
|
18
|
+
has_attachment_methods(options)
|
19
|
+
end
|
20
|
+
|
21
|
+
module AttachmentMethods
|
22
|
+
def require_column(column, message)
|
23
|
+
begin
|
24
|
+
raise message unless column_names.include?(column)
|
25
|
+
rescue ActiveRecord::StatementInvalid => i
|
26
|
+
# table doesn't exist yet, i.e. hasn't been migrated in...
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def has_attachment_methods(options={})
|
31
|
+
require_column 'filename', "Milton requires a filename column on #{class_name} table"
|
32
|
+
|
33
|
+
# It's possible that this is being included from a class and a sub
|
34
|
+
# class of that class, in which case we need to merge the existing
|
35
|
+
# options up.
|
36
|
+
self.milton_options ||= {}
|
37
|
+
milton_options.merge!(options)
|
38
|
+
|
39
|
+
# Character used to seperate a filename from its derivative options, this
|
40
|
+
# character will be stripped from all incoming filenames and replaced by
|
41
|
+
# replacement
|
42
|
+
milton_options[:separator] ||= '.'
|
43
|
+
milton_options[:replacement] ||= '-'
|
44
|
+
milton_options[:tempfile_path] ||= File.join(Rails.root, "tmp", "milton")
|
45
|
+
milton_options[:storage] ||= :disk
|
46
|
+
milton_options[:storage_options] ||= {}
|
47
|
+
milton_options[:processors] ||= {}
|
48
|
+
milton_options[:uploading] ||= true
|
49
|
+
|
50
|
+
# Set to true to allow on-demand processing of derivatives. This can
|
51
|
+
# be rediculously slow because it requires that the existance of the
|
52
|
+
# derivative is checked each time it's requested -- throw in S3 and
|
53
|
+
# that becomes a huge lag. Reccommended only for prototyping.
|
54
|
+
milton_options[:postproccess] ||= false
|
55
|
+
|
56
|
+
# TODO: Write about recipes
|
57
|
+
# * They're refered to by name in #path
|
58
|
+
# * They're an order of derivations to make against this attachment
|
59
|
+
# * They run in the order defined
|
60
|
+
# * They are created and run when the AR model is created
|
61
|
+
# * They're necessary when +:postprocessing+ is turned off
|
62
|
+
milton_options[:recipes] ||= {}
|
63
|
+
milton_options[:recipes].each do |name, steps|
|
64
|
+
steps = [steps] unless steps.is_a?(Array)
|
65
|
+
steps.each do |step|
|
66
|
+
step.each { |processor, options| Milton.try_require "milton/derivatives/#{processor}", "No '#{processor}' processor found for Milton" }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# TODO: Write about storage options
|
71
|
+
# * Late binding (so right_aws is only req'd if you use S3)
|
72
|
+
Milton.try_require "milton/storage/#{milton_options[:storage]}_file", "No '#{milton_options[:storage]}' storage found for Milton"
|
73
|
+
|
74
|
+
# TODO: initialize these options in DiskFile
|
75
|
+
if milton_options[:storage] == :disk
|
76
|
+
# root of where the underlying files are stored (or will be stored)
|
77
|
+
# on the file system
|
78
|
+
milton_options[:storage_options][:root] ||= File.join(Rails.root, "public", table_name)
|
79
|
+
milton_options[:storage_options][:root] = File.expand_path(milton_options[:storage_options][:root])
|
80
|
+
# mode to set on stored files and created directories
|
81
|
+
milton_options[:storage_options][:chmod] ||= 0755
|
82
|
+
end
|
83
|
+
|
84
|
+
validates_presence_of :filename
|
85
|
+
|
86
|
+
after_destroy :destroy_attached_file
|
87
|
+
after_create :create_derivatives
|
88
|
+
|
89
|
+
include Milton::Attachment::InstanceMethods
|
90
|
+
|
91
|
+
if milton_options[:uploading]
|
92
|
+
require 'milton/uploading'
|
93
|
+
extend Milton::Uploading::ClassMethods
|
94
|
+
include Milton::Uploading::InstanceMethods
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# These get mixed in to your model when you use Milton
|
100
|
+
module InstanceMethods
|
101
|
+
# Sets the filename to the given filename (sanitizes the given filename
|
102
|
+
# as well)
|
103
|
+
#
|
104
|
+
# TODO: change the filename on the underlying file system on save so as
|
105
|
+
# not to orphan the file
|
106
|
+
def filename=(name)
|
107
|
+
write_attribute :filename, Storage::StoredFile.sanitize_filename(name, self.class.milton_options)
|
108
|
+
end
|
109
|
+
|
110
|
+
# Returns the content_type of this attachment, tries to determine it if
|
111
|
+
# hasn't been determined yet or is not saved to the database
|
112
|
+
def content_type
|
113
|
+
return self[:content_type] unless self[:content_type].blank?
|
114
|
+
self.content_type = attached_file.mime_type
|
115
|
+
end
|
116
|
+
|
117
|
+
# Sets the content type to the given type
|
118
|
+
def content_type=(type)
|
119
|
+
write_attribute :content_type, type.to_s.strip
|
120
|
+
end
|
121
|
+
|
122
|
+
# Simple helper, same as path except returns the directory from
|
123
|
+
# .../public/ on, i.e. for showing images in your views.
|
124
|
+
#
|
125
|
+
# @asset.path => /var/www/site/public/assets/000/000/001/313/milton.jpg
|
126
|
+
# @asset.public_path => /assets/000/000/001/313/milton.jpg
|
127
|
+
#
|
128
|
+
# Can send a different base path than public if you want to give the
|
129
|
+
# path from that base on, useful if you change your root path to
|
130
|
+
# somewhere else.
|
131
|
+
def public_path(options={}, base='public')
|
132
|
+
path(options).gsub(/.*?\/#{base}/, '')
|
133
|
+
end
|
134
|
+
|
135
|
+
# The path to the file.
|
136
|
+
def path(options=nil)
|
137
|
+
return attached_file.path if options.nil?
|
138
|
+
process(options).path
|
139
|
+
end
|
140
|
+
|
141
|
+
protected
|
142
|
+
|
143
|
+
# Meant to be used as an after_create filter -- loops over all the
|
144
|
+
# recipes and processes them to create the derivatives.
|
145
|
+
def create_derivatives
|
146
|
+
milton_options[:recipes].each{ |name, recipe| process(name, true) } if milton_options[:recipes].any?
|
147
|
+
end
|
148
|
+
|
149
|
+
# Process the given options to produce a final derivative. +options+
|
150
|
+
# takes a Hash of options to process or the name of a pre-defined
|
151
|
+
# recipe which will be looked up and processed.
|
152
|
+
#
|
153
|
+
# Pass +force = true+ to force processing regardless of if
|
154
|
+
# +:postprocessing+ is turned on or not.
|
155
|
+
#
|
156
|
+
# Returns the final Derivative of all processors in the recipe.
|
157
|
+
def process(options, force=false)
|
158
|
+
options = milton_options[:recipes][options] unless options.is_a?(Hash)
|
159
|
+
options = [options] unless options.is_a?(Array)
|
160
|
+
|
161
|
+
source = attached_file
|
162
|
+
options.each do |recipe|
|
163
|
+
recipe.each do |processor, opts|
|
164
|
+
source = Derivative.factory(processor, source, opts, self.class.milton_options).process_if(process? || force).file
|
165
|
+
end
|
166
|
+
end
|
167
|
+
source
|
168
|
+
end
|
169
|
+
|
170
|
+
# Returns true if derivaties of the attachment should be processed,
|
171
|
+
# returns false if no processing should be done when a derivative is
|
172
|
+
# requested.
|
173
|
+
#
|
174
|
+
# No processing also means the derivative won't be checked for
|
175
|
+
# existance (since that can be slow) so w/o postprocessing things will
|
176
|
+
# be much faster but #path will happily return the paths to Derivatives
|
177
|
+
# which don't exist.
|
178
|
+
#
|
179
|
+
# It is highly recommended that you turn +:postprocessing+ off for
|
180
|
+
# anything but prototyping, and instead use recipes and refer to them
|
181
|
+
# via #path. +:postprocessing+ relies on checking for existance which
|
182
|
+
# will kill any real application.
|
183
|
+
def process?
|
184
|
+
self.class.milton_options[:postprocessing]
|
185
|
+
end
|
186
|
+
|
187
|
+
# A reference to the attached file, this is probably what you want to
|
188
|
+
# overwrite to introduce a new behavior
|
189
|
+
#
|
190
|
+
# i.e.
|
191
|
+
# have attached_file return a ResizeableFile, or a TranscodableFile
|
192
|
+
def attached_file
|
193
|
+
@attached_file ||= Storage::StoredFile.adapter(self.class.milton_options[:storage]).new(filename, id, self.class.milton_options)
|
194
|
+
end
|
195
|
+
|
196
|
+
# Clean the file from the filesystem
|
197
|
+
def destroy_attached_file
|
198
|
+
attached_file.destroy
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
begin
|
2
|
+
require 'mimetype_fu'
|
3
|
+
rescue MissingSourceFile
|
4
|
+
end
|
5
|
+
|
6
|
+
module Milton
|
7
|
+
class File < ::File
|
8
|
+
class << self
|
9
|
+
def extension(filename)
|
10
|
+
extension = extname(filename)
|
11
|
+
extension.slice(1, extension.length-1)
|
12
|
+
end
|
13
|
+
|
14
|
+
# File respond_to?(:mime_type) is true if mimetype_fu is installed, so
|
15
|
+
# this way we always have File.mime_type? available but it favors
|
16
|
+
# mimetype_fu's implementation.
|
17
|
+
def mime_type?(file)
|
18
|
+
::File.respond_to?(:mime_type?) ? super(file.filename) : file.content_type
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'tempfile'
|
2
|
+
|
3
|
+
module Milton
|
4
|
+
# For lack of a better name, a MiltonTempfile adds some helpful
|
5
|
+
# functionality to Ruby's Tempfile
|
6
|
+
class Tempfile < ::Tempfile
|
7
|
+
class << self
|
8
|
+
def create(data_or_path, tempfile_path)
|
9
|
+
FileUtils.mkdir_p(tempfile_path) unless File.exists?(tempfile_path)
|
10
|
+
|
11
|
+
tempfile = new(basename, tempfile_path)
|
12
|
+
|
13
|
+
if data_or_path.is_a?(StringIO)
|
14
|
+
tempfile.binmode
|
15
|
+
tempfile.write data_or_path.read
|
16
|
+
tempfile.close
|
17
|
+
else
|
18
|
+
tempfile.close
|
19
|
+
FileUtils.cp((data_or_path.respond_to?(:path) ? data_or_path.path : data_or_path), tempfile.path)
|
20
|
+
end
|
21
|
+
|
22
|
+
tempfile
|
23
|
+
end
|
24
|
+
|
25
|
+
def basename
|
26
|
+
"#{rand(Time.now.to_i)}"
|
27
|
+
end
|
28
|
+
|
29
|
+
def filename(extension)
|
30
|
+
"#{basename}.#{extension}"
|
31
|
+
end
|
32
|
+
|
33
|
+
def path(tempfile_path, extension)
|
34
|
+
File.join(tempfile_path, filename(extension))
|
35
|
+
end
|
36
|
+
|
37
|
+
# Simple helper that returns a path to a tempfile with a uniquely
|
38
|
+
# generated basename and same extension as the given source.
|
39
|
+
def from(source)
|
40
|
+
filename(Milton::File.extension(source))
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
module Milton
|
2
|
+
# Represents a file created on the file system that is a derivative of the
|
3
|
+
# one referenced by the model, i.e. a thumbnail of an image, or a transcode
|
4
|
+
# of a video.
|
5
|
+
#
|
6
|
+
# Provides a container for options and a uniform API to dealing with
|
7
|
+
# passing options for the creation of derivatives.
|
8
|
+
#
|
9
|
+
# Files created as derivatives have their creation options appended into
|
10
|
+
# their filenames so it can be checked later if a file w/ the given
|
11
|
+
# options already exists (so as not to create it again).
|
12
|
+
#
|
13
|
+
class Derivative
|
14
|
+
class << self
|
15
|
+
# Given a string of attachment options, splits them out into a hash,
|
16
|
+
# useful for things that take options on the query string or from
|
17
|
+
# filenames
|
18
|
+
def options_from(string)
|
19
|
+
Hash[*(string.split('_').collect { |option|
|
20
|
+
key, value = option.split('=')
|
21
|
+
[ key.to_sym, value || true ] # nothing on RHS of = means it's a boolean true
|
22
|
+
}).flatten]
|
23
|
+
end
|
24
|
+
|
25
|
+
# Given a filename (presumably with options embedded in it) parses out
|
26
|
+
# the options and returns them as a hash.
|
27
|
+
def extract_options_from(filename, options)
|
28
|
+
File.basename(filename, File.extname(filename))[(filename.rindex(options[:separator]) + 1)..-1]
|
29
|
+
end
|
30
|
+
|
31
|
+
# Creates a new Derivative from the given filename by extracting the
|
32
|
+
# options.
|
33
|
+
def from_filename(filename)
|
34
|
+
Derivative.new(filename, options_from(extract_options_from(filename)))
|
35
|
+
end
|
36
|
+
|
37
|
+
def process(source, options={}, settings={})
|
38
|
+
returning(derivative = new(source, options, settings)) do
|
39
|
+
derivative.process unless derivative.exists?
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def factory(type, source, options={}, settings={})
|
44
|
+
begin
|
45
|
+
klass = "Milton::#{type.to_s.classify}".constantize
|
46
|
+
rescue NameError
|
47
|
+
begin
|
48
|
+
require "milton/derivatives/#{type.to_s}"
|
49
|
+
rescue MissingSourceFile => e
|
50
|
+
raise MissingSourceFile.new("#{e.message} (milton: couldn't find the processor '#{type}' you were trying to load)", e.path)
|
51
|
+
end
|
52
|
+
klass = "Milton::#{type.to_s.classify}".constantize
|
53
|
+
end
|
54
|
+
|
55
|
+
klass.new(source, options, settings)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
attr_reader :options, :settings
|
60
|
+
|
61
|
+
# Instantiate a new Derivative:
|
62
|
+
# * +source+: a reference to the Storage::StoredFile this will be a Derivative of
|
63
|
+
# * +options+: options to generate the Derivative using
|
64
|
+
# * +settings+: settings about how to create Derivatives
|
65
|
+
def initialize(source, options={}, settings={})
|
66
|
+
@source = source
|
67
|
+
@options = options.is_a?(String) ? self.class.options_from(options) : options
|
68
|
+
@settings = settings
|
69
|
+
end
|
70
|
+
|
71
|
+
# The resulting filename of this Derivative with embedded options.
|
72
|
+
def filename
|
73
|
+
# ignore false booleans and don't output =true for true booleans,
|
74
|
+
# otherwise just k=v
|
75
|
+
append = options.reject{ |k, v| v.is_a?(FalseClass) }.collect { |k, v| v === true ? k.to_s : "#{k}=#{v}" }.sort.join('_')
|
76
|
+
extension = File.extname(@source.path)
|
77
|
+
File.basename(@source.path, extension) + (append.blank? ? '' : "#{settings[:separator]}#{append}") + extension
|
78
|
+
end
|
79
|
+
|
80
|
+
# The full path and filename to this Derivative.
|
81
|
+
def path
|
82
|
+
file.path
|
83
|
+
end
|
84
|
+
|
85
|
+
# Returns true if this Derivative has already been generated and stored.
|
86
|
+
def exists?
|
87
|
+
file.exists?
|
88
|
+
end
|
89
|
+
|
90
|
+
# Overwrite this to provide your derivatives processing.
|
91
|
+
def process;end;
|
92
|
+
|
93
|
+
# Convenience method, only runs process if the given condition is true.
|
94
|
+
# Returns the derivative so it's chainable.
|
95
|
+
def process_if(condition)
|
96
|
+
process if condition && !exists?
|
97
|
+
return self
|
98
|
+
end
|
99
|
+
|
100
|
+
# Returns the StoredFile which represents the Derivative (which is a copy
|
101
|
+
# of the source w/ a different filename).
|
102
|
+
def file
|
103
|
+
@file ||= @source.clone(filename)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'milton/derivatives/thumbnail/image'
|
2
|
+
require 'milton/derivatives/thumbnail/crop_calculator'
|
3
|
+
|
4
|
+
module Milton
|
5
|
+
class Thumbnail < Derivative
|
6
|
+
def process
|
7
|
+
raise "target size must be specified for resizing" unless options.has_key?(:size)
|
8
|
+
|
9
|
+
temp_dst = File.join(settings[:tempfile_path], Milton::Tempfile.from(@source.filename))
|
10
|
+
temp_src = File.join(settings[:tempfile_path], Milton::Tempfile.from(@source.filename))
|
11
|
+
|
12
|
+
@source.copy(temp_src)
|
13
|
+
image = Image.from_path(temp_src)
|
14
|
+
|
15
|
+
# TODO: this really only makes sense for processing recipes, reimplement
|
16
|
+
# once it's setup to build all derivatives then push to storage
|
17
|
+
#
|
18
|
+
# For speed, any derivatives less than 640-wide are made from a
|
19
|
+
# 640-wide version of the image (so you're not generating tiny
|
20
|
+
# thumbnails from an 8-megapixel upload)
|
21
|
+
# source = if image.width > 640 && Image.from_geometry(options[:size]).width < 640
|
22
|
+
# Thumbnail.process(@source, { :size => '640x' }, settings).file
|
23
|
+
# else
|
24
|
+
# @source
|
25
|
+
# end
|
26
|
+
|
27
|
+
if options[:crop]
|
28
|
+
crop = CropCalculator.new(image, Image.from_geometry(options[:size]))
|
29
|
+
size = crop.resizing_geometry
|
30
|
+
conversion_options = %Q(-gravity #{crop.gravity} -crop #{crop.cropping_geometry})
|
31
|
+
end
|
32
|
+
|
33
|
+
Milton.syscall!(%Q{convert #{temp_src} -geometry #{size || options[:size]} #{conversion_options || ''} +repage "#{temp_dst}"})
|
34
|
+
|
35
|
+
# TODO: raise if the store fails
|
36
|
+
file.store(temp_dst)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module Milton
|
2
|
+
class CropCalculator
|
3
|
+
attr_reader :original, :target
|
4
|
+
|
5
|
+
# Initializes a new CropCalculator with the two given Images.
|
6
|
+
#
|
7
|
+
# A CropCalculator is used to calculate the proper zoom/crop dimensions
|
8
|
+
# to be passed to ImageMagick's convert method in order to transform
|
9
|
+
# the original Image's dimensions into the target Image's dimensions
|
10
|
+
# with sensible zoom/cropping.
|
11
|
+
def initialize(original, target)
|
12
|
+
@original = original
|
13
|
+
@target = target
|
14
|
+
end
|
15
|
+
|
16
|
+
# Returns the geometry string to send to ImageMagick's convert -resize
|
17
|
+
# argument -- that is, the dimensions that the original Image would
|
18
|
+
# need to be resized to in order to result in the given target Image's
|
19
|
+
# dimensions with cropping.
|
20
|
+
def resizing_geometry
|
21
|
+
case
|
22
|
+
when original.wider? then "#{resized_width}x#{target.height}"
|
23
|
+
when original.square? && target.wider? then "#{target.width}x#{resized_height}"
|
24
|
+
when original.square? && !target.wider? then "#{resized_width}x#{target.height}"
|
25
|
+
else "#{target.width}x#{resized_height}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# The geometry string to send to ImageMagick's convert -crop argument.
|
30
|
+
def cropping_geometry
|
31
|
+
"#{target.width}x#{target.height}+0+0"
|
32
|
+
end
|
33
|
+
|
34
|
+
# The gravity to use for cropping.
|
35
|
+
def gravity
|
36
|
+
original.wider? ? "center" : "north"
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def resized_width
|
42
|
+
(target.height * original.width / original.height).to_i
|
43
|
+
end
|
44
|
+
|
45
|
+
def resized_height
|
46
|
+
(target.width * original.height / original.width).to_i
|
47
|
+
end
|
48
|
+
|
49
|
+
# TODO: this is the old-school cropping w/ coords, need to implement
|
50
|
+
# cropping w/ coords using the new system calls
|
51
|
+
# def crop_with_coordinates(img, x, y, size, options={})
|
52
|
+
# gravity = options[:gravity] || Magick::NorthGravity
|
53
|
+
# cropped_img = nil
|
54
|
+
# img = Magick::Image.read(img).first unless img.is_a?(Magick::Image)
|
55
|
+
# szx, szy = img.columns, img.rows
|
56
|
+
# sz = self.class.get_size_from_parameter(size)
|
57
|
+
# # logger.info "crop_with_coordinates: img.crop!(#{x}, #{y}, #{sz[0]}, #{sz[1]}, true)"
|
58
|
+
# # cropped_img = img.resize!(sz[0], sz[1]) # EEEEEK
|
59
|
+
# cropped_img = img.crop!(x, y, szx, szy, true)
|
60
|
+
# cropped_img.crop_resized!(sz[0], sz[1], gravity) # EEEEEK
|
61
|
+
# self.temp_path = write_to_temp_file(cropped_img.to_blob)
|
62
|
+
# end
|
63
|
+
end
|
64
|
+
end
|