seems that mencoder will sometimes fail when transcoding for a
[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 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(5) {
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 `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         orientation = `exif '#{filename}'`.detect { |line| line =~ /^Orientation/ }
210         if orientation =~ /right - top/
211             angle = 90
212         elsif orientation =~ /left - bottom/
213             angle = -90
214         else
215             return 0
216         end
217         size = get_image_size(filename)
218         #- remove rotate if image is obviously already in portrait (situation can come from gthumb)
219         if size && size[:x] < size[:y]
220             return 0
221         else
222             return angle
223         end
224     end
225
226     def rotate_pixbuf(pixbuf, angle)
227         return pixbuf.rotate(angle ==  90 ? Gdk::Pixbuf::ROTATE_CLOCKWISE :
228                              angle == 180 ? Gdk::Pixbuf::ROTATE_UPSIDEDOWN :
229                              angle == 270 ? Gdk::Pixbuf::ROTATE_COUNTERCLOCKWISE :
230                                             Gdk::Pixbuf::ROTATE_NONE)
231     end
232
233     def gen_thumbnails_element(orig, xmldirorelem, allow_background, dests)
234         if xmldirorelem.name == 'dir'
235             xmldirorelem = xmldirorelem.elements["[@filename='#{utf8(File.basename(orig))}']"]
236         end
237         gen_thumbnails(orig, allow_background, dests, xmldirorelem, '')
238     end
239
240     def gen_thumbnails_subdir(orig, xmldirorelem, allow_background, dests, type)
241         #- type can be `subdirs' or `thumbnails' 
242         gen_thumbnails(orig, allow_background, dests, xmldirorelem, type + '-')
243     end
244
245     def gen_thumbnails(orig, allow_background, dests, felem, attributes_prefix)
246         if !dests.detect { |dest| !File.exists?(dest['filename']) } 
247             return true
248         end
249
250         convert_options = ''
251         dest_dir = make_dest_filename(File.dirname(dests[0]['filename']))
252
253         if entry2type(orig) == 'image'
254             if felem
255                 if whitebalance = felem.attributes["#{attributes_prefix}white-balance"]
256                     neworig = "#{dest_dir}/#{File.basename(orig)}-whitebalance#{whitebalance}.jpg"
257                     cmd = "booh-fix-whitebalance '#{orig}' '#{neworig}' #{whitebalance}"
258                     sys(cmd)
259                     if File.exists?(neworig)
260                         orig = neworig
261                     end
262                 end
263                 rotate = felem.attributes["#{attributes_prefix}rotate"]
264                 if !rotate
265                     felem.add_attribute("#{attributes_prefix}rotate", rotate = guess_rotate(orig).to_i)
266                 end
267                 convert_options += "-rotate #{rotate} "
268                 if felem.attributes["#{attributes_prefix}enhance"]
269                     convert_options += ($config['convert-enhance'] || $convert_enhance) + " "
270                 end
271             end
272             for dest in dests
273                 if !File.exists?(dest['filename'])
274                     cmd = nil
275                     #- don't resize if image is already smaller than destination size
276                     if size = get_image_size(orig)
277                         dest['size'] =~ /(\d+)x(\d+)/
278                         if (rotate == "90" || rotate == "270" || size[:x] < size[:y]) ? size[:y] < $1.to_i : size[:x] < $1.to_i
279                             cmd = "#{$convert} #{convert_options} '#{orig}' '#{dest['filename']}'"
280                         end
281                     end
282                     cmd ||= "#{$convert} #{convert_options}-size #{dest['size']} -resize #{dest['size']} '#{orig}' '#{dest['filename']}'"
283                     if allow_background
284                         psys(cmd)
285                     else
286                         sys(cmd)
287                     end
288                 end
289             end
290             if neworig
291                 if allow_background
292                     waitjobs
293                 end
294                 system("rm -f '#{neworig}'")
295             end
296             return true
297
298         elsif entry2type(orig) == 'video'
299             if felem
300                 #- frame-offset is an attribute that allows to specify which frame to use for the thumbnail
301                 frame_offset = felem.attributes["#{attributes_prefix}frame-offset"]
302                 if !frame_offset
303                     felem.add_attribute("#{attributes_prefix}frame-offset", frame_offset = "0")
304                 end
305                 frame_offset = frame_offset.to_i
306                 if rotate = felem.attributes["#{attributes_prefix}rotate"]
307                     convert_options += "-rotate #{rotate} "
308                 end
309                 if felem.attributes["#{attributes_prefix}enhance"]
310                     convert_options += ($config['convert-enhance'] || $convert_enhance) + " "
311                 end
312             end
313             orig_image = "#{dest_dir}/#{File.basename(orig)}.jpg000000.jpg"
314             system("rm -f '#{orig_image}'")
315             for dest in dests
316                 if !File.exists?(orig_image)
317                     transcode_options = ''
318                     if felem
319                         if felem.attributes["#{attributes_prefix}color-swap"]
320                             transcode_options += '-k '
321                         end
322                     end
323                     cmd = "transcode -a 0 -c #{frame_offset}-#{frame_offset+1} -i '#{orig}' -y jpg -o '#{dest_dir}/#{File.basename(orig)}.jpg' #{transcode_options} 2>&1"
324                     msg 2, cmd
325                     results = subproc_runaway_aware(cmd)
326                     if results =~ /skipping frames/ && results =~ /encoded 0 frames/
327                         msg 0, _("specified frame-offset too large? max frame was: %s. that may also be another problem. try another value.") %
328                                results.scan(/skipping frames \[000000-(\d+)\]/)[-1]
329                         return false
330                     elsif results =~ /V: import format.*unknown/ || !File.exists?(orig_image)
331                         msg 2, _("* could not extract first image of video %s with transcode, will try first converting with mencoder") % orig
332                         cmd = "mencoder '#{orig}' -nosound -ovc lavc -lavcopts vcodec=mjpeg -o '#{dest_dir}/#{File.basename(orig)}.avi' -frames #{frame_offset+26} -fps 25 >/dev/null 2>/dev/null"
333                         msg 2, cmd
334                         system cmd
335                         if File.exists?("#{dest_dir}/#{File.basename(orig)}.avi")
336                             cmd = "transcode -a 0 -c #{frame_offset}-#{frame_offset+1} -i '#{dest_dir}/#{File.basename(orig)}.avi' -y jpg -o '#{dest_dir}/#{File.basename(orig)}.jpg' #{transcode_options} 2>&1"
337                             msg 2, cmd
338                             results = subproc_runaway_aware(cmd)
339                             system("rm -f '#{dest_dir}/#{File.basename(orig)}.avi'")
340                             if results =~ /skipping frames/ && results =~ /encoded 0 frames/
341                                 msg 0, _("specified frame-offset too large? max frame was: %s. that may also be another probleme. try another value.") %
342                                        results.scan(/skipping frames \[000000-(\d+)\]/)[-1]
343                                 return false
344                             elsif results =~ /V: import format.*unknown/ || !File.exists?(orig_image)
345                                 msg 0, _("could not extract first image of video %s encoded by mencoder") % "#{dest_dir}/#{File.basename(orig)}.avi"
346                                 return false
347                             end
348                         else
349                             msg 0, _("could not make mencoder to encode %s to mpeg4") % orig
350                             return false
351                         end
352                     end
353                     if felem && whitebalance = felem.attributes["#{attributes_prefix}white-balance"]
354                         if whitebalance.to_f != 0
355                             neworig = "#{dest_dir}/#{File.basename(orig)}-whitebalance#{whitebalance}.jpg"
356                             cmd = "booh-fix-whitebalance '#{orig_image}' '#{neworig}' #{whitebalance}"
357                             sys(cmd)
358                             if File.exists?(neworig)
359                                 orig_image = neworig
360                             end
361                         end
362                     end
363                 end
364                 if !File.exists?(dest['filename'])
365                     sys("#{$convert} #{convert_options}-size #{dest['size']} -resize #{dest['size']} '#{orig_image}' '#{dest['filename']}'")
366                 end
367             end
368             if neworig
369                 system("rm -f '#{neworig}'")
370             end
371             return true
372         end
373     end
374
375     def invornil(obj, methodname)
376         if obj == nil
377             return nil
378         else
379             return obj.method(methodname).call
380         end
381     end
382
383     def find_subalbum_info_type(xmldir)
384         #- first look for subdirs info; if not, means there is no subdir
385         if xmldir.attributes['subdirs-caption']
386             return 'subdirs'
387         else
388             return 'thumbnails'
389         end
390     end
391
392     def find_subalbum_caption_info(xmldir)
393         type = find_subalbum_info_type(xmldir)
394         return [ from_utf8(xmldir.attributes["#{type}-captionfile"]), xmldir.attributes["#{type}-caption"] ]
395     end
396
397     def file_size(path)
398         begin
399             return File.size(path)
400         rescue
401             return -1
402         end
403     end
404
405     def max(a, b)
406         a > b ? a : b
407     end
408
409     def substInFile(name)
410         newcontent = IO.readlines(name).collect { |l| yield l }
411         ios = File.open(name, "w")
412         ios.write(newcontent)
413         ios.close
414     end
415 end
416
417 class File
418     def File.reduce_path(path)
419         return path.gsub(/\w+\/\.\.\//, '')
420     end
421 end
422
423 class REXML::Element
424     def previous_element_byname(name)
425         n = self
426         while n = n.previous_element
427             if n.name == name
428                 return n
429             end
430         end
431         return nil
432     end
433
434     def previous_element_byname_notattr(name, attr)
435         n = self
436         while n = n.previous_element
437             if n.name == name && !n.attributes[attr]
438                 return n
439             end
440         end
441         return nil
442     end
443
444     def next_element_byname(name)
445         n = self
446         while n = n.next_element
447             if n.name == name
448                 return n
449             end
450         end
451         return nil
452     end
453
454     def next_element_byname_notattr(name, attr)
455         n = self
456         while n = n.next_element
457             if n.name == name && !n.attributes[attr]
458                 return n
459             end
460         end
461         return nil
462     end
463
464     def child_byname_notattr(name, attr)
465         elements.each(name) { |element|
466             if !element.attributes[attr]
467                 return element
468             end
469         }
470         return nil
471     end
472 end
473
474