transcode first place is not reliable, use mencoder first
[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 = "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"
373                     msg 2, cmd
374                     system cmd
375                     if File.exists?("#{dest_dir}/#{orig_base}.avi")
376                         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"
377                         msg 2, cmd
378                         results = subproc_runaway_aware(cmd)
379                         system("rm -f '#{dest_dir}/#{orig_base}.avi'")
380                         if results =~ /skipping frames/ && results =~ /encoded 0 frames/
381                             msg 0, _("specified frame-offset too large? max frame was: %s. that may also be another probleme. try another value.") %
382                                 results.scan(/skipping frames \[000000-(\d+)\]/)[-1]
383                             return false
384                         elsif results =~ /V: import format.*unknown/ || !File.exists?(orig_image)
385                             msg 0, _("could not extract first image of video %s encoded by mencoder") % "#{dest_dir}/#{orig_base}.avi"
386                             return false
387                         end
388                     else
389                         msg 0, _("could not make mencoder to encode %s to mjpeg") % orig
390                         return false
391                     end
392                     if felem && whitebalance = felem.attributes["#{attributes_prefix}white-balance"]
393                         if whitebalance.to_f != 0
394                             neworig = "#{dest_dir}/#{orig_base}-whitebalance#{whitebalance}.jpg"
395                             cmd = "booh-fix-whitebalance '#{orig_image}' '#{neworig}' #{whitebalance}"
396                             sys(cmd)
397                             if File.exists?(neworig)
398                                 orig_image = neworig
399                             end
400                         end
401                     end
402                     if felem && gammacorrect = felem.attributes["#{attributes_prefix}gamma-correction"]
403                         if gammacorrect.to_f != 0
404                             neworig = "#{dest_dir}/#{orig_base}-gammacorrect#{gammacorrect}.jpg"
405                             cmd = "booh-gamma-correction '#{orig_image}' '#{neworig}' #{gammacorrect}"
406                             sys(cmd)
407                             if File.exists?(neworig)
408                                 orig_image = neworig
409                             end
410                         end
411                     end
412                 end
413                 if !File.exists?(dest['filename'])
414                     sys("#{$convert} #{convert_options}-size #{dest['size']} -resize #{dest['size']} '#{orig_image}' '#{dest['filename']}'")
415                 end
416             end
417             if neworig
418                 system("rm -f '#{neworig}'")
419             end
420             return true
421         end
422     end
423
424     def invornil(obj, methodname)
425         if obj == nil
426             return nil
427         else
428             return obj.method(methodname).call
429         end
430     end
431
432     def find_subalbum_info_type(xmldir)
433         #- first look for subdirs info; if not, means there is no subdir
434         if xmldir.attributes['subdirs-caption']
435             return 'subdirs'
436         else
437             return 'thumbnails'
438         end
439     end
440
441     def find_subalbum_caption_info(xmldir)
442         type = find_subalbum_info_type(xmldir)
443         return [ from_utf8(xmldir.attributes["#{type}-captionfile"]), xmldir.attributes["#{type}-caption"] ]
444     end
445
446     def file_size(path)
447         begin
448             return File.size(path)
449         rescue
450             return -1
451         end
452     end
453
454     def max(a, b)
455         a > b ? a : b
456     end
457
458     def clamp(n, a, b)
459         n < a ? a : n > b ? b : n
460     end
461
462     def pano_amount(elem)
463         if pano_amount = elem.attributes['pano-amount']
464             if $N_per_row
465                 return clamp(pano_amount.to_f, 1, $N_per_row.to_i)
466             else
467                 return clamp(pano_amount.to_f, 1, $default_N.to_i)
468             end
469         else
470             return nil
471         end
472     end
473
474     def substInFile(name)
475         newcontent = IO.readlines(name).collect { |l| yield l }
476         ios = File.open(name, "w")
477         ios.write(newcontent)
478         ios.close
479     end
480 end
481
482 class File
483     def File.reduce_path(path)
484         return path.gsub(/\w+\/\.\.\//, '')
485     end
486 end
487
488 module Enumerable
489     def collect_with_index
490         out = []
491         each_with_index { |e,i|
492             out << yield(e,i)
493         }
494         return out
495     end
496 end
497
498 class REXML::Element
499     def previous_element_byname(name)
500         n = self
501         while n = n.previous_element
502             if n.name == name
503                 return n
504             end
505         end
506         return nil
507     end
508
509     def previous_element_byname_notattr(name, attr)
510         n = self
511         while n = n.previous_element
512             if n.name == name && !n.attributes[attr]
513                 return n
514             end
515         end
516         return nil
517     end
518
519     def next_element_byname(name)
520         n = self
521         while n = n.next_element
522             if n.name == name
523                 return n
524             end
525         end
526         return nil
527     end
528
529     def next_element_byname_notattr(name, attr)
530         n = self
531         while n = n.next_element
532             if n.name == name && !n.attributes[attr]
533                 return n
534             end
535         end
536         return nil
537     end
538
539     def child_byname_notattr(name, attr)
540         elements.each(name) { |element|
541             if !element.attributes[attr]
542                 return element
543             end
544         }
545         return nil
546     end
547 end
548
549