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