bulldog 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -0
- data/DESCRIPTION.txt +3 -0
- data/LICENSE +20 -0
- data/README.rdoc +18 -0
- data/Rakefile +64 -0
- data/VERSION +1 -0
- data/bulldog.gemspec +157 -0
- data/lib/bulldog.rb +95 -0
- data/lib/bulldog/attachment.rb +49 -0
- data/lib/bulldog/attachment/base.rb +167 -0
- data/lib/bulldog/attachment/has_dimensions.rb +94 -0
- data/lib/bulldog/attachment/image.rb +63 -0
- data/lib/bulldog/attachment/maybe.rb +229 -0
- data/lib/bulldog/attachment/none.rb +37 -0
- data/lib/bulldog/attachment/pdf.rb +63 -0
- data/lib/bulldog/attachment/unknown.rb +11 -0
- data/lib/bulldog/attachment/video.rb +143 -0
- data/lib/bulldog/error.rb +5 -0
- data/lib/bulldog/has_attachment.rb +214 -0
- data/lib/bulldog/interpolation.rb +73 -0
- data/lib/bulldog/missing_file.rb +12 -0
- data/lib/bulldog/processor.rb +5 -0
- data/lib/bulldog/processor/argument_tree.rb +116 -0
- data/lib/bulldog/processor/base.rb +124 -0
- data/lib/bulldog/processor/ffmpeg.rb +172 -0
- data/lib/bulldog/processor/image_magick.rb +134 -0
- data/lib/bulldog/processor/one_shot.rb +19 -0
- data/lib/bulldog/reflection.rb +234 -0
- data/lib/bulldog/saved_file.rb +19 -0
- data/lib/bulldog/stream.rb +186 -0
- data/lib/bulldog/style.rb +38 -0
- data/lib/bulldog/style_set.rb +101 -0
- data/lib/bulldog/tempfile.rb +28 -0
- data/lib/bulldog/util.rb +92 -0
- data/lib/bulldog/validations.rb +68 -0
- data/lib/bulldog/vector2.rb +18 -0
- data/rails/init.rb +9 -0
- data/script/console +8 -0
- data/spec/data/empty.txt +0 -0
- data/spec/data/test.jpg +0 -0
- data/spec/data/test.mov +0 -0
- data/spec/data/test.pdf +0 -0
- data/spec/data/test.png +0 -0
- data/spec/data/test2.jpg +0 -0
- data/spec/helpers/image_creation.rb +8 -0
- data/spec/helpers/temporary_directory.rb +25 -0
- data/spec/helpers/temporary_models.rb +76 -0
- data/spec/helpers/temporary_values.rb +102 -0
- data/spec/helpers/test_upload_files.rb +108 -0
- data/spec/helpers/time_travel.rb +20 -0
- data/spec/integration/data/test.jpg +0 -0
- data/spec/integration/lifecycle_hooks_spec.rb +213 -0
- data/spec/integration/processing_image_attachments.rb +72 -0
- data/spec/integration/processing_video_attachments_spec.rb +82 -0
- data/spec/integration/saving_an_attachment_spec.rb +31 -0
- data/spec/matchers/file_operations.rb +159 -0
- data/spec/spec_helper.rb +76 -0
- data/spec/unit/attachment/base_spec.rb +311 -0
- data/spec/unit/attachment/image_spec.rb +128 -0
- data/spec/unit/attachment/maybe_spec.rb +126 -0
- data/spec/unit/attachment/pdf_spec.rb +137 -0
- data/spec/unit/attachment/video_spec.rb +176 -0
- data/spec/unit/attachment_spec.rb +61 -0
- data/spec/unit/has_attachment_spec.rb +700 -0
- data/spec/unit/interpolation_spec.rb +108 -0
- data/spec/unit/processor/argument_tree_spec.rb +159 -0
- data/spec/unit/processor/ffmpeg_spec.rb +467 -0
- data/spec/unit/processor/image_magick_spec.rb +260 -0
- data/spec/unit/processor/one_shot_spec.rb +70 -0
- data/spec/unit/reflection_spec.rb +338 -0
- data/spec/unit/stream_spec.rb +234 -0
- data/spec/unit/style_set_spec.rb +44 -0
- data/spec/unit/style_spec.rb +51 -0
- data/spec/unit/validations_spec.rb +491 -0
- data/spec/unit/vector2_spec.rb +27 -0
- data/tasks/bulldog_tasks.rake +4 -0
- metadata +193 -0
@@ -0,0 +1,143 @@
|
|
1
|
+
module Bulldog
|
2
|
+
module Attachment
|
3
|
+
class Video < Base
|
4
|
+
handle :video
|
5
|
+
|
6
|
+
#
|
7
|
+
# Return the width and height of the named style, as a 2-element
|
8
|
+
# array.
|
9
|
+
#
|
10
|
+
# This runs ffmpeg for, and only for, the original style.
|
11
|
+
#
|
12
|
+
# +style_name+ defaults to the attribute's #default_style.
|
13
|
+
#
|
14
|
+
def dimensions(style_name)
|
15
|
+
video_tracks(style_name).first.dimensions
|
16
|
+
end
|
17
|
+
|
18
|
+
include HasDimensions
|
19
|
+
|
20
|
+
#
|
21
|
+
# Return the duration of the named style, as an
|
22
|
+
# ActiveSupport::Duration.
|
23
|
+
#
|
24
|
+
# This runs ffmpeg for, and only for, the original style.
|
25
|
+
#
|
26
|
+
# +style_name+ defaults to the attribute's #default_style.
|
27
|
+
#
|
28
|
+
def duration(style_name)
|
29
|
+
# TODO: support styles with different durations
|
30
|
+
if stream.missing?
|
31
|
+
0
|
32
|
+
else
|
33
|
+
examine
|
34
|
+
@original_duration
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
#
|
39
|
+
# Return the video tracks of the named style, as an array of
|
40
|
+
# VideoTrack objects.
|
41
|
+
#
|
42
|
+
# Each VideoTrack has:
|
43
|
+
#
|
44
|
+
# * <tt>#dimension</tt> - the dimensions of the video track,
|
45
|
+
# [width, height].
|
46
|
+
#
|
47
|
+
def video_tracks(style_name=nil)
|
48
|
+
style_name ||= reflection.default_style
|
49
|
+
if style_name == :original
|
50
|
+
if stream.missing?
|
51
|
+
[VideoTrack.new(:dimensions => [2, 2])]
|
52
|
+
else
|
53
|
+
examine
|
54
|
+
if @original_video_tracks.empty?
|
55
|
+
@original_video_tracks << VideoTrack.new(:dimensions => [2, 2])
|
56
|
+
end
|
57
|
+
@original_video_tracks
|
58
|
+
end
|
59
|
+
else
|
60
|
+
style = reflection.styles[style_name]
|
61
|
+
target_dimensions = style[:size].split(/x/).map(&:to_i)
|
62
|
+
video_tracks(:original).map do |video_track|
|
63
|
+
dimensions = resized_dimensions(dimensions(:original), target_dimensions, style[:filled])
|
64
|
+
dimensions.map!{|i| i &= -2} # some codecs require multiples of 2
|
65
|
+
VideoTrack.new(:dimensions => dimensions)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
#
|
71
|
+
# Return the video tracks of the named style, as an array of
|
72
|
+
# AudioTrack objects.
|
73
|
+
#
|
74
|
+
# AudioTrack objects do not yet have any useful methods.
|
75
|
+
#
|
76
|
+
def audio_tracks(style_name)
|
77
|
+
examine
|
78
|
+
@original_audio_tracks
|
79
|
+
end
|
80
|
+
|
81
|
+
storable_attribute :duration , :per_style => true, :memoize => true
|
82
|
+
|
83
|
+
protected # ---------------------------------------------------
|
84
|
+
|
85
|
+
#
|
86
|
+
# Return the default processor class to use for this attachment.
|
87
|
+
#
|
88
|
+
def default_processor_type
|
89
|
+
:ffmpeg
|
90
|
+
end
|
91
|
+
|
92
|
+
private # -----------------------------------------------------
|
93
|
+
|
94
|
+
#
|
95
|
+
# Read the original image metadata with ffmpeg.
|
96
|
+
#
|
97
|
+
def run_examination
|
98
|
+
return false if stream.missing?
|
99
|
+
output = `ffmpeg -i #{stream.path} 2>&1`
|
100
|
+
parse_output(output)
|
101
|
+
end
|
102
|
+
|
103
|
+
def parse_output(output)
|
104
|
+
result = false
|
105
|
+
@original_duration = 0
|
106
|
+
@original_video_tracks = []
|
107
|
+
@original_audio_tracks = []
|
108
|
+
io = StringIO.new(output)
|
109
|
+
while (line = io.gets)
|
110
|
+
case line
|
111
|
+
when /^Input #0, (.*?), from '(?:.*)':$/
|
112
|
+
result = true
|
113
|
+
when /^ Duration: (\d+):(\d+):(\d+)\.(\d+)/
|
114
|
+
@original_duration = $1.to_i.hours + $2.to_i.minutes + $3.to_i.seconds
|
115
|
+
when /Stream #(?:.*?): Video: /
|
116
|
+
if $' =~ /(\d+)x(\d+)/
|
117
|
+
dimensions = [$1.to_i, $2.to_i]
|
118
|
+
end
|
119
|
+
@original_video_tracks << VideoTrack.new(:dimensions => dimensions)
|
120
|
+
when /Stream #(?:.*?): Audio: (.*?)/
|
121
|
+
@original_audio_tracks << AudioTrack.new
|
122
|
+
end
|
123
|
+
end
|
124
|
+
result
|
125
|
+
end
|
126
|
+
|
127
|
+
class Track
|
128
|
+
def initialize(attributes={})
|
129
|
+
attributes.each do |name, value|
|
130
|
+
send("#{name}=", value)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
class VideoTrack < Track
|
136
|
+
attr_accessor :dimensions
|
137
|
+
end
|
138
|
+
|
139
|
+
class AudioTrack < Track
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,214 @@
|
|
1
|
+
module Bulldog
|
2
|
+
module HasAttachment
|
3
|
+
def self.included(base)
|
4
|
+
base.extend ClassMethods
|
5
|
+
base.instance_variable_set(:@attachment_reflections, {})
|
6
|
+
|
7
|
+
# We need to store the attachment changes ourselves, since
|
8
|
+
# they're unavailable in an after_save.
|
9
|
+
base.before_save :store_original_attachments
|
10
|
+
base.after_save :save_attachments
|
11
|
+
base.after_save :clear_original_attachments
|
12
|
+
|
13
|
+
base.before_save :update_attachment_timestamps
|
14
|
+
base.after_destroy :destroy_attachments
|
15
|
+
|
16
|
+
# Force initialization of attachments, as #destroy will freeze
|
17
|
+
# the attributes afterwards.
|
18
|
+
base.before_destroy :initialize_remaining_attachments
|
19
|
+
|
20
|
+
%w[validation save create update].each do |event|
|
21
|
+
base.send("before_#{event}", "process_attachments_for_before_#{event}")
|
22
|
+
base.send("after_#{event}", "process_attachments_for_after_#{event}")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def save_attachments
|
27
|
+
attachment_reflections.each do |name, reflection|
|
28
|
+
original_attachment = @original_attachments[name] and
|
29
|
+
original_attachment.destroy
|
30
|
+
_attachment_for(name).save
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def destroy_attachments
|
35
|
+
attachment_reflections.each do |name, reflection|
|
36
|
+
_attachment_for(name).destroy
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def update_attachment_timestamps
|
41
|
+
attachment_reflections.each do |name, reflection|
|
42
|
+
next unless send("#{name}_changed?")
|
43
|
+
setter = "#{name}_updated_at="
|
44
|
+
if respond_to?(setter)
|
45
|
+
send(setter, Time.now)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def process_attachment(name, event, *args)
|
51
|
+
reflection = attachment_reflections[name] or
|
52
|
+
raise ArgumentError, "no such attachment: #{name}"
|
53
|
+
_attachment_for(name).process(event, *args)
|
54
|
+
end
|
55
|
+
|
56
|
+
def attachment_reflection_for(name)
|
57
|
+
self.class.attachment_reflections[name]
|
58
|
+
end
|
59
|
+
|
60
|
+
private # -------------------------------------------------------
|
61
|
+
|
62
|
+
# Prefixed with '_', as it would collide with paperclip otherwise.
|
63
|
+
def _attachment_for(name)
|
64
|
+
read_attribute(name) or
|
65
|
+
initialize_attachment(name)
|
66
|
+
end
|
67
|
+
|
68
|
+
def initialize_attachment(name)
|
69
|
+
if new_record?
|
70
|
+
value = nil
|
71
|
+
else
|
72
|
+
reflection = attachment_reflection_for(name)
|
73
|
+
file_name_column = reflection.column_name_for_stored_attribute(:file_name)
|
74
|
+
file_name = file_name_column ? send(file_name_column) : nil
|
75
|
+
if file_name_column && file_name.nil?
|
76
|
+
value = nil
|
77
|
+
else
|
78
|
+
template = reflection.path_template
|
79
|
+
style = reflection.styles[:original]
|
80
|
+
original_path = Interpolation.interpolate(template, self, name, style, :basename => file_name)
|
81
|
+
if File.exist?(original_path)
|
82
|
+
value = SavedFile.new(original_path, :file_name => file_name)
|
83
|
+
else
|
84
|
+
if file_name_column
|
85
|
+
value = MissingFile.new(:file_name => file_name)
|
86
|
+
else
|
87
|
+
value = nil
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
attachment = make_attachment_for(name, value)
|
94
|
+
write_attribute_without_dirty(name, attachment)
|
95
|
+
attachment.read_storable_attributes
|
96
|
+
attachment
|
97
|
+
end
|
98
|
+
|
99
|
+
def assign_attachment(name, value)
|
100
|
+
old_attachment = _attachment_for(name)
|
101
|
+
unless old_attachment.value == value
|
102
|
+
old_attachment.clear_stored_attributes
|
103
|
+
new_attachment = make_attachment_for(name, value)
|
104
|
+
new_attachment.set_stored_attributes
|
105
|
+
write_attribute(name, new_attachment)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def make_attachment_for(name, value)
|
110
|
+
return Attachment.none(self, name) if value.nil?
|
111
|
+
stream = Stream.new(value)
|
112
|
+
reflection = attachment_reflection_for(name)
|
113
|
+
type = reflection.detect_attachment_type(self, stream)
|
114
|
+
Attachment.of_type(type, self, name, stream)
|
115
|
+
end
|
116
|
+
|
117
|
+
def store_original_attachments
|
118
|
+
@original_attachments = {}
|
119
|
+
attachment_reflections.each do |name, reflection|
|
120
|
+
if send("#{name}_changed?")
|
121
|
+
@original_attachments[name] = send("#{name}_was")
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def clear_original_attachments
|
127
|
+
remove_instance_variable :@original_attachments
|
128
|
+
end
|
129
|
+
|
130
|
+
def process_attachments_for_event(event, *args)
|
131
|
+
self.class.attachment_reflections.each do |name, reflection|
|
132
|
+
_attachment_for(reflection.name).process(event, *args)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def initialize_remaining_attachments
|
137
|
+
self.attachment_reflections.each do |name, reflection|
|
138
|
+
_attachment_for(name) # force initialization
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
%w[validation save create update].each do |event|
|
143
|
+
module_eval <<-EOS
|
144
|
+
def process_attachments_for_before_#{event}
|
145
|
+
process_attachments_for_event(:before_#{event})
|
146
|
+
end
|
147
|
+
def process_attachments_for_after_#{event}
|
148
|
+
process_attachments_for_event(:after_#{event})
|
149
|
+
end
|
150
|
+
EOS
|
151
|
+
end
|
152
|
+
|
153
|
+
delegate :attachment_reflections, :to => 'self.class'
|
154
|
+
|
155
|
+
module ClassMethods
|
156
|
+
def attachment_reflections
|
157
|
+
@attachment_reflections ||=
|
158
|
+
begin
|
159
|
+
hash = {}
|
160
|
+
superhash = superclass.attachment_reflections
|
161
|
+
superhash.map do |name, reflection|
|
162
|
+
hash[name] = reflection.clone
|
163
|
+
end
|
164
|
+
hash
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
#
|
169
|
+
# Declare that this model has an attachment.
|
170
|
+
#
|
171
|
+
# TODO: example that shows all the options.
|
172
|
+
#
|
173
|
+
def has_attachment(name, &block)
|
174
|
+
reflection = attachment_reflections[name] || Reflection.new(self, name)
|
175
|
+
reflection.configure(&block)
|
176
|
+
attachment_reflections[name] = reflection
|
177
|
+
define_attachment_accessors(reflection.name)
|
178
|
+
define_attachment_attribute_methods(reflection.name)
|
179
|
+
end
|
180
|
+
|
181
|
+
def define_attachment_accessors(name)
|
182
|
+
module_eval <<-EOS, __FILE__, __LINE__
|
183
|
+
def #{name}
|
184
|
+
_attachment_for(:#{name})
|
185
|
+
end
|
186
|
+
|
187
|
+
def #{name}=(value)
|
188
|
+
assign_attachment(:#{name}, value)
|
189
|
+
end
|
190
|
+
|
191
|
+
def #{name}?
|
192
|
+
_attachment_for(:#{name}).present?
|
193
|
+
end
|
194
|
+
EOS
|
195
|
+
end
|
196
|
+
|
197
|
+
def define_attachment_attribute_methods(name)
|
198
|
+
# HACK: Without this, methods defined via
|
199
|
+
# #attribute_method_suffix (e.g., #ATTACHMENT_changed?) won't
|
200
|
+
# be defined unless the attachment is assigned first.
|
201
|
+
# ActiveRecord appears to give us no other way without
|
202
|
+
# defining an after_initialize, which is slow.
|
203
|
+
attribute_method_suffixes.each do |suffix|
|
204
|
+
next unless suffix[0] == ?_ # skip =, ?.
|
205
|
+
class_eval <<-EOS
|
206
|
+
def #{name}#{suffix}(*args, &block)
|
207
|
+
attribute#{suffix}('#{name}', *args, &block)
|
208
|
+
end
|
209
|
+
EOS
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module Bulldog
|
2
|
+
module Interpolation
|
3
|
+
Error = Class.new(Bulldog::Error)
|
4
|
+
|
5
|
+
def self.interpolate(template, record, name, style, overrides={})
|
6
|
+
# TODO: would be nice if this wasn't such a special case.
|
7
|
+
if overrides[:basename]
|
8
|
+
extension = File.extname(overrides[:basename]).sub(/\A./, '')
|
9
|
+
overrides[:extension] ||= extension
|
10
|
+
end
|
11
|
+
template.gsub(/:(?:(\w+)|\{(\w+?)\})/) do
|
12
|
+
key = ($1 || $2).to_sym
|
13
|
+
if (override = overrides[key])
|
14
|
+
override
|
15
|
+
elsif @interpolations.key?(key)
|
16
|
+
@interpolations[key].call(record, name, style)
|
17
|
+
else
|
18
|
+
raise Error, "no such interpolation key: #{key}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.to_interpolate(key, &substitution)
|
24
|
+
@interpolations[key] = substitution
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.reset
|
28
|
+
@interpolations = {}
|
29
|
+
|
30
|
+
to_interpolate :class do |record, name, style|
|
31
|
+
record.class.name.underscore.pluralize
|
32
|
+
end
|
33
|
+
|
34
|
+
to_interpolate :id do |record, name, style|
|
35
|
+
record.send(record.class.primary_key)
|
36
|
+
end
|
37
|
+
|
38
|
+
to_interpolate :id_partition do |record, name, style|
|
39
|
+
id = record.send(record.class.primary_key)
|
40
|
+
("%09d" % id).scan(/\d{3}/).join("/")
|
41
|
+
end
|
42
|
+
|
43
|
+
to_interpolate :attachment do |record, name, style|
|
44
|
+
name
|
45
|
+
end
|
46
|
+
|
47
|
+
to_interpolate :style do |record, name, style|
|
48
|
+
style.name
|
49
|
+
end
|
50
|
+
|
51
|
+
to_interpolate :basename do |record, name, style|
|
52
|
+
reflection = record.attachment_reflection_for(name)
|
53
|
+
column_name = reflection.column_name_for_stored_attribute(:file_name) or
|
54
|
+
raise Error, ":basename interpolation requires storing the file name - add a column #{name}_file_name or use store_attributes"
|
55
|
+
record.send(column_name)
|
56
|
+
end
|
57
|
+
|
58
|
+
to_interpolate :extension do |record, name, style|
|
59
|
+
reflection = record.attachment_reflection_for(name)
|
60
|
+
column_name = reflection.column_name_for_stored_attribute(:file_name) or
|
61
|
+
raise Error, ":extension interpolation requires storing the file name - add a column #{name}_file_name or use store_attributes"
|
62
|
+
basename = record.send(column_name) or
|
63
|
+
raise Error, ":extension interpolation used when file_name not set - if you need to interpolate the url, pass a :basename override"
|
64
|
+
File.extname(basename).sub(/\A\./, '')
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
#
|
69
|
+
# Reset the list of interpolation definitions.
|
70
|
+
#
|
71
|
+
reset
|
72
|
+
end
|
73
|
+
end
|