support no identify and no transcode/mencoder
[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 !$ignore_videos && 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(10) {
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 !$no_identify && `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                     cmd ||= "#{$convert} #{convert_options}-size #{dest['size']} -resize '#{dest['size']}>' '#{orig}' '#{dest['filename']}'"
276                     if allow_background
277                         psys(cmd)
278                     else
279                         sys(cmd)
280                     end
281                 end
282             end
283             if neworig
284                 if allow_background
285                     waitjobs
286                 end
287                 system("rm -f '#{neworig}'")
288             end
289             return true
290
291         elsif entry2type(orig) == 'video'
292             if felem
293                 #- frame-offset is an attribute that allows to specify which frame to use for the thumbnail
294                 frame_offset = felem.attributes["#{attributes_prefix}frame-offset"]
295                 if !frame_offset
296                     felem.add_attribute("#{attributes_prefix}frame-offset", frame_offset = "0")
297                 end
298                 frame_offset = frame_offset.to_i
299                 if rotate = felem.attributes["#{attributes_prefix}rotate"]
300                     convert_options += "-rotate #{rotate} "
301                 end
302                 if felem.attributes["#{attributes_prefix}enhance"]
303                     convert_options += ($config['convert-enhance'] || $convert_enhance) + " "
304                 end
305             end
306             orig_base = File.basename(dests[0]['filename']) + File.basename(orig)
307             orig_image = "#{dest_dir}/#{orig_base}.jpg000000.jpg"
308             system("rm -f '#{orig_image}'")
309             for dest in dests
310                 if !File.exists?(orig_image)
311                     transcode_options = ''
312                     if felem
313                         if felem.attributes["#{attributes_prefix}color-swap"]
314                             transcode_options += '-k '
315                         end
316                     end
317                     cmd = "transcode -a 0 -c #{frame_offset}-#{frame_offset+1} -i '#{orig}' -y jpg -o '#{dest_dir}/#{orig_base}.jpg' #{transcode_options} 2>&1"
318                     msg 2, cmd
319                     results = subproc_runaway_aware(cmd)
320                     if results =~ /skipping frames/ && results =~ /encoded 0 frames/
321                         msg 0, _("specified frame-offset too large? max frame was: %s. that may also be another problem. try another value.") %
322                                results.scan(/skipping frames \[000000-(\d+)\]/)[-1]
323                         return false
324                     elsif results =~ /V: import format.*unknown/ || !File.exists?(orig_image)
325                         msg 2, _("* could not extract first image of video %s with transcode, will try first converting with mencoder") % orig
326                         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"
327                         msg 2, cmd
328                         system cmd
329                         if File.exists?("#{dest_dir}/#{orig_base}.avi")
330                             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"
331                             msg 2, cmd
332                             results = subproc_runaway_aware(cmd)
333                             system("rm -f '#{dest_dir}/#{orig_base}.avi'")
334                             if results =~ /skipping frames/ && results =~ /encoded 0 frames/
335                                 msg 0, _("specified frame-offset too large? max frame was: %s. that may also be another probleme. try another value.") %
336                                        results.scan(/skipping frames \[000000-(\d+)\]/)[-1]
337                                 return false
338                             elsif results =~ /V: import format.*unknown/ || !File.exists?(orig_image)
339                                 msg 0, _("could not extract first image of video %s encoded by mencoder") % "#{dest_dir}/#{orig_base}.avi"
340                                 return false
341                             end
342                         else
343                             msg 0, _("could not make mencoder to encode %s to mpeg4") % orig
344                             return false
345                         end
346                     end
347                     if felem && whitebalance = felem.attributes["#{attributes_prefix}white-balance"]
348                         if whitebalance.to_f != 0
349                             neworig = "#{dest_dir}/#{orig_base}-whitebalance#{whitebalance}.jpg"
350                             cmd = "booh-fix-whitebalance '#{orig_image}' '#{neworig}' #{whitebalance}"
351                             sys(cmd)
352                             if File.exists?(neworig)
353                                 orig_image = neworig
354                             end
355                         end
356                     end
357                 end
358                 if !File.exists?(dest['filename'])
359                     sys("#{$convert} #{convert_options}-size #{dest['size']} -resize #{dest['size']} '#{orig_image}' '#{dest['filename']}'")
360                 end
361             end
362             if neworig
363                 system("rm -f '#{neworig}'")
364             end
365             return true
366         end
367     end
368
369     def invornil(obj, methodname)
370         if obj == nil
371             return nil
372         else
373             return obj.method(methodname).call
374         end
375     end
376
377     def find_subalbum_info_type(xmldir)
378         #- first look for subdirs info; if not, means there is no subdir
379         if xmldir.attributes['subdirs-caption']
380             return 'subdirs'
381         else
382             return 'thumbnails'
383         end
384     end
385
386     def find_subalbum_caption_info(xmldir)
387         type = find_subalbum_info_type(xmldir)
388         return [ from_utf8(xmldir.attributes["#{type}-captionfile"]), xmldir.attributes["#{type}-caption"] ]
389     end
390
391     def file_size(path)
392         begin
393             return File.size(path)
394         rescue
395             return -1
396         end
397     end
398
399     def max(a, b)
400         a > b ? a : b
401     end
402
403     def substInFile(name)
404         newcontent = IO.readlines(name).collect { |l| yield l }
405         ios = File.open(name, "w")
406         ios.write(newcontent)
407         ios.close
408     end
409 end
410
411 class File
412     def File.reduce_path(path)
413         return path.gsub(/\w+\/\.\.\//, '')
414     end
415 end
416
417 class REXML::Element
418     def previous_element_byname(name)
419         n = self
420         while n = n.previous_element
421             if n.name == name
422                 return n
423             end
424         end
425         return nil
426     end
427
428     def previous_element_byname_notattr(name, attr)
429         n = self
430         while n = n.previous_element
431             if n.name == name && !n.attributes[attr]
432                 return n
433             end
434         end
435         return nil
436     end
437
438     def next_element_byname(name)
439         n = self
440         while n = n.next_element
441             if n.name == name
442                 return n
443             end
444         end
445         return nil
446     end
447
448     def next_element_byname_notattr(name, attr)
449         n = self
450         while n = n.next_element
451             if n.name == name && !n.attributes[attr]
452                 return n
453             end
454         end
455         return nil
456     end
457
458     def child_byname_notattr(name, attr)
459         elements.each(name) { |element|
460             if !element.attributes[attr]
461                 return element
462             end
463         }
464         return nil
465     end
466 end
467
468