3 # Part of Opo, a small Video Whale
5 # This python script is a GUI front end for opo
7 # Copyright (C) 2011 Douglas Bagnall
9 # Opo is free software: you can redistribute it and/or modify it under
10 # the terms of the GNU General Public License as published by the Free
11 # Software Foundation, either version 3 of the License, or (at your
12 # option) any later version.
14 # Opo is distributed in the hope that it will be useful, but WITHOUT
15 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
16 # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
17 # License for more details.
19 # You should have received a copy of the GNU General Public License
20 # along with this program. If not, see <http://www.gnu.org/licenses/>.
28 from ConfigParser
import SafeConfigParser
, Error
as CPError
30 #defaults, most can be over-ridden in opo.rc
41 #an approximate target for working out bps settings (for mpeg4/2-ish codecs)
42 #256 * 192 * 4 screens --> 192k * BYTES_PER_PIXEL_PER_SECOND bps
43 #1024 * 768 * 4 screens --> 3M * BYTES_PER_PIXEL_PER_SECOND bps
44 BYTES_PER_PIXEL_PER_SECOND
= 2.5
50 class OpoError(Exception):
55 print >> sys
.stderr
, m
57 def name_suggester(dir, base
, suffix
):
58 from os
.path
import join
, exists
59 for i
in range(1, 999):
60 name
= "%s-%s.%s" % (base
, i
, suffix
)
61 fullname
= join(dir, name
)
62 if not exists(fullname
):
64 raise OpoError("please: think up a name, or we'll be here forever")
66 def start_stitching_process(output_file
, input_files
, width
, height
,
67 audio_source
=None, muxer
=MUXER
, encoder
=ENCODER
,
68 scale
=1.0, clip_top
=0, audio_codec
='mp2',
69 progress_report
=False):
71 from urlparse
import urlsplit
73 for i
, uri
in enumerate(input_files
):
74 if not uri
.startswith('file://'):
76 uri
= 'file://' + urllib
.quote(os
.path
.abspath(uri
))
78 fn
= '/' + urllib
.unquote(urlsplit(uri
).path
)#.decode('utf-8')
79 #trigger exception if the filenamevdoesn't exist (so no http uris)
82 except AttributeError, e
: #"None has no attribute startswith"
84 raise OpoError("Not all input files are specified")
90 muxer
= output_file
.rsplit('.', 1)[1]
92 log('defaulting to avi muxer')
96 'vp8': ['vp8enc', 'quality=8'],
97 'mjpeg': ['jpegenc', 'idct-method=2', 'quality=85'],
98 'mpeg1': ['mpeg2enc',], #XXX need to set bitrate, etc
99 'theora': ['theoraenc',], #XXX settings
100 'x264': ['x264enc', 'tune=fastdecode', 'quantizer=21'],
101 'flv': ['ffenc_flv', "bitrate=%(bitrate)s"], #XXX settings
102 'mpeg4': ['ffenc_mpeg4', "bitrate=%(bitrate)s"], #XXX settings
103 'msmpeg4': ['ffenc_msmpeg4', "bitrate=%(bitrate)s"],#
107 'bitrate': int(BYTES_PER_PIXEL_PER_SECOND
* width
* height
* len(input_files
)),
110 'vorbis': ['vorbisenc', 'bitrate=192', 'cbr=true', 'target=bitrate',],
111 'mp3': ['lamemp3enc', 'bitrate=192', 'cbr=true', 'target=bitrate',],
112 'mp2': ['twolame', 'bitrate=320'],
116 'avi': ['!', 'avimux', 'name=mux', ],
117 'flv': ['!', 'flvmux', 'name=mux', ],
118 'webm': ['!', 'webmmux', 'name=mux', ],
119 'mpeg': ['!', 'mplex', 'name=mux', ],
121 muxers
['mpg'] = muxers
['mpeg']
122 encoder_pipe
= [s
% details
for s
in encoders
[encoder
]]
123 mux_pipe
= [s
% details
for s
in muxers
[muxer
]]
125 progress_pipe
= ['progressreport', '!']
129 pipeline
= (['gst-launch-0.10',
133 '!', 'ffmpegcolorspace',
139 ['!', 'filesink', 'location=%s' % output_file
,])
141 image_width
= int(width
* scale
)
142 image_height
= int(height
* scale
)
143 image_width_adj
= (width
- image_width
) // 2
146 for i
, fn
in enumerate(input_files
):
147 left
= i
* width
+ image_width_adj
148 right
= (len(input_files
) - 1 - i
) * width
+ image_width_adj
150 if fn
.endswith('Julia%20resize%204%2028th.mov'):
153 if i
!= audio_source
:
159 log("doing sound for %s" % i
)
167 pipeline
.extend(audio_codecs
[audio_codec
])
168 pipeline
.extend(['!', 'mux.', 'demux.'])
172 '!', 'video/x-raw-yuv,', 'width=%s,' % image_width
, 'height=%s' % image_height
,
173 ';', 'video/x-raw-rgb,', 'width=%s,' % image_width
, 'height=%s' % image_height
,
174 '!', 'videobox', 'border-alpha=0', 'alpha=1', 'left=-%s' % left
, 'right=-%s' % right
,
175 'top=%s' % top
, 'bottom=%s' % (image_height
- height
- top
),
180 log(' '.join(pipeline
).replace('!', ' \\\n!').replace('. ', '.\\\n '))
181 p
= subprocess
.Popen(pipeline
)
187 stitch_tick_id
= None
191 def play(self
, logfile
=None):
192 """Play the currently selected video"""
193 os
.environ
['GST_DEBUG'] = '2'
194 if logfile
is not None:
196 logfile
= logfile
.replace('$NOW', time
.strftime('%Y-%m-%d+%H:%M:%S'))
197 f
= open(logfile
, 'w')
199 cmd
= [OPO
, '-s', str(self
.screens
), '-c', self
.video
,
200 '-w', str(self
.display_width
), '-h', str(self
.display_height
)]
202 cmd
.extend(['-x', str(self
.x_screens
)])
203 if self
.force_multiscreen
:
207 log("Starting play: %r" % ' '.join(cmd
))
208 subprocess
.call(cmd
, stdout
=f
, stderr
=subprocess
.STDOUT
)
209 if logfile
is not None:
213 def on_play_now(self
, widget
, data
=None):
214 self
.play(logfile
="logs/opo-$NOW.log")
216 def on_mode_switch(self
, widget
, data
=None):
217 """Turning auto mode on or off, according to the widget's
218 state ('active' is auto). If the widget is toggled to the
219 current mode, ignore it."""
220 auto
= widget
.get_active()
221 if auto
== self
.is_auto
:
222 log("spurious auto toggle")
225 for x
in self
.advanced_widgets
:
226 x
.set_sensitive(not auto
)
228 self
.start_auto_countdown()
230 self
.stop_auto_countdown()
232 def start_auto_countdown(self
):
233 self
.countdown
= self
.timeout
234 if self
.auto_tick_id
is None: #lest, somehow, two ticks try going at once.
235 self
.auto_tick_id
= gobject
.timeout_add(1000, self
.auto_tick
)
237 def stop_auto_countdown(self
):
238 if self
.auto_tick_id
is not None:
239 gobject
.source_remove(self
.auto_tick_id
)
240 self
.auto_tick_id
= None
241 self
.mode_switch
.set_label("Play _automatically in %s seconds" % self
.timeout
)
245 if self
.countdown
> 0:
246 if self
.countdown
== 1:
247 self
.mode_switch
.set_label("Play _automatically in one second!")
249 self
.mode_switch
.set_label("Play _automatically in %s seconds" % self
.countdown
)
251 self
.auto_tick_id
= None
252 self
.play(logfile
="logs/opo-auto-$NOW.log")
253 #returning False stops countdown, which is perhaps irrelevant
254 #as self.play should never return
257 def on_chooser(self
, widget
, *data
):
258 self
.video
= widget
.get_uri()
259 self
.update_heading()
260 self
.update_choose_dir(widget
.get_current_folder())
262 def stitch_video(self
, widget
, data
=None):
263 """Launch the video joining gstreamer process, show a progress
264 bar/ spinner, and start a ticker that watches for its end."""
265 log("stitching video !")
266 output_file
= self
.stitch_target_field
.get_text()
267 input_files
= [x
.get_uri() for x
in self
.stitch_choosers
]
268 width
= int(self
.width_field
.get_text())
269 height
= int(self
.width_field
.get_text())
270 self
.stitching_process
= start_stitching_process(output_file
, input_files
,
271 width
, height
, self
.stitch_audio_source
)
272 #self.stitching_process = subprocess.Popen(['sleep', '10'])
273 self
.progress_bar
= gtk
.ProgressBar()
274 self
.progress_bar
.set_pulse_step(0.02)
275 self
.vbox
.pack_start(self
.progress_bar
)
276 self
.stitch_button
.hide()
277 self
.progress_bar
.show()
278 self
.stitch_tick_id
= gobject
.timeout_add(150, self
.stitch_tick
, ouptput_file
)
279 self
.currently_stitching
= output_file
281 def stitch_tick(self
, output_file
):
282 """Spin the progress bar and wait for the finished video"""
283 r
= self
.stitching_process
.poll()
285 self
.progress_bar
.pulse()
288 #XXX should catch and display gstreamer output
289 log("got result %s" % r
)
290 self
.progress_bar
.hide()
291 self
.stitch_button
.show()
292 self
.stitch_tick_id
= None
293 #XXX make the new video the chosen one
295 v
= self
.currently_stitching
= output_file
296 if v
[:7] == 'file://':
298 self
.chooser
.set_filename(v
)
299 self
.update_choose_dir(dirname(v
))
302 def on_stitch_chooser(self
, widget
, n
):
303 """Unify the stitch_choosers current folder, unless they have video set"""
304 d
= widget
.get_current_folder()
305 for i
in range(self
.screens
):
306 if self
.stitch_choosers
[i
].get_filename() is None:
307 self
.stitch_choosers
[i
].set_current_folder(d
)
310 def on_choose_stitch_target(self
, widget
, data
=None):
311 dialog
= gtk
.FileChooserDialog("Save as", action
=gtk
.FILE_CHOOSER_ACTION_SAVE
,
312 buttons
=(gtk
.STOCK_CANCEL
, gtk
.RESPONSE_REJECT
,
313 gtk
.STOCK_OK
, gtk
.RESPONSE_ACCEPT
)
315 dialog
.set_do_overwrite_confirmation(True)
317 dialog
.set_current_folder(self
.choose_dir
)
319 response
= dialog
.run()
320 filename
= dialog
.get_filename()
321 if response
in (gtk
.RESPONSE_ACCEPT
, gtk
.RESPONSE_OK
):
322 directory
, basename
= os
.path
.split(filename
)
323 self
.update_choose_dir(directory
)
324 self
.stitch_target_field
.set_text(dialog
.get_filename())
328 def on_stitch_audio_source(self
, widget
, n
):
329 self
.stitch_audio_source
= n
332 rc
= SafeConfigParser()
334 def _get(section
, item
, default
=None):
336 return rc
.get(section
, item
)
341 self
.unstitched_dir
= _get('Paths', 'unstitched_dir', UNSTITCHED_DIR
)
342 self
.update_choose_dir(_get('Paths', 'choose_dir', CHOOSE_DIR
))
343 self
.video
= _get('Paths', 'last_played')
344 self
.timeout
= int(_get('Misc', 'timeout', TIMEOUT
))
345 self
.auto_start
= _get('Misc', 'auto_start', '').lower() in ('true', '1', 'yes') or AUTO_START
346 self
.full_screen
= _get('Display', 'full_screen', '').lower() in ('true', '1', 'yes') or FULL_SCREEN
347 self
.screens
= int(_get('Display', 'screens', SCREENS
))
348 self
.import_width
= int(_get('Import', 'screen_width', SCREEN_WIDTH
))
349 self
.import_height
= int(_get('Import', 'screen_height', SCREEN_HEIGHT
))
350 self
.display_width
= int(_get('Display', 'screen_width', SCREEN_WIDTH
))
351 self
.display_height
= int(_get('Display', 'screen_height', SCREEN_HEIGHT
))
352 self
.x_screens
= int(_get('Display', 'x_screens', X_SCREENS
))
353 self
.force_multiscreen
= _get('Display', 'force_multiscreen', '').lower() in ('true', '1', 'yes')
356 rc
= SafeConfigParser()
358 for section
, key
, value
in (
359 ('Paths', 'unstitched_dir', self
.unstitched_dir
),
360 ('Paths', 'choose_dir', self
.choose_dir
),
361 ('Paths', 'last_played', self
.video
),
363 if value
is not None:
364 if not rc
.has_section(section
):
365 rc
.add_section(section
)
366 rc
.set(section
, key
, value
)
368 with
open(RC_FILE
, 'wb') as configfile
:
371 def update_heading(self
):
373 video_name
= self
.video
.rsplit('/', 1)[1]
374 self
.heading
.set_markup('<big><b>%s</b> is ready to play</big>' %
376 self
.play_now
.set_sensitive(True)
378 log("Couldn't set heading", e
)
379 self
.heading
.set_markup('<big>No video selected</big>')
380 self
.play_now
.set_sensitive(False)
382 def update_choose_dir(self
, choosedir
):
383 self
.choose_dir
= choosedir
385 self
.chooser
.set_current_folder(choosedir
)
387 def make_window(self
):
388 self
.window
= gtk
.Window(gtk
.WINDOW_TOPLEVEL
)
389 self
.window
.set_border_width(15)
390 self
.vbox
= gtk
.VBox(False, 3)
391 self
.advanced_widgets
= []
393 _add
= self
.vbox
.pack_start
394 def _add_advanced(widget
):
395 self
.vbox
.pack_start(widget
)
396 self
.advanced_widgets
.append(widget
)
399 # add a separator with slightly more space than usual
400 _add(gtk
.HSeparator(), True, True, 5)
404 h
.set_line_wrap(True)
408 self
.play_now
= gtk
.Button("_Play now")
409 self
.play_now
.connect("clicked", self
.on_play_now
, None)
414 self
.mode_switch
= gtk
.CheckButton("Play _automatically in %s seconds" % self
.timeout
)
415 self
.mode_switch
.connect("toggled", self
.on_mode_switch
, None)
416 _add(self
.mode_switch
)
421 chooser_lab
= gtk
.Label("Ch_oose another combined video (%s screens)" % self
.screens
)
422 chooser_lab
.set_use_underline(True)
423 chooser_lab
.set_alignment(0, 0.5)
424 self
.chooser
= gtk
.FileChooserButton(title
="video")
426 self
.chooser
.set_current_folder(self
.choose_dir
)
427 self
.chooser
.set_width_chars(40)
428 self
.chooser
.connect('file-set', self
.on_chooser
, None)
430 chooser_lab
.set_mnemonic_widget(self
.chooser
)
432 _add_advanced(chooser_lab
)
433 _add_advanced(self
.chooser
)
436 #create another by stitching subvideos
437 nb
= gtk
.Label("Construct a _new combined video out of %s video files" % self
.screens
)
438 nb
.set_use_underline(True)
440 nb
.set_alignment(0, 0.5)
443 sound
= gtk
.Label("Sound:")
444 sound
.set_alignment(0.99, 0.5)
448 self
.stitch_silent
= gtk
.RadioButton(None, "silent")
449 self
.stitch_silent
.connect("toggled", self
.on_stitch_audio_source
, None)
450 #hb.pack_start(self.stitch_silent, False)
453 self
.stitch_choosers
= []
454 self
.stitch_audio_source
= None
455 for i
in range(self
.screens
):
456 fc
= gtk
.FileChooserButton(title
="video %s" % i
)
457 fcl
= gtk
.Label("Screen _%s" % (i
+ 1))
458 fcl
.set_use_underline(True)
459 fcl
.set_mnemonic_widget(fc
)
460 fc_sound
= gtk
.RadioButton(self
.stitch_silent
, "audio %s" % (i
+ 1,))
461 fc_sound
.set_active(False)
462 fc_sound
.set_tooltip_text("use the sound from video %s" % (i
+ 1,))
463 fc_sound
.connect("toggled", self
.on_stitch_audio_source
, i
)
465 fc_set
.pack_start(fcl
, False)
466 fc_set
.pack_start(fc
)
467 fc_set
.pack_start(fc_sound
, False)
468 self
.stitch_choosers
.append(fc
)
469 fc
.connect('file-set', self
.on_stitch_chooser
, i
)
470 _add_advanced(fc_set
)
473 #self.stitch_silent = gtk.RadioButton(None, "no sound")
474 hb
.pack_end(self
.stitch_silent
, False)
480 self
.stitch_target_field
= gtk
.Entry()
481 self
.stitch_target_field
.set_width_chars(40)
482 self
.stitch_target_field
.set_text(name_suggester(self
.choose_dir
, 'new', 'avi'))
484 self
.choose_stitch_target
= gtk
.Button(label
="choose")
485 self
.choose_stitch_target
.connect("clicked", self
.on_choose_stitch_target
, None)
488 name_label
= gtk
.Label("Save as")
489 hb
.pack_start(name_label
, False)
490 hb
.pack_start(self
.stitch_target_field
)
491 hb
.pack_start(self
.choose_stitch_target
)
492 nb
.set_mnemonic_widget(self
.stitch_choosers
[0])
497 label
= gtk
.Label("Pixel size of each screen: ")
498 hb
.pack_start(label
, False)
499 for name
, default
, label
in (
500 ('width_field', self
.import_width
, "_width"),
501 ('height_field', self
.import_height
, "_height"),
506 e
.set_text(str(default
))
507 setattr(self
, name
, e
)
508 lb
= gtk
.Label(label
)
509 lb
.set_use_underline(True)
510 lb
.set_mnemonic_widget(e
)
511 hb
.pack_start(lb
, False)
512 hb
.pack_start(e
, False)
516 self
.stitch_button
= gtk
.Button("Assemble the new _video")
517 self
.stitch_button
.connect("clicked", self
.stitch_video
, None)
518 _add_advanced(self
.stitch_button
)
520 self
.window
.add(self
.vbox
)
522 def place_window(self
):
523 self
.window
.move(300, 50)
529 self
.update_heading()
530 self
.mode_switch
.set_active(self
.auto_start
and self
.video
is not None)
531 self
.window
.connect("destroy", self
.destroy
)
532 self
.window
.show_all()
535 def destroy(self
, widget
, data
=None):
540 quit_onclick
= destroy
542 if __name__
== '__main__':