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