shotwellfs 0.0.1RC0
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 +19 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +34 -0
- data/Rakefile +8 -0
- data/bin/shotwellfs +7 -0
- data/lib/shotwellfs/filesystem.rb +305 -0
- data/lib/shotwellfs/transform.rb +183 -0
- data/lib/shotwellfs/version.rb +3 -0
- data/lib/shotwellfs.rb +38 -0
- data/shotwellfs.gemspec +30 -0
- metadata +202 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
gem 'rfuse', :path => "../rfuse", :git => "https://github.com/lwoggardner/rfuse.git"
|
4
|
+
gem 'rfusefs', :path => "../rfusefs", :git => "https://github.com/lwoggardner/rfusefs.git"
|
5
|
+
# Specify your gem's dependencies in shotwellfs.gemspec
|
6
|
+
gemspec
|
7
|
+
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Grant Gardner
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
# Shotwellfs
|
2
|
+
|
3
|
+
Provides a FUSE filesystem over a Shotwell database of photos and videos (http://yorba.org/shotwell)
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
$ gem install shotwellfs
|
8
|
+
|
9
|
+
## Usage
|
10
|
+
|
11
|
+
Start shotwellfs
|
12
|
+
|
13
|
+
$ shotwellfs <path/to/shotwell-dir> <mountpoint> [ -o mountoptions ]
|
14
|
+
|
15
|
+
_shotwell-dir_ is the directory containing shotwell's private data (ie data/photo.db)
|
16
|
+
|
17
|
+
Navigate to _mountpoint_ in your favourite file browser and see your photos layed out as events
|
18
|
+
|
19
|
+
* Crop and RedEye transformations are applied to JPG and TIF images (and cached in shotwell_dir)
|
20
|
+
|
21
|
+
* Shotwell event and photo information is available via extended attributes and thus easier to parse
|
22
|
+
regardless of filetype
|
23
|
+
|
24
|
+
For more advanced usage, including controlling how events are mapped to directories, see
|
25
|
+
|
26
|
+
$ shotwellfs -h
|
27
|
+
|
28
|
+
## Contributing
|
29
|
+
|
30
|
+
1. Fork it
|
31
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
32
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
33
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
34
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
data/bin/shotwellfs
ADDED
@@ -0,0 +1,305 @@
|
|
1
|
+
require 'shotwellfs/transform'
|
2
|
+
require 'fusefs/sqlitemapper'
|
3
|
+
require 'date'
|
4
|
+
require 'set'
|
5
|
+
require 'fileutils'
|
6
|
+
require 'ffi-xattr'
|
7
|
+
require 'sys/filesystem'
|
8
|
+
require 'rb-inotify'
|
9
|
+
|
10
|
+
module ShotwellFS
|
11
|
+
|
12
|
+
# A Fuse filesystem over a shotwell picture/video library
|
13
|
+
class FileSystem < FuseFS::SqliteMapperFS
|
14
|
+
|
15
|
+
def self.source_type(value)
|
16
|
+
value.start_with?("video") ? "video" : "photo"
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.source_id(value)
|
20
|
+
value[6..-1].hex
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
Event = Struct.new('Event', :id, :path, :time, :xattr)
|
25
|
+
|
26
|
+
SHOTWELL_SQL = <<-SQL
|
27
|
+
SELECT P.rating as 'rating', P.exposure_time as 'exposure_time',
|
28
|
+
P.title as 'title', P.comment as 'comment', P.filename as 'filename', P.id as 'id',
|
29
|
+
P.event_id as 'event_id', "photo" as 'type', P.transformations as 'transformations'
|
30
|
+
FROM phototable P
|
31
|
+
WHERE P.rating >= %1$d and P.event_id > 0
|
32
|
+
UNION
|
33
|
+
SELECT V.rating, V.exposure_time as 'exposure_time',
|
34
|
+
V.title, V.comment, V.filename as 'filename', V.id as 'id',
|
35
|
+
V.event_id as 'event_id', "video" as 'type', null
|
36
|
+
FROM videotable V
|
37
|
+
WHERE V.rating >= %1$d and V.event_id > 0
|
38
|
+
SQL
|
39
|
+
|
40
|
+
TAG_SQL = <<-SQL
|
41
|
+
select name,photo_id_list
|
42
|
+
FROM tagtable
|
43
|
+
SQL
|
44
|
+
|
45
|
+
EVENT_SQL = <<-SQL
|
46
|
+
SELECT E.id as 'id', E.name as 'name', E.comment as 'comment', P.exposure_time as 'exposure_time'
|
47
|
+
FROM eventtable E, phototable P
|
48
|
+
WHERE source_type(E.primary_source_id) = 'photo'
|
49
|
+
AND source_id(E.primary_source_id) = P.id
|
50
|
+
UNION
|
51
|
+
SELECT E.id, E.name, E.comment, V.exposure_time
|
52
|
+
FROM eventtable E, videotable V
|
53
|
+
WHERE source_type(E.primary_source_id) = 'video'
|
54
|
+
AND source_id(E.primary_source_id) = V.id
|
55
|
+
SQL
|
56
|
+
|
57
|
+
XATTR_TRANSFORM_ID = 'user.shotwell.transform_id'
|
58
|
+
|
59
|
+
def initialize(options)
|
60
|
+
@shotwell_dir = options[:device]
|
61
|
+
shotwell_db = "#{@shotwell_dir}/data/photo.db"
|
62
|
+
|
63
|
+
# Default event name if it is not set - date expression based on exposure time of primary photo
|
64
|
+
@event_name = options[:event_name] || "%d %a"
|
65
|
+
|
66
|
+
# Event to path conversion.
|
67
|
+
# id, name, comment
|
68
|
+
# via event.exposure_time.strftime() and sprintf(format,event)
|
69
|
+
@event_path = options[:event_path] || "%Y-%m %{name}"
|
70
|
+
|
71
|
+
# File names (without extension
|
72
|
+
@photo_path = options[:photo_path] || "%{id}"
|
73
|
+
|
74
|
+
@video_path = options[:video_path] || @photo_path
|
75
|
+
|
76
|
+
example_event = { id: 1, name: "<event name>", comment: "<event comment>", exposure_time: 0}
|
77
|
+
|
78
|
+
example_photo = { id: 1000, title: "<photo title>", comment: "<photo comment>",
|
79
|
+
rating:5, exposure_time: Time.now.to_i, filename: "photo.jpg", type: "photo" }
|
80
|
+
example_video = { id: 1000, title: "<photo title>", comment: "<photo comment>",
|
81
|
+
rating:5, exposure_time: Time.now.to_i, filename: "video.mp4", type: "video" }
|
82
|
+
|
83
|
+
event_path = event_path(example_event)
|
84
|
+
|
85
|
+
puts "Mapping paths as\n#{file_path(event_path,example_photo)}\n#{file_path(event_path,example_video)}"
|
86
|
+
|
87
|
+
min_rating = options[:rating] || 0
|
88
|
+
|
89
|
+
sql = sprintf(SHOTWELL_SQL,min_rating)
|
90
|
+
|
91
|
+
super(shotwell_db,sql,use_raw_file_access: true)
|
92
|
+
end
|
93
|
+
|
94
|
+
def transforms_dir
|
95
|
+
unless @transforms_dir
|
96
|
+
@transforms_dir = "#{@shotwell_dir}/fuse/transforms"
|
97
|
+
FileUtils.mkdir_p(@transforms_dir) unless File.directory?(@transforms_dir)
|
98
|
+
end
|
99
|
+
@transforms_dir
|
100
|
+
end
|
101
|
+
|
102
|
+
def transform_required?(filename,transform_id)
|
103
|
+
!(File.exists?(filename) && transform_id.eql?(Xattr.new(filename)[XATTR_TRANSFORM_ID]))
|
104
|
+
end
|
105
|
+
|
106
|
+
def transform(row)
|
107
|
+
if row[:transformations]
|
108
|
+
|
109
|
+
transformations = Transform.new(row[:transformations])
|
110
|
+
|
111
|
+
transform_id = transformations.generate_id(row[:id])
|
112
|
+
filename = "#{transforms_dir}/#{row[:id]}.jpg"
|
113
|
+
|
114
|
+
if transform_id
|
115
|
+
|
116
|
+
if transform_required?(filename,transform_id)
|
117
|
+
|
118
|
+
puts "Generating transform for #{row[:filename]}"
|
119
|
+
puts "Writing to #{filename} with id #{transform_id}"
|
120
|
+
puts transformations
|
121
|
+
|
122
|
+
transformations.apply(row[:filename],filename)
|
123
|
+
|
124
|
+
xattr = Xattr.new(filename)
|
125
|
+
xattr[XATTR_TRANSFORM_ID] = transform_id
|
126
|
+
end
|
127
|
+
|
128
|
+
return [ transform_id, filename ]
|
129
|
+
end
|
130
|
+
end
|
131
|
+
# Ho transforms
|
132
|
+
[ row[:id],row[:filename] ]
|
133
|
+
end
|
134
|
+
|
135
|
+
def map_row(row)
|
136
|
+
row = symbolize(row)
|
137
|
+
xattr = file_xattr(row)
|
138
|
+
|
139
|
+
transform_id, real_file = transform(row)
|
140
|
+
xattr[XATTR_TRANSFORM_ID] = transform_id.to_s
|
141
|
+
|
142
|
+
path = file_path(@events[row[:event_id]].path,row)
|
143
|
+
|
144
|
+
options = { :exposure_time => row[:exposure_time], :event_id => row[:event_id], :xattr => xattr }
|
145
|
+
[ real_file, path, options ]
|
146
|
+
end
|
147
|
+
|
148
|
+
def scan
|
149
|
+
db.create_function("source_type",1) do |func, value|
|
150
|
+
func.result = self.class.source_type(value)
|
151
|
+
end
|
152
|
+
|
153
|
+
db.create_function("source_id",1) do |func, value|
|
154
|
+
func.result = self.class.source_id(value)
|
155
|
+
end
|
156
|
+
|
157
|
+
load_keywords
|
158
|
+
load_events
|
159
|
+
|
160
|
+
puts "Scan ##{scan_id} Finding images and photos for #{@events.size} events"
|
161
|
+
super
|
162
|
+
@keywords= nil
|
163
|
+
@events = nil
|
164
|
+
end
|
165
|
+
|
166
|
+
|
167
|
+
# override default time handling for pathmapper
|
168
|
+
def times(path)
|
169
|
+
possible_file = node(path)
|
170
|
+
tm = possible_file ? possible_file[:exposure_time] : nil
|
171
|
+
|
172
|
+
#set mtime and ctime to the exposure time
|
173
|
+
return tm ? [0, tm, tm] : [0, 0, 0]
|
174
|
+
end
|
175
|
+
|
176
|
+
def statistics(path)
|
177
|
+
df_path = unmap(path) || @shotwell_dir
|
178
|
+
df = Sys::Filesystem.stat(df_path)
|
179
|
+
stats.to_statistics(df.blocks_available * df.block_size, df.files_free)
|
180
|
+
end
|
181
|
+
|
182
|
+
def mounted()
|
183
|
+
super
|
184
|
+
start_notifier()
|
185
|
+
end
|
186
|
+
|
187
|
+
def unmounted()
|
188
|
+
stop_notifier()
|
189
|
+
super
|
190
|
+
end
|
191
|
+
|
192
|
+
|
193
|
+
private
|
194
|
+
|
195
|
+
attr_reader :events,:keywords
|
196
|
+
|
197
|
+
def event_path(event)
|
198
|
+
event_time = Time.at(event[:exposure_time])
|
199
|
+
event[:name] ||= Time.at(event_time).strftime(@event_name)
|
200
|
+
return sprintf(event_time.strftime(@event_path),event)
|
201
|
+
end
|
202
|
+
|
203
|
+
def event_xattr(event)
|
204
|
+
xattr = {}
|
205
|
+
xattr['user.shotwell.event_id'] = event[:id].to_s
|
206
|
+
xattr['user.shotwell.event_name'] = event[:name] || ""
|
207
|
+
xattr['user.shotwell.event_comment'] = event[:comment] || ""
|
208
|
+
return xattr
|
209
|
+
end
|
210
|
+
|
211
|
+
def file_path(event_path,image)
|
212
|
+
ext = File.extname(image[:filename]).downcase
|
213
|
+
|
214
|
+
format = image['type'] == 'photo' ? @photo_path : @video_path
|
215
|
+
|
216
|
+
filename = sprintf(Time.at(image[:exposure_time]).strftime(format),image)
|
217
|
+
|
218
|
+
return "#{event_path}/#{filename}#{ext}"
|
219
|
+
end
|
220
|
+
|
221
|
+
def file_xattr(image)
|
222
|
+
xattr = { }
|
223
|
+
xattr['user.shotwell.title'] = image[:title] || ""
|
224
|
+
xattr['user.shotwell.comment'] = image[:comment] || ""
|
225
|
+
xattr['user.shotwell.id'] = image[:id].to_s
|
226
|
+
|
227
|
+
keywords = @keywords[image[:type]][image[:id]]
|
228
|
+
xattr['user.shotwell.keywords'] = keywords.to_a.join(",")
|
229
|
+
xattr['user.shotwell.rating'] = image[:rating].to_s
|
230
|
+
return xattr
|
231
|
+
end
|
232
|
+
|
233
|
+
def symbolize(row)
|
234
|
+
Hash[row.map{ |k, v| [(k.respond_to?(:to_sym) ? k.to_sym : k), v] }]
|
235
|
+
end
|
236
|
+
|
237
|
+
def load_events
|
238
|
+
@events = {}
|
239
|
+
db.execute(EVENT_SQL) do |row|
|
240
|
+
row = symbolize(row)
|
241
|
+
id = row[:id]
|
242
|
+
path = event_path(row)
|
243
|
+
time = row[:exposure_time]
|
244
|
+
|
245
|
+
xattr = event_xattr(row)
|
246
|
+
|
247
|
+
@events[id] = Event.new(id,path,time,xattr)
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
def load_keywords
|
252
|
+
@keywords = {}
|
253
|
+
@keywords['video'] = Hash.new() { |h,k| h[k] = Set.new() }
|
254
|
+
@keywords['photo'] = Hash.new() { |h,k| h[k] = Set.new() }
|
255
|
+
|
256
|
+
db.execute(TAG_SQL) do |row|
|
257
|
+
name = row['name']
|
258
|
+
photo_list = row['photo_id_list']
|
259
|
+
next unless photo_list
|
260
|
+
# just use the last entry on the tag path
|
261
|
+
slash = name.rindex('/')
|
262
|
+
|
263
|
+
tag = slash ? name[slash+1..-1] : name
|
264
|
+
photo_list.split(",").each do |item|
|
265
|
+
type = self.class.source_type(item)
|
266
|
+
id = self.class.source_id(item)
|
267
|
+
@keywords[type][id] << tag
|
268
|
+
end
|
269
|
+
end
|
270
|
+
nil
|
271
|
+
end
|
272
|
+
|
273
|
+
def map_file(*args)
|
274
|
+
node = super
|
275
|
+
parent = node.parent
|
276
|
+
unless parent[:sw_scan_id] == scan_id
|
277
|
+
event_id = node[:event_id]
|
278
|
+
event = @events[event_id]
|
279
|
+
parent[:xattr] = event.xattr
|
280
|
+
parent[:exposure_time] = event.time
|
281
|
+
parent[:sw_scan_id] = scan_id
|
282
|
+
end
|
283
|
+
node
|
284
|
+
end
|
285
|
+
|
286
|
+
def start_notifier
|
287
|
+
@notifier ||= INotify::Notifier.new()
|
288
|
+
modified = false
|
289
|
+
@notifier.watch(db_path,:modify,:close_write) do |event|
|
290
|
+
modified = modified || event.flags.include?(:modify)
|
291
|
+
if modified && event.flags.include?(:close_write)
|
292
|
+
puts "calling rescan"
|
293
|
+
rescan
|
294
|
+
puts "rescanned"
|
295
|
+
modified = false
|
296
|
+
end
|
297
|
+
end
|
298
|
+
Thread.new { @notifier.run }
|
299
|
+
end
|
300
|
+
|
301
|
+
def stop_notifier
|
302
|
+
@notifier.stop() if @notifier
|
303
|
+
end
|
304
|
+
end
|
305
|
+
end
|
@@ -0,0 +1,183 @@
|
|
1
|
+
require 'RMagick'
|
2
|
+
require 'digest/md5'
|
3
|
+
require 'iniparse'
|
4
|
+
module ShotwellFS
|
5
|
+
|
6
|
+
class Transform
|
7
|
+
|
8
|
+
# Bump this if the transformation code changes in a way
|
9
|
+
# that would change the images
|
10
|
+
VERSION = 1
|
11
|
+
|
12
|
+
attr_reader :crop,:redeye
|
13
|
+
|
14
|
+
class RedEye
|
15
|
+
attr_reader :eyes
|
16
|
+
|
17
|
+
Eye = Struct.new(:x,:y,:radius) do
|
18
|
+
def to_s()
|
19
|
+
"#{x},#{y}+#{radius}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
def initialize(section)
|
25
|
+
@eyes = []
|
26
|
+
num_points = section['num_points'].to_i
|
27
|
+
0.upto(num_points - 1) do |point|
|
28
|
+
radius = section["radius#{point}"].to_i
|
29
|
+
center = section["center#{point}"]
|
30
|
+
match = /\((\d+),\s*(\d+)\)/.match(center)
|
31
|
+
@eyes << Eye.new(match[1].to_i,match[2].to_i,radius)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def to_s
|
36
|
+
"RedEye(#{@eyes.join(',')})"
|
37
|
+
end
|
38
|
+
|
39
|
+
#convert.exe before.jpg -region "230x140+60+130" ^
|
40
|
+
#-fill black ^
|
41
|
+
#-fuzz 25%% ^
|
42
|
+
#-opaque rgb("192","00","10")
|
43
|
+
def apply(image)
|
44
|
+
image.view(0,0,image.columns,image.rows) do |view|
|
45
|
+
eyes.each { |eye| do_redeye(eye,view) }
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# This algorithm ported directly from shotwell.
|
50
|
+
def do_redeye(eye,pixbuf)
|
51
|
+
|
52
|
+
#
|
53
|
+
# we remove redeye within a circular region called the "effect
|
54
|
+
#extent." the effect extent is inscribed within its "bounding
|
55
|
+
#rectangle." */
|
56
|
+
|
57
|
+
# /* for each scanline in the top half-circle of the effect extent,
|
58
|
+
# compute the number of pixels by which the effect extent is inset
|
59
|
+
#from the edges of its bounding rectangle. note that we only have
|
60
|
+
#to do this for the first quadrant because the second quadrant's
|
61
|
+
#insets can be derived by symmetry */
|
62
|
+
r = eye.radius
|
63
|
+
x_insets_first_quadrant = Array.new(eye.radius + 1)
|
64
|
+
|
65
|
+
i = 0
|
66
|
+
r.step(0,-1) do |y|
|
67
|
+
theta = Math.asin(y.to_f / r)
|
68
|
+
x = (r.to_f * Math.cos(theta) + 0.5).to_i
|
69
|
+
x_insets_first_quadrant[i] = eye.radius - x
|
70
|
+
i = i + 1
|
71
|
+
end
|
72
|
+
|
73
|
+
x_bounds_min = eye.x - eye.radius
|
74
|
+
x_bounds_max = eye.x + eye.radius
|
75
|
+
ymin = eye.y - eye.radius
|
76
|
+
ymin = (ymin < 0) ? 0 : ymin
|
77
|
+
ymax = eye.y
|
78
|
+
ymax = (ymax > (pixbuf.height - 1)) ? (pixbuf.height - 1) : ymax
|
79
|
+
|
80
|
+
#/* iterate over all the pixels in the top half-circle of the effect
|
81
|
+
#extent from top to bottom */
|
82
|
+
inset_index = 0
|
83
|
+
ymin.upto(ymax) do |y_it|
|
84
|
+
xmin = x_bounds_min + x_insets_first_quadrant[inset_index]
|
85
|
+
xmin = (xmin < 0) ? 0 : xmin
|
86
|
+
xmax = x_bounds_max - x_insets_first_quadrant[inset_index]
|
87
|
+
xmax = (xmax > (pixbuf.width - 1)) ? (pixbuf.width - 1) : xmax
|
88
|
+
|
89
|
+
xmin.upto(xmax) { |x_it| red_reduce_pixel(pixbuf,x_it, y_it) }
|
90
|
+
inset_index += 1
|
91
|
+
end
|
92
|
+
|
93
|
+
#/* iterate over all the pixels in the bottom half-circle of the effect
|
94
|
+
#extent from top to bottom */
|
95
|
+
ymin = eye.y
|
96
|
+
ymax = eye.y + eye.radius
|
97
|
+
inset_index = x_insets_first_quadrant.length - 1
|
98
|
+
ymin.upto(ymax) do |y_it|
|
99
|
+
xmin = x_bounds_min + x_insets_first_quadrant[inset_index]
|
100
|
+
xmin = (xmin < 0) ? 0 : xmin
|
101
|
+
xmax = x_bounds_max - x_insets_first_quadrant[inset_index]
|
102
|
+
xmax = (xmax > (pixbuf.width - 1)) ? (pixbuf.width - 1) : xmax
|
103
|
+
|
104
|
+
xmin.upto(xmax) { |x_it| red_reduce_pixel(pixbuf,x_it, y_it) }
|
105
|
+
inset_index -= 1
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def red_reduce_pixel(pixbuf,x,y)
|
110
|
+
#/* Due to inaccuracies in the scaler, we can occasionally
|
111
|
+
#* get passed a coordinate pair outside the image, causing
|
112
|
+
#* us to walk off the array and into segfault territory.
|
113
|
+
# * Check coords prior to drawing to prevent this... */
|
114
|
+
if ((x >= 0) && (y >= 0) && (x < pixbuf.width) && (y < pixbuf.height))
|
115
|
+
|
116
|
+
#/* The pupil of the human eye has no pigment, so we expect all
|
117
|
+
#color channels to be of about equal intensity. This means that at
|
118
|
+
#any point within the effects region, the value of the red channel
|
119
|
+
#should be about the same as the values of the green and blue
|
120
|
+
#channels. So set the value of the red channel to be the mean of the
|
121
|
+
#values of the red and blue channels. This preserves achromatic
|
122
|
+
#intensity across all channels while eliminating any extraneous flare
|
123
|
+
#affecting the red channel only (i.e. the red-eye effect). */
|
124
|
+
g = pixbuf[y][x].green
|
125
|
+
b = pixbuf[y][x].blue
|
126
|
+
pixbuf[y][x].red = (g + b) / 2
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
class Crop
|
132
|
+
attr_reader :x,:y,:width,:height
|
133
|
+
def initialize(section)
|
134
|
+
@x = section["left"].to_i
|
135
|
+
@y = section["top"].to_i
|
136
|
+
@width = section["right"].to_i - @x
|
137
|
+
@height = section["bottom"].to_i - @y
|
138
|
+
end
|
139
|
+
|
140
|
+
def to_s
|
141
|
+
"Crop(#{x},#{y},#{width},#{height})"
|
142
|
+
end
|
143
|
+
|
144
|
+
def apply(image)
|
145
|
+
image.crop!(x,y,width,height)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def initialize(transformations)
|
150
|
+
doc = IniParse.parse(transformations)
|
151
|
+
@crop = doc.has_section?("crop") ? Crop.new(doc["crop"]) : nil
|
152
|
+
@redeye = doc.has_section?("redeye") ? RedEye.new(doc["redeye"]) : nil
|
153
|
+
end
|
154
|
+
|
155
|
+
def has_transforms?
|
156
|
+
( crop || redeye ) && true
|
157
|
+
end
|
158
|
+
|
159
|
+
def generate_id(photo_id)
|
160
|
+
if has_transforms?
|
161
|
+
Digest::MD5.hexdigest("#{photo_id}:#{self}")
|
162
|
+
else
|
163
|
+
nil
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def apply(input,output)
|
168
|
+
image = Magick::Image.read(input)[0]
|
169
|
+
image.format = "JPG"
|
170
|
+
|
171
|
+
redeye.apply(image) if redeye
|
172
|
+
crop.apply(image) if crop
|
173
|
+
|
174
|
+
image.write(output)
|
175
|
+
image.destroy!
|
176
|
+
end
|
177
|
+
|
178
|
+
def to_s
|
179
|
+
xforms = [ redeye, crop ].reject { |x| x.nil? }.join("\n ")
|
180
|
+
"#{self.class.name}:#{VERSION}\n #{xforms}"
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
data/lib/shotwellfs.rb
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'shotwellfs/version'
|
2
|
+
require 'rfusefs'
|
3
|
+
require 'shotwellfs/filesystem'
|
4
|
+
|
5
|
+
module ShotwellFS
|
6
|
+
|
7
|
+
OPTIONS = [ :rating, :event_name, :event_path, :photo_path, :video_path ]
|
8
|
+
OPTION_USAGE = <<-HELP
|
9
|
+
|
10
|
+
[shotwellfs: #{VERSION}]
|
11
|
+
|
12
|
+
-o rating=N only include photos and videos with this rating or greater (default 0)
|
13
|
+
-o event_name=FMT strftime format used to generate text for unnamed events (default "%d %a")
|
14
|
+
-o event_path=FMT strftime and sprintf format to generate path prefix for events.
|
15
|
+
Available event fields - id, name, comment
|
16
|
+
(default "%Y-%m %<name>s")
|
17
|
+
-o photo_path=FMT strftime and sprintf format to generate path for photo files
|
18
|
+
Available photo fields - id, title, comment, rating
|
19
|
+
(default "%<id>d")
|
20
|
+
-o video_path=FMT as above for video files. If not set, photo_path is used.
|
21
|
+
|
22
|
+
HELP
|
23
|
+
|
24
|
+
def self.main(*args)
|
25
|
+
|
26
|
+
FuseFS.main(args,OPTIONS,OPTION_USAGE,"path/to/shotwell_dir") do |options|
|
27
|
+
if options[:device] && File.exists?("#{options[:device]}/data/photo.db")
|
28
|
+
fs = FileSystem.new(options)
|
29
|
+
Signal.trap("HUP") { fs.rescan() }
|
30
|
+
fs
|
31
|
+
else
|
32
|
+
puts "shotwellfs: failed to access Shotwell photo database #{options[:device]}/data/photo.db"
|
33
|
+
nil
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
data/shotwellfs.gemspec
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'shotwellfs/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "shotwellfs"
|
8
|
+
gem.version = ShotwellFS::VERSION
|
9
|
+
gem.authors = ["Grant Gardner"]
|
10
|
+
gem.email = ["grant@lastweekend.com.au"]
|
11
|
+
gem.description = %q{A Fuse filesystem to remap image files according to shotwell metadata}
|
12
|
+
gem.summary = %q{FUSE fileystem for Shotwell}
|
13
|
+
gem.homepage = "http://github.com/lwoggardner/shotwellfs"
|
14
|
+
|
15
|
+
gem.files = `git ls-files`.split($/)
|
16
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
17
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
18
|
+
gem.require_paths = ["lib"]
|
19
|
+
|
20
|
+
gem.add_runtime_dependency("sqlite3","~>1.3")
|
21
|
+
gem.add_runtime_dependency("rfusefs",">=1.0.2")
|
22
|
+
gem.add_runtime_dependency("rb-inotify","~>0.9")
|
23
|
+
gem.add_runtime_dependency("rmagick","~>2.13")
|
24
|
+
gem.add_runtime_dependency("iniparse","~>1.1")
|
25
|
+
gem.add_runtime_dependency("ffi-xattr","~>0.0")
|
26
|
+
gem.add_runtime_dependency("sys-filesystem")
|
27
|
+
gem.add_development_dependency("yard")
|
28
|
+
gem.add_development_dependency("redcarpet")
|
29
|
+
|
30
|
+
end
|
metadata
ADDED
@@ -0,0 +1,202 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: shotwellfs
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1RC0
|
5
|
+
prerelease: 5
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Grant Gardner
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2014-06-20 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: sqlite3
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '1.3'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ~>
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '1.3'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: rfusefs
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: 1.0.2
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: 1.0.2
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: rb-inotify
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ~>
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0.9'
|
54
|
+
type: :runtime
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ~>
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0.9'
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: rmagick
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ~>
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '2.13'
|
70
|
+
type: :runtime
|
71
|
+
prerelease: false
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ~>
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '2.13'
|
78
|
+
- !ruby/object:Gem::Dependency
|
79
|
+
name: iniparse
|
80
|
+
requirement: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ~>
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '1.1'
|
86
|
+
type: :runtime
|
87
|
+
prerelease: false
|
88
|
+
version_requirements: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ~>
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '1.1'
|
94
|
+
- !ruby/object:Gem::Dependency
|
95
|
+
name: ffi-xattr
|
96
|
+
requirement: !ruby/object:Gem::Requirement
|
97
|
+
none: false
|
98
|
+
requirements:
|
99
|
+
- - ~>
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0.0'
|
102
|
+
type: :runtime
|
103
|
+
prerelease: false
|
104
|
+
version_requirements: !ruby/object:Gem::Requirement
|
105
|
+
none: false
|
106
|
+
requirements:
|
107
|
+
- - ~>
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '0.0'
|
110
|
+
- !ruby/object:Gem::Dependency
|
111
|
+
name: sys-filesystem
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
113
|
+
none: false
|
114
|
+
requirements:
|
115
|
+
- - ! '>='
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :runtime
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
none: false
|
122
|
+
requirements:
|
123
|
+
- - ! '>='
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
- !ruby/object:Gem::Dependency
|
127
|
+
name: yard
|
128
|
+
requirement: !ruby/object:Gem::Requirement
|
129
|
+
none: false
|
130
|
+
requirements:
|
131
|
+
- - ! '>='
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '0'
|
134
|
+
type: :development
|
135
|
+
prerelease: false
|
136
|
+
version_requirements: !ruby/object:Gem::Requirement
|
137
|
+
none: false
|
138
|
+
requirements:
|
139
|
+
- - ! '>='
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: '0'
|
142
|
+
- !ruby/object:Gem::Dependency
|
143
|
+
name: redcarpet
|
144
|
+
requirement: !ruby/object:Gem::Requirement
|
145
|
+
none: false
|
146
|
+
requirements:
|
147
|
+
- - ! '>='
|
148
|
+
- !ruby/object:Gem::Version
|
149
|
+
version: '0'
|
150
|
+
type: :development
|
151
|
+
prerelease: false
|
152
|
+
version_requirements: !ruby/object:Gem::Requirement
|
153
|
+
none: false
|
154
|
+
requirements:
|
155
|
+
- - ! '>='
|
156
|
+
- !ruby/object:Gem::Version
|
157
|
+
version: '0'
|
158
|
+
description: A Fuse filesystem to remap image files according to shotwell metadata
|
159
|
+
email:
|
160
|
+
- grant@lastweekend.com.au
|
161
|
+
executables:
|
162
|
+
- shotwellfs
|
163
|
+
extensions: []
|
164
|
+
extra_rdoc_files: []
|
165
|
+
files:
|
166
|
+
- .gitignore
|
167
|
+
- Gemfile
|
168
|
+
- LICENSE.txt
|
169
|
+
- README.md
|
170
|
+
- Rakefile
|
171
|
+
- bin/shotwellfs
|
172
|
+
- lib/shotwellfs.rb
|
173
|
+
- lib/shotwellfs/filesystem.rb
|
174
|
+
- lib/shotwellfs/transform.rb
|
175
|
+
- lib/shotwellfs/version.rb
|
176
|
+
- shotwellfs.gemspec
|
177
|
+
homepage: http://github.com/lwoggardner/shotwellfs
|
178
|
+
licenses: []
|
179
|
+
post_install_message:
|
180
|
+
rdoc_options: []
|
181
|
+
require_paths:
|
182
|
+
- lib
|
183
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
184
|
+
none: false
|
185
|
+
requirements:
|
186
|
+
- - ! '>='
|
187
|
+
- !ruby/object:Gem::Version
|
188
|
+
version: '0'
|
189
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
190
|
+
none: false
|
191
|
+
requirements:
|
192
|
+
- - ! '>'
|
193
|
+
- !ruby/object:Gem::Version
|
194
|
+
version: 1.3.1
|
195
|
+
requirements: []
|
196
|
+
rubyforge_project:
|
197
|
+
rubygems_version: 1.8.25
|
198
|
+
signing_key:
|
199
|
+
specification_version: 3
|
200
|
+
summary: FUSE fileystem for Shotwell
|
201
|
+
test_files: []
|
202
|
+
has_rdoc:
|