bulldog 0.0.1
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.
- 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
|