use Gdk::Pixbuf#get_file_info for obtaining the dimensions of the picture, its faaaster
[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 begin
32     require 'gtk2'
33 rescue LoadError
34     $no_gtk2 = true
35 end
36 begin
37     require 'booh/libadds'
38 rescue LoadError
39     $no_libadds = true
40 end
41
42 module Booh
43     $verbose_level = 2
44     $CURRENT_CHARSET = `locale charmap`.chomp
45     $convert = 'convert -interlace line +profile "*"'
46     $convert_enhance = '-contrast -enhance -normalize'
47
48     def utf8(string)
49         return Iconv::iconv("UTF-8", $CURRENT_CHARSET, string).to_s
50     end
51
52     def utf8cut(string, maxlen)
53         begin
54             return Iconv::iconv("UTF-8", $CURRENT_CHARSET, string[0..maxlen-1]).to_s
55         rescue Iconv::InvalidCharacter
56             return utf8cut(string, maxlen-1)
57         end
58     end
59
60     def sizename(key)
61         #- fake for gettext to find these; if themes need more sizes, english name for them should be added here
62         sizenames = { 'small' => utf8(_("small")), 'medium' => utf8(_("medium")), 'large' => utf8(_("large")),
63                       'x-large' => utf8(_("x-large")), 'xx-large' => utf8(_("xx-large")),
64                       'original' => utf8(_("original")) }
65         return sizenames[key] || key
66     end
67     
68     def from_utf8(string)
69         return Iconv::iconv($CURRENT_CHARSET, "UTF-8", string).to_s
70     end
71
72     def from_utf8_safe(string)
73         begin
74             return Iconv::iconv($CURRENT_CHARSET, "UTF-8", string).to_s
75         rescue Iconv::IllegalSequence
76             return ''
77         end
78     end
79
80     def make_dest_filename_old(orig_filename)
81         #- we remove non alphanumeric characters but need to do that
82         #- cleverly to not end up with two similar dest filenames. we won't
83         #- urlencode because urldecode might happen in the browser.
84         return orig_filename.unpack("C*").collect { |v| v.chr =~ /[a-zA-Z\-_0-9\.\/]/ ? v.chr : sprintf("%2X", v) }.to_s
85     end
86
87     def make_dest_filename(orig_filename)
88         #- we remove non alphanumeric characters but need to do that
89         #- cleverly to not end up with two similar dest filenames. we won't
90         #- urlencode because urldecode might happen in the browser.
91         return orig_filename.unpack("C*").collect { |v| v.chr =~ /[a-zA-Z\-_0-9\.\/]/ ? v.chr : sprintf("~%02X", v) }.to_s
92     end
93
94     def msg(verbose_level, msg)
95         if verbose_level <= $verbose_level
96             if verbose_level == 0
97                 warn _("\t***ERROR***: %s\n") % msg
98             elsif verbose_level == 1
99                 warn _("\tWarning: %s\n") % msg
100             else
101                 puts msg
102             end
103         end
104     end
105
106     def msg_(verbose_level, msg)
107         if verbose_level <= $verbose_level
108             if verbose_level == 0
109                 warn _("\t***ERROR***: %s") % msg
110             elsif verbose_level == 1
111                 warn _("\tWarning: %s") % msg
112             else
113                 print msg
114             end
115         end
116     end
117
118     def die_(msg)
119         puts msg
120         exit 1
121     end
122
123     def select_theme(name, limit_sizes, optimizefor32, nperrow)
124         $theme = name
125         msg 3, _("Selecting theme '%s'") % $theme
126         themedir = "#{$FPATH}/themes/#{$theme}"
127         if !File.directory?(themedir)
128             die _("Theme was not found (tried %s directory).") % themedir
129         end
130         eval File.open("#{themedir}/metadata/parameters.rb").readlines.join
131
132         if limit_sizes
133             if limit_sizes != 'all'
134                 sizes = limit_sizes.split(/,/)
135                 $images_size = $images_size.find_all { |e| sizes.include?(e['name']) }
136                 if $images_size.length == 0
137                     die _("Can't carry on, no valid size selected.")
138                 end
139             end
140         else
141             $images_size = $images_size.find_all { |e| !e['optional'] }
142         end
143
144         if optimizefor32
145             $images_size.each { |e|
146                 e['fullscreen'].gsub!(/(\d+x)(\d+)/) { $1 + ($2.to_f*8/9).to_i.to_s }
147                 e['thumbnails'].gsub!(/(\d+x)(\d+)/) { $1 + ($2.to_f*8/9).to_i.to_s }
148             }
149             $albums_thumbnail_size.gsub!(/(\d+x)(\d+)/) { $1 + ($2.to_f*8/9).to_i.to_s }
150         end
151
152         if nperrow && nperrow != $default_N
153             ratio = nperrow.to_f / $default_N.to_f
154             $images_size.each { |e|
155                 e['thumbnails'].gsub!(/(\d+)x(\d+)/) { ($1.to_f/ratio).to_i.to_s + 'x' + ($2.to_f/ratio).to_i.to_s }
156             }
157         end
158
159         $default_size = $images_size.detect { |sizeobj| sizeobj['default'] }
160         if $default_size == nil
161             $default_size = $images_size[0]
162         end
163     end
164
165     def entry2type(entry)
166         if entry =~ /\.(jpg|jpeg|jpe|gif|bmp|png)$/i && entry !~ /['"\[\]]/
167             return 'image'
168         elsif !$ignore_videos && entry =~ /\.(mov|avi|mpg|mpeg|mpe|wmv|asx|3gp|mp4)$/i && entry !~ /['"\[\]]/
169             #- might consider using file magic later..
170             return 'video'
171         else
172             return nil
173         end
174     end
175
176     def sys(cmd)
177         msg 2, cmd
178         system(cmd)
179     end
180
181     def waitjob
182         finished = Process.wait2
183         $pids.delete(finished[0])
184         $pids = $pids.find_all { |pid| Process.waitpid(pid, Process::WNOHANG) == nil }
185     end
186
187     def waitjobs
188         while $pids && $pids.length > 0
189             waitjob
190         end
191     end
192
193     #- parallelizable sys
194     def psys(cmd)
195         if $mproc
196             if pid = fork
197                 $pids << pid
198             else
199                 msg 2, cmd + ' &'
200                 system(cmd)
201                 exit 0
202             end
203             if $pids.length == $mproc
204                 waitjob
205             end
206         else
207             sys(cmd)
208         end
209     end
210
211     #- grab the results of a command but don't sleep forever on a runaway process
212     def subproc_runaway_aware(command)
213         begin
214             timeout(10) {
215                 return `#{command}`
216             }
217         rescue Timeout::Error    
218             msg 1, _("forgetting runaway process (transcode sucks again?)")
219             #- todo should slay transcode but dunno how to do that
220             return nil
221         end
222     end
223
224     def get_image_size(fullpath)
225         if !$no_identify
226             if $sizes_cache.nil?
227                 $sizes_cache = {}
228             end
229             if $sizes_cache[fullpath].nil?
230                 #- identify is slow, try with gdk if available (negligible vs 35ms)
231                 if $no_gtk2
232                     if `identify '#{fullpath}'` =~ / JPEG (\d+)x(\d+) /
233                         $sizes_cache[fullpath] = { :x => $1.to_i, :y => $2.to_i }
234                     end
235                 else
236                     format, width, height = Gdk::Pixbuf.get_file_info(fullpath)
237                     if width
238                         $sizes_cache[fullpath] = { :x => width, :y => height }
239                     end
240                 end
241             end
242             return $sizes_cache[fullpath]
243         else
244             return nil
245         end
246     end
247
248     #- commify from http://pleac.sourceforge.net/ (pleac rulz)
249     def commify(n)
250         n.to_s =~ /([^\.]*)(\..*)?/
251         int, dec = $1.reverse, $2 ? $2 : ""
252         while int.gsub!(/(,|\.|^)(\d{3})(\d)/, '\1\2' + _(",") + '\3')
253         end
254         int.reverse + dec
255     end
256
257     def guess_rotate(filename)
258         #- identify is slow, try with libexif if available (4ms vs 35ms)
259         if $no_libadds
260             if $no_identify
261                 return 0
262             end
263             orientation = `identify -format "%[EXIF:orientation]" '#{filename}'`.chomp.to_i
264         else
265             orientation = Exif.orientation(filename)
266         end
267
268         if orientation == 6
269             angle = 90
270         elsif orientation == 8
271             angle = -90
272         else
273             return 0
274         end
275
276         #- remove rotate if image is obviously already in portrait (situation can come from gthumb)
277         size = get_image_size(filename)
278         if size && size[:x] < size[:y]
279             return 0
280         else
281             return angle
282         end
283     end
284
285     def rotate_pixbuf(pixbuf, angle)
286         return pixbuf.rotate(angle ==  90 ? Gdk::Pixbuf::ROTATE_CLOCKWISE :
287                              angle == 180 ? Gdk::Pixbuf::ROTATE_UPSIDEDOWN :
288                              (angle == 270 || angle == -90) ? Gdk::Pixbuf::ROTATE_COUNTERCLOCKWISE :
289                                             Gdk::Pixbuf::ROTATE_NONE)
290     end
291
292     def gen_thumbnails_element(orig, xmldirorelem, allow_background, dests)
293         if xmldirorelem.name == 'dir'
294             xmldirorelem = xmldirorelem.elements["*[@filename='#{utf8(File.basename(orig))}']"]
295         end
296         gen_thumbnails(orig, allow_background, dests, xmldirorelem, '')
297     end
298
299     def gen_thumbnails_subdir(orig, xmldirorelem, allow_background, dests, type)
300         #- type can be `subdirs' or `thumbnails' 
301         gen_thumbnails(orig, allow_background, dests, xmldirorelem, type + '-')
302     end
303
304     def gen_thumbnails(orig, allow_background, dests, felem, attributes_prefix)
305         if !dests.detect { |dest| !File.exists?(dest['filename']) } 
306             return true
307         end
308
309         convert_options = ''
310         dest_dir = make_dest_filename(File.dirname(dests[0]['filename']))
311
312         if entry2type(orig) == 'image'
313             if felem
314                 if whitebalance = felem.attributes["#{attributes_prefix}white-balance"]
315                     neworig = "#{dest_dir}/#{File.basename(orig)}-whitebalance#{whitebalance}.jpg"
316                     cmd = "booh-fix-whitebalance '#{orig}' '#{neworig}' #{whitebalance}"
317                     sys(cmd)
318                     if File.exists?(neworig)
319                         orig = neworig
320                     end
321                 end
322                 if gammacorrect = felem.attributes["#{attributes_prefix}gamma-correction"]
323                     neworig = "#{dest_dir}/#{File.basename(orig)}-gammacorrect#{gammacorrect}.jpg"
324                     cmd = "booh-gamma-correction '#{orig}' '#{neworig}' #{gammacorrect}"
325                     sys(cmd)
326                     if File.exists?(neworig)
327                         orig = neworig
328                     end
329                 end
330                 rotate = felem.attributes["#{attributes_prefix}rotate"]
331                 if !rotate
332                     felem.add_attribute("#{attributes_prefix}rotate", rotate = guess_rotate(orig).to_s)
333                 end
334                 convert_options += "-rotate #{rotate} "
335                 if felem.attributes["#{attributes_prefix}enhance"]
336                     convert_options += ($config['convert-enhance'] || $convert_enhance) + " "
337                 end
338             end
339             for dest in dests
340                 if !File.exists?(dest['filename'])
341                     cmd = nil
342                     cmd ||= "#{$convert} #{convert_options}-size #{dest['size']} -resize '#{dest['size']}>' '#{orig}' '#{dest['filename']}'"
343                     if allow_background
344                         psys(cmd)
345                     else
346                         sys(cmd)
347                     end
348                 end
349             end
350             if neworig
351                 if allow_background
352                     waitjobs
353                 end
354                 system("rm -f '#{neworig}'")
355             end
356             return true
357
358         elsif entry2type(orig) == 'video'
359             if felem
360                 #- frame-offset is an attribute that allows to specify which frame to use for the thumbnail
361                 frame_offset = felem.attributes["#{attributes_prefix}frame-offset"]
362                 if !frame_offset
363                     felem.add_attribute("#{attributes_prefix}frame-offset", frame_offset = "0")
364                 end
365                 frame_offset = frame_offset.to_i
366                 if rotate = felem.attributes["#{attributes_prefix}rotate"]
367                     convert_options += "-rotate #{rotate} "
368                 end
369                 if felem.attributes["#{attributes_prefix}enhance"]
370                     convert_options += ($config['convert-enhance'] || $convert_enhance) + " "
371                 end
372             end
373             orig_base = File.basename(dests[0]['filename']) + File.basename(orig)
374             orig_image = "#{dest_dir}/#{orig_base}.jpg000000.jpg"
375             system("rm -f '#{orig_image}'")
376             for dest in dests
377                 if !File.exists?(orig_image)
378                     transcode_options = ''
379                     if felem
380                         if felem.attributes["#{attributes_prefix}color-swap"]
381                             transcode_options += '-k '
382                         end
383                     end
384                     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"
385                     msg 2, cmd
386                     system cmd
387                     if File.exists?("#{dest_dir}/#{orig_base}.avi")
388                         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"
389                         msg 2, cmd
390                         results = subproc_runaway_aware(cmd)
391                         system("rm -f '#{dest_dir}/#{orig_base}.avi'")
392                         if results =~ /skipping frames/ && results =~ /encoded 0 frames/
393                             msg 0, _("specified frame-offset too large? max frame was: %s. that may also be another probleme. try another value.") %
394                                 results.scan(/skipping frames \[000000-(\d+)\]/)[-1]
395                             return false
396                         elsif results =~ /V: import format.*unknown/ || !File.exists?(orig_image)
397                             msg 0, _("could not extract first image of video %s encoded by mencoder") % "#{dest_dir}/#{orig_base}.avi"
398                             return false
399                         end
400                     else
401                         msg 0, _("could not make mencoder to encode %s to mjpeg") % orig
402                         return false
403                     end
404                     if felem && whitebalance = felem.attributes["#{attributes_prefix}white-balance"]
405                         if whitebalance.to_f != 0
406                             neworig = "#{dest_dir}/#{orig_base}-whitebalance#{whitebalance}.jpg"
407                             cmd = "booh-fix-whitebalance '#{orig_image}' '#{neworig}' #{whitebalance}"
408                             sys(cmd)
409                             if File.exists?(neworig)
410                                 orig_image = neworig
411                             end
412                         end
413                     end
414                     if felem && gammacorrect = felem.attributes["#{attributes_prefix}gamma-correction"]
415                         if gammacorrect.to_f != 0
416                             neworig = "#{dest_dir}/#{orig_base}-gammacorrect#{gammacorrect}.jpg"
417                             cmd = "booh-gamma-correction '#{orig_image}' '#{neworig}' #{gammacorrect}"
418                             sys(cmd)
419                             if File.exists?(neworig)
420                                 orig_image = neworig
421                             end
422                         end
423                     end
424                 end
425                 if !File.exists?(dest['filename'])
426                     sys("#{$convert} #{convert_options}-size #{dest['size']} -resize #{dest['size']} '#{orig_image}' '#{dest['filename']}'")
427                 end
428             end
429             if neworig
430                 system("rm -f '#{neworig}'")
431             end
432             return true
433         end
434     end
435
436     def invornil(obj, methodname)
437         if obj == nil
438             return nil
439         else
440             return obj.method(methodname).call
441         end
442     end
443
444     def find_subalbum_info_type(xmldir)
445         #- first look for subdirs info; if not, means there is no subdir
446         if xmldir.attributes['subdirs-caption']
447             return 'subdirs'
448         else
449             return 'thumbnails'
450         end
451     end
452
453     def find_subalbum_caption_info(xmldir)
454         type = find_subalbum_info_type(xmldir)
455         return [ from_utf8(xmldir.attributes["#{type}-captionfile"]), xmldir.attributes["#{type}-caption"] ]
456     end
457
458     def file_size(path)
459         begin
460             return File.size(path)
461         rescue
462             return -1
463         end
464     end
465
466     def max(a, b)
467         a > b ? a : b
468     end
469
470     def clamp(n, a, b)
471         n < a ? a : n > b ? b : n
472     end
473
474     def pano_amount(elem)
475         if pano_amount = elem.attributes['pano-amount']
476             if $N_per_row
477                 return clamp(pano_amount.to_f, 1, $N_per_row.to_i)
478             else
479                 return clamp(pano_amount.to_f, 1, $default_N.to_i)
480             end
481         else
482             return nil
483         end
484     end
485
486     def substInFile(name)
487         newcontent = IO.readlines(name).collect { |l| yield l }
488         ios = File.open(name, "w")
489         ios.write(newcontent)
490         ios.close
491     end
492 end
493
494 class File
495     def File.reduce_path(path)
496         return path.gsub(/\w+\/\.\.\//, '')
497     end
498 end
499
500 module Enumerable
501     def collect_with_index
502         out = []
503         each_with_index { |e,i|
504             out << yield(e,i)
505         }
506         return out
507     end
508 end
509
510 class REXML::Element
511     def previous_element_byname(name)
512         n = self
513         while n = n.previous_element
514             if n.name == name
515                 return n
516             end
517         end
518         return nil
519     end
520
521     def previous_element_byname_notattr(name, attr)
522         n = self
523         while n = n.previous_element
524             if n.name == name && !n.attributes[attr]
525                 return n
526             end
527         end
528         return nil
529     end
530
531     def next_element_byname(name)
532         n = self
533         while n = n.next_element
534             if n.name == name
535                 return n
536             end
537         end
538         return nil
539     end
540
541     def next_element_byname_notattr(name, attr)
542         n = self
543         while n = n.next_element
544             if n.name == name && !n.attributes[attr]
545                 return n
546             end
547         end
548         return nil
549     end
550
551     def child_byname_notattr(name, attr)
552         elements.each(name) { |element|
553             if !element.attributes[attr]
554                 return element
555             end
556         }
557         return nil
558     end
559 end
560
561