use identify to discover EXIF orientation rather than exif
[booh] / lib / booh / booh-lib.rb
1 #                         *  BOOH  *
2 #
3 # A.k.a `Best web-album Of the world, Or your money back, Humerus'.
4 #
5 # The acronyn sucks, however this is a tribute to Dragon Ball by
6 # Akira Toriyama, where the last enemy beaten by heroes of Dragon
7 # Ball is named "Boo". But there was already a free software project
8 # called Boo, so this one will be it "Booh". Or whatever.
9 #
10 #
11 # Copyright (c) 2004 Guillaume Cottenceau <gc3 at bluewin.ch>
12 #
13 # This software may be freely redistributed under the terms of the GNU
14 # public license version 2.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
19
20 require 'iconv'
21 require 'timeout'
22
23 require 'rexml/document'
24
25 require 'gettext'
26 include GetText
27 bindtextdomain("booh")
28
29 require 'booh/config.rb'
30 require 'booh/version.rb'
31
32 module Booh
33     $verbose_level = 2
34     $CURRENT_CHARSET = `locale charmap`.chomp
35     $convert = 'convert -interlace line +profile "*"'
36     $convert_enhance = '-contrast -enhance -normalize'
37
38     def utf8(string)
39         return Iconv::iconv("UTF-8", $CURRENT_CHARSET, string).to_s
40     end
41
42     def sizename(key)
43         #- fake for gettext to find these; if themes need more sizes, english name for them should be added here
44         sizenames = { 'small' => utf8(_("small")), 'medium' => utf8(_("medium")), 'large' => utf8(_("large")),
45                       'x-large' => utf8(_("x-large")), 'xx-large' => utf8(_("xx-large")),
46                       'original' => utf8(_("original")) }
47         return sizenames[key] || key
48     end
49     
50     def from_utf8(string)
51         return Iconv::iconv($CURRENT_CHARSET, "UTF-8", string).to_s
52     end
53
54     def make_dest_filename(orig_filename)
55         #- we remove non alphanumeric characters but need to do that
56         #- cleverly to not end up with two similar dest filenames. we won't
57         #- urlencode because urldecode might happen in the browser.
58         return orig_filename.unpack("C*").collect { |v| v.chr =~ /[a-zA-Z\-_0-9\.\/]/ ? v.chr : sprintf("%2X", v) }.to_s
59     end
60
61     def msg(verbose_level, msg)
62         if verbose_level <= $verbose_level
63             if verbose_level == 0
64                 warn _("\t***ERROR***: %s\n") % msg
65             elsif verbose_level == 1
66                 warn _("\tWarning: %s\n") % msg
67             else
68                 puts msg
69             end
70         end
71     end
72
73     def msg_(verbose_level, msg)
74         if verbose_level <= $verbose_level
75             if verbose_level == 0
76                 warn _("\t***ERROR***: %s") % msg
77             elsif verbose_level == 1
78                 warn _("\tWarning: %s") % msg
79             else
80                 print msg
81             end
82         end
83     end
84
85     def die(msg)
86         puts msg
87         exit 1
88     end
89
90     def select_theme(name, limit_sizes, optimizefor32, nperrow)
91         $theme = name
92         msg 3, _("Selecting theme '%s'") % $theme
93         themedir = "#{$FPATH}/themes/#{$theme}"
94         if !File.directory?(themedir)
95             die _("Theme was not found (tried %s directory).") % themedir
96         end
97         eval File.open("#{themedir}/metadata/parameters.rb").readlines.join
98
99         if limit_sizes
100             if limit_sizes != 'all'
101                 sizes = limit_sizes.split(/,/)
102                 $images_size = $images_size.find_all { |e| sizes.include?(e['name']) }
103                 if $images_size.length == 0
104                     die _("Can't carry on, no valid size selected.")
105                 end
106             end
107         else
108             $images_size = $images_size.find_all { |e| !e['optional'] }
109         end
110
111         if optimizefor32
112             $images_size.each { |e|
113                 e['fullscreen'].gsub!(/(\d+x)(\d+)/) { $1 + ($2.to_f*8/9).to_i.to_s }
114                 e['thumbnails'].gsub!(/(\d+x)(\d+)/) { $1 + ($2.to_f*8/9).to_i.to_s }
115             }
116             $albums_thumbnail_size.gsub!(/(\d+x)(\d+)/) { $1 + ($2.to_f*8/9).to_i.to_s }
117         end
118
119         if nperrow && nperrow != $default_N
120             ratio = nperrow.to_f / $default_N.to_f
121             $images_size.each { |e|
122                 e['thumbnails'].gsub!(/(\d+)x(\d+)/) { ($1.to_f/ratio).to_i.to_s + 'x' + ($2.to_f/ratio).to_i.to_s }
123             }
124         end
125
126         $default_size = $images_size.detect { |sizeobj| sizeobj['default'] }
127         if $default_size == nil
128             $default_size = $images_size[0]
129         end
130     end
131
132     def entry2type(entry)
133         if entry =~ /\.(jpg|jpeg|jpe|gif|bmp|png)$/i && entry !~ /['"\[\]]/
134             return 'image'
135         elsif !$ignore_videos && entry =~ /\.(mov|avi|mpg|mpeg|mpe|wmv|asx|3gp|mp4)$/i && entry !~ /['"\[\]]/
136             #- might consider using file magic later..
137             return 'video'
138         else
139             return nil
140         end
141     end
142
143     def sys(cmd)
144         msg 2, cmd
145         system(cmd)
146     end
147
148     def waitjob
149         finished = Process.wait2
150         $pids.delete(finished[0])
151         $pids = $pids.find_all { |pid| Process.waitpid(pid, Process::WNOHANG) == nil }
152     end
153
154     def waitjobs
155         while $pids && $pids.length > 0
156             waitjob
157         end
158     end
159
160     #- parallelizable sys
161     def psys(cmd)
162         if $mproc
163             if pid = fork
164                 $pids << pid
165             else
166                 msg 2, cmd + ' &'
167                 system(cmd)
168                 exit 0
169             end
170             if $pids.length == $mproc
171                 waitjob
172             end
173         else
174             sys(cmd)
175         end
176     end
177
178     #- grab the results of a command but don't sleep forever on a runaway process
179     def subproc_runaway_aware(command)
180         begin
181             timeout(10) {
182                 return `#{command}`
183             }
184         rescue Timeout::Error    
185             msg 1, _("forgetting runaway process (transcode sucks again?)")
186             #- todo should slay transcode but dunno how to do that
187             return nil
188         end
189     end
190
191     def get_image_size(fullpath)
192         if !$no_identify && `identify '#{fullpath}'` =~ / JPEG (\d+)x(\d+) /
193             return { :x => $1.to_i, :y => $2.to_i }
194         else
195             return nil
196         end
197     end
198
199     #- commify from http://pleac.sourceforge.net/ (pleac rulz)
200     def commify(n)
201         n.to_s =~ /([^\.]*)(\..*)?/
202         int, dec = $1.reverse, $2 ? $2 : ""
203         while int.gsub!(/(,|\.|^)(\d{3})(\d)/, '\1\2' + _(",") + '\3')
204         end
205         int.reverse + dec
206     end
207
208     def guess_rotate(filename)
209         if $no_identify
210             return 0
211         end
212
213         orientation = `identify -format "%[EXIF:orientation]" '#{filename}'`.chomp
214         if orientation == '6'
215             angle = 90
216         elsif orientation == '8'
217             angle = -90
218         else
219             return 0
220         end
221         size = get_image_size(filename)
222         #- remove rotate if image is obviously already in portrait (situation can come from gthumb)
223         if size && size[:x] < size[:y]
224             return 0
225         else
226             return angle
227         end
228     end
229
230     def rotate_pixbuf(pixbuf, angle)
231         return pixbuf.rotate(angle ==  90 ? Gdk::Pixbuf::ROTATE_CLOCKWISE :
232                              angle == 180 ? Gdk::Pixbuf::ROTATE_UPSIDEDOWN :
233                              angle == 270 ? Gdk::Pixbuf::ROTATE_COUNTERCLOCKWISE :
234                                             Gdk::Pixbuf::ROTATE_NONE)
235     end
236
237     def gen_thumbnails_element(orig, xmldirorelem, allow_background, dests)
238         if xmldirorelem.name == 'dir'
239             xmldirorelem = xmldirorelem.elements["*[@filename='#{utf8(File.basename(orig))}']"]
240         end
241         gen_thumbnails(orig, allow_background, dests, xmldirorelem, '')
242     end
243
244     def gen_thumbnails_subdir(orig, xmldirorelem, allow_background, dests, type)
245         #- type can be `subdirs' or `thumbnails' 
246         gen_thumbnails(orig, allow_background, dests, xmldirorelem, type + '-')
247     end
248
249     def gen_thumbnails(orig, allow_background, dests, felem, attributes_prefix)
250         if !dests.detect { |dest| !File.exists?(dest['filename']) } 
251             return true
252         end
253
254         convert_options = ''
255         dest_dir = make_dest_filename(File.dirname(dests[0]['filename']))
256
257         if entry2type(orig) == 'image'
258             if felem
259                 if whitebalance = felem.attributes["#{attributes_prefix}white-balance"]
260                     neworig = "#{dest_dir}/#{File.basename(orig)}-whitebalance#{whitebalance}.jpg"
261                     cmd = "booh-fix-whitebalance '#{orig}' '#{neworig}' #{whitebalance}"
262                     sys(cmd)
263                     if File.exists?(neworig)
264                         orig = neworig
265                     end
266                 end
267                 rotate = felem.attributes["#{attributes_prefix}rotate"]
268                 if !rotate
269                     felem.add_attribute("#{attributes_prefix}rotate", rotate = guess_rotate(orig).to_i)
270                 end
271                 convert_options += "-rotate #{rotate} "
272                 if felem.attributes["#{attributes_prefix}enhance"]
273                     convert_options += ($config['convert-enhance'] || $convert_enhance) + " "
274                 end
275             end
276             for dest in dests
277                 if !File.exists?(dest['filename'])
278                     cmd = nil
279                     cmd ||= "#{$convert} #{convert_options}-size #{dest['size']} -resize '#{dest['size']}>' '#{orig}' '#{dest['filename']}'"
280                     if allow_background
281                         psys(cmd)
282                     else
283                         sys(cmd)
284                     end
285                 end
286             end
287             if neworig
288                 if allow_background
289                     waitjobs
290                 end
291                 system("rm -f '#{neworig}'")
292             end
293             return true
294
295         elsif entry2type(orig) == 'video'
296             if felem
297                 #- frame-offset is an attribute that allows to specify which frame to use for the thumbnail
298                 frame_offset = felem.attributes["#{attributes_prefix}frame-offset"]
299                 if !frame_offset
300                     felem.add_attribute("#{attributes_prefix}frame-offset", frame_offset = "0")
301                 end
302                 frame_offset = frame_offset.to_i
303                 if rotate = felem.attributes["#{attributes_prefix}rotate"]
304                     convert_options += "-rotate #{rotate} "
305                 end
306                 if felem.attributes["#{attributes_prefix}enhance"]
307                     convert_options += ($config['convert-enhance'] || $convert_enhance) + " "
308                 end
309             end
310             orig_base = File.basename(dests[0]['filename']) + File.basename(orig)
311             orig_image = "#{dest_dir}/#{orig_base}.jpg000000.jpg"
312             system("rm -f '#{orig_image}'")
313             for dest in dests
314                 if !File.exists?(orig_image)
315                     transcode_options = ''
316                     if felem
317                         if felem.attributes["#{attributes_prefix}color-swap"]
318                             transcode_options += '-k '
319                         end
320                     end
321                     cmd = "transcode -a 0 -c #{frame_offset}-#{frame_offset+1} -i '#{orig}' -y jpg -o '#{dest_dir}/#{orig_base}.jpg' #{transcode_options} 2>&1"
322                     msg 2, cmd
323                     results = subproc_runaway_aware(cmd)
324                     if results =~ /skipping frames/ && results =~ /encoded 0 frames/
325                         msg 0, _("specified frame-offset too large? max frame was: %s. that may also be another problem. try another value.") %
326                                results.scan(/skipping frames \[000000-(\d+)\]/)[-1]
327                         return false
328                     elsif results =~ /V: import format.*unknown/ || !File.exists?(orig_image)
329                         msg 2, _("* could not extract first image of video %s with transcode, will try first converting with mencoder") % orig
330                         cmd = "mencoder '#{orig}' -nosound -ovc lavc -lavcopts vcodec=mjpeg -o '#{dest_dir}/#{orig_base}.avi' -frames #{frame_offset+26} -fps 25 >/dev/null 2>/dev/null"
331                         msg 2, cmd
332                         system cmd
333                         if File.exists?("#{dest_dir}/#{orig_base}.avi")
334                             cmd = "transcode -a 0 -c #{frame_offset}-#{frame_offset+1} -i '#{dest_dir}/#{orig_base}.avi' -y jpg -o '#{dest_dir}/#{orig_base}.jpg' #{transcode_options} 2>&1"
335                             msg 2, cmd
336                             results = subproc_runaway_aware(cmd)
337                             system("rm -f '#{dest_dir}/#{orig_base}.avi'")
338                             if results =~ /skipping frames/ && results =~ /encoded 0 frames/
339                                 msg 0, _("specified frame-offset too large? max frame was: %s. that may also be another probleme. try another value.") %
340                                        results.scan(/skipping frames \[000000-(\d+)\]/)[-1]
341                                 return false
342                             elsif results =~ /V: import format.*unknown/ || !File.exists?(orig_image)
343                                 msg 0, _("could not extract first image of video %s encoded by mencoder") % "#{dest_dir}/#{orig_base}.avi"
344                                 return false
345                             end
346                         else
347                             msg 0, _("could not make mencoder to encode %s to mpeg4") % orig
348                             return false
349                         end
350                     end
351                     if felem && whitebalance = felem.attributes["#{attributes_prefix}white-balance"]
352                         if whitebalance.to_f != 0
353                             neworig = "#{dest_dir}/#{orig_base}-whitebalance#{whitebalance}.jpg"
354                             cmd = "booh-fix-whitebalance '#{orig_image}' '#{neworig}' #{whitebalance}"
355                             sys(cmd)
356                             if File.exists?(neworig)
357                                 orig_image = neworig
358                             end
359                         end
360                     end
361                 end
362                 if !File.exists?(dest['filename'])
363                     sys("#{$convert} #{convert_options}-size #{dest['size']} -resize #{dest['size']} '#{orig_image}' '#{dest['filename']}'")
364                 end
365             end
366             if neworig
367                 system("rm -f '#{neworig}'")
368             end
369             return true
370         end
371     end
372
373     def invornil(obj, methodname)
374         if obj == nil
375             return nil
376         else
377             return obj.method(methodname).call
378         end
379     end
380
381     def find_subalbum_info_type(xmldir)
382         #- first look for subdirs info; if not, means there is no subdir
383         if xmldir.attributes['subdirs-caption']
384             return 'subdirs'
385         else
386             return 'thumbnails'
387         end
388     end
389
390     def find_subalbum_caption_info(xmldir)
391         type = find_subalbum_info_type(xmldir)
392         return [ from_utf8(xmldir.attributes["#{type}-captionfile"]), xmldir.attributes["#{type}-caption"] ]
393     end
394
395     def file_size(path)
396         begin
397             return File.size(path)
398         rescue
399             return -1
400         end
401     end
402
403     def max(a, b)
404         a > b ? a : b
405     end
406
407     def substInFile(name)
408         newcontent = IO.readlines(name).collect { |l| yield l }
409         ios = File.open(name, "w")
410         ios.write(newcontent)
411         ios.close
412     end
413 end
414
415 class File
416     def File.reduce_path(path)
417         return path.gsub(/\w+\/\.\.\//, '')
418     end
419 end
420
421 class REXML::Element
422     def previous_element_byname(name)
423         n = self
424         while n = n.previous_element
425             if n.name == name
426                 return n
427             end
428         end
429         return nil
430     end
431
432     def previous_element_byname_notattr(name, attr)
433         n = self
434         while n = n.previous_element
435             if n.name == name && !n.attributes[attr]
436                 return n
437             end
438         end
439         return nil
440     end
441
442     def next_element_byname(name)
443         n = self
444         while n = n.next_element
445             if n.name == name
446                 return n
447             end
448         end
449         return nil
450     end
451
452     def next_element_byname_notattr(name, attr)
453         n = self
454         while n = n.next_element
455             if n.name == name && !n.attributes[attr]
456                 return n
457             end
458         end
459         return nil
460     end
461
462     def child_byname_notattr(name, attr)
463         elements.each(name) { |element|
464             if !element.attributes[attr]
465                 return element
466             end
467         }
468         return nil
469     end
470 end
471
472