plugins: videos: photos: add videos plugin
This change adds a new plugin that mirrors photos functionality, but for
videos (mp4 and others).
NOTE: this plugin requires ffmepg installed on the host machine
$ sudo apt-get install -y ffmpeg
diff --git a/Makefile b/Makefile
index ead5917..daa6b60 100644
--- a/Makefile
+++ b/Makefile
@@ -7,6 +7,7 @@
OUTPUTDIR=$(BASEDIR)/output
OUTPUTDIR_THEME=$(OUTPUTDIR)/theme
OUTPUTDIR_PHOTOS=$(OUTPUTDIR)/photos
+OUTPUTDIR_VIDEOS=$(OUTPUTDIR)/videos
CONFFILE=$(BASEDIR)/pelicanconf.py
PUBLISHCONF=$(BASEDIR)/publishconf.py
@@ -35,8 +36,11 @@
endif
INSTALLFLAGS := $(INSTALLFLAGS) -rXaA
INSTALLDIR ?=
+# Copy the whole directory theme
INSTALLDIR_THEME ?=
+# Copy contents only
INSTALLDIR_PHOTOS ?=
+INSTALLDIR_VIDEOS ?=
DEBUG ?= 0
ifeq ($(DEBUG), 1)
@@ -56,6 +60,8 @@
@echo ' make clean-html remove all html files and folders '
@echo ' make clean-theme remove output/theme '
@echo ' make clean-photos remove output/photos '
+ @echo ' make clean-videos remove output/videos '
+ @echo ' make distclean remove output and __pycache__ '
@echo ' make clean remove output folder '
@echo ' make regenerate regenerate files upon modification '
@echo ' make publish generate using production settings '
@@ -73,6 +79,7 @@
@echo ' make install-html copy *.html, html folders locally '
@echo ' make install-theme copy output/theme locally '
@echo ' make install-photos copy output/photos locally '
+ @echo ' make install-videos copy output/videos locally '
@echo ' make install copy output/* locally '
@echo ' '
@echo 'Set the DEBUG variable to 1 to enable debugging, e.g. make DEBUG=1 html '
@@ -92,9 +99,15 @@
clean-photos:
[ ! -d "$(OUTPUTDIR_PHOTOS)" ] || rm -rf $(OUTPUTDIR_PHOTOS)
+clean-videos:
+ [ ! -d "$(OUTPUTDIR_VIDEOS)" ] || rm -rf $(OUTPUTDIR_VIDEOS)
+
clean:
[ ! -d "$(OUTPUTDIR)" ] || rm -rf $(OUTPUTDIR)
+distclean: clean
+ find $(BASEDIR) -type d -name "__pycache__" -exec rm -rf '{}' '+'
+
regenerate:
$(PELICAN) -r $(INPUTDIR) -o $(OUTPUTDIR) -s $(CONFFILE) $(PELICANOPTS)
@@ -151,18 +164,27 @@
install-html:
[ -d "$(OUTPUTDIR)" ] && [ -d "$(INSTALLDIR)" ] && \
- $(SUDO) rsync $(INSTALLFLAGS) --exclude=$(notdir $(OUTPUTDIR_THEME)) \
- --exclude=$(notdir $(OUTPUTDIR_PHOTOS)) $(OUTPUTDIR)/* $(INSTALLDIR)
+ $(SUDO) rsync $(INSTALLFLAGS) \
+ --exclude=$(notdir $(OUTPUTDIR_THEME)) \
+ --exclude=$(notdir $(OUTPUTDIR_PHOTOS)) \
+ --exclude=$(notdir $(OUTPUTDIR_VIDEOS)) \
+ $(OUTPUTDIR)/* $(INSTALLDIR)
install-theme:
[ -d "$(OUTPUTDIR_THEME)" ] && [ -d "$(INSTALLDIR_THEME)" ] && \
$(SUDO) rsync $(INSTALLFLAGS) $(OUTPUTDIR_THEME) $(INSTALLDIR_THEME)/
install-photos:
- [ -d "$(OUTPUTDIR_PHOTOS)" ] && [ -d "$(INSTALLDIR_PHOTOS)" ] && \
- $(SUDO) rsync $(INSTALLFLAGS) $(OUTPUTDIR_PHOTOS) $(INSTALLDIR_PHOTOS)/
+ [ ! -d "$(OUTPUTDIR_PHOTOS)" ] && { echo "Nothing to do for: install-photos"; \
+ exit 0; } || [ -d "$(INSTALLDIR_PHOTOS)" ] && \
+ $(SUDO) rsync $(INSTALLFLAGS) $(OUTPUTDIR_PHOTOS)/ $(INSTALLDIR_PHOTOS)
-install: install-html install-theme install-photos
+install-videos:
+ [ ! -d "$(OUTPUTDIR_VIDEOS)" ] && { echo "Nothing to do for: install-videos"; \
+ exit 0; } || [ -d "$(INSTALLDIR_VIDEOS)" ] && \
+ $(SUDO) rsync $(INSTALLFLAGS) $(OUTPUTDIR_VIDEOS)/ $(INSTALLDIR_VIDEOS)
+
+install: install-html install-theme install-photos install-videos
.PHONY: html help clean clean-html clean-theme clean-photos regenerate serve serve-global devserver stopserver publish ssh_upload rsync_upload dropbox_upload ftp_upload s3_upload cf_upload github install install-html install-theme install-photos
diff --git a/pelicanconf.py b/pelicanconf.py
index f7e0f58..d100903 100644
--- a/pelicanconf.py
+++ b/pelicanconf.py
@@ -75,6 +75,7 @@
PLUGINS = [
'assets',
'photos',
+ 'videos',
]
# Derive categories from the folder name
@@ -207,7 +208,7 @@
# {url} placeholder in PAGINATION_PATTERNS.
DAY_ARCHIVE_URL = ''
-# Gallery plugin
+# Photos plugin
PHOTO_LIBRARY = os.getenv('PELICAN_PHOTO_LIBRARY')
PHOTO_EXCLUDE = os.getenv('PELICAN_PHOTO_EXCLUDE')
PHOTO_EXCLUDEALL = os.getenv('PELICAN_PHOTO_EXCLUDEALL')
@@ -215,3 +216,10 @@
PHOTO_ARTICLE = (2000, 1333, 100)
PHOTO_THUMB = (300, 200, 100)
PHOTO_SQUARE_THUMB = False
+
+# Videos plugin
+VIDEO_LIBRARY = os.getenv('PELICAN_VIDEO_LIBRARY')
+VIDEO_EXCLUDE = os.getenv('PELICAN_VIDEO_EXCLUDE')
+VIDEO_EXCLUDEALL = os.getenv('PELICAN_VIDEO_EXCLUDEALL')
+VIDEO_GALLERY = (720, 400, 100)
+VIDEO_ARTICLE = (720, 400, 100)
diff --git a/plugins/photos/photos.py b/plugins/photos/photos.py
index 551bdf6..2c6966a 100644
--- a/plugins/photos/photos.py
+++ b/plugins/photos/photos.py
@@ -123,7 +123,9 @@
if resized not in DEFAULT_CONFIG['queue_resize']:
DEFAULT_CONFIG['queue_resize'][resized] = (orig, spec)
elif DEFAULT_CONFIG['queue_resize'][resized] != (orig, spec):
- logger.error('photos: resize conflict for {}, {}-{} is not {}-{}'.format(resized, DEFAULT_CONFIG['queue_resize'][resized][0], DEFAULT_CONFIG['queue_resize'][resized][1], orig, spec))
+ error_msg = 'photos: resize conflict for {}, {}-{} is not {}-{}'
+ logger.error(error_msg.format(resized, DEFAULT_CONFIG['queue_resize'][resized][0],
+ DEFAULT_CONFIG['queue_resize'][resized][1], orig, spec))
def isalpha(img):
@@ -314,7 +316,7 @@
basename = os.path.basename(abs_path)
if basename in generator.settings['PHOTO_EXCLUDE']:
- logger.warning('photos: Skip gallery: {}'.format(basename))
+ logger.warning('photos: skip gallery: {}'.format(basename))
continue
orig, spec = what
@@ -523,15 +525,15 @@
generator.settings['PHOTO_THUMB'])
content.photo_gallery.append((title, content_gallery))
- logger.debug('Gallery Data: '.format(pprint.pformat(content.photo_gallery)))
+ logger.debug('Gallery Data: {}'.format(pprint.pformat(content.photo_gallery)))
DEFAULT_CONFIG['created_galleries']['gallery'] = content_gallery
else:
logger.error('photos: Gallery does not exist: {} at {}'.format(gallery['location'], dir_gallery))
def detect_gallery(generator, content):
- if 'gallery' in content.metadata:
- gallery = content.metadata.get('gallery')
+ if 'photo-gallery' in content.metadata:
+ gallery = content.metadata.get('photo-gallery')
if gallery.startswith('{photo}') or gallery.startswith('{filename}'):
process_gallery(generator, content, gallery)
elif gallery:
diff --git a/plugins/videos/__init__.py b/plugins/videos/__init__.py
new file mode 100644
index 0000000..08503e9
--- /dev/null
+++ b/plugins/videos/__init__.py
@@ -0,0 +1 @@
+from .videos import *
diff --git a/plugins/videos/api.txt b/plugins/videos/api.txt
new file mode 100644
index 0000000..8db8ae3
--- /dev/null
+++ b/plugins/videos/api.txt
@@ -0,0 +1,210 @@
+## Container formats
+--------------------
+
+class converter.formats.AviFormat
+
+ Avi container format, often used vith DivX video.
+
+class converter.formats.BaseFormat
+
+ Base format class.
+
+ Supported formats are: ogg, avi, mkv, webm, flv, mov, mp4, mpeg
+
+class converter.formats.FlvFormat
+
+ Flash Video container format.
+
+class converter.formats.MkvFormat
+
+ Matroska format, often used with H.264 video.
+
+class converter.formats.MovFormat
+
+ Mov container format, used mostly with H.264 video content, often for mobile platforms.
+
+class converter.formats.Mp3Format
+
+ Mp3 container, used audio-only mp3 files
+
+class converter.formats.Mp4Format
+
+ Mp4 container format, the default Format for H.264 video content.
+
+class converter.formats.MpegFormat
+
+ MPEG(TS) container, used mainly for MPEG 1/2 video codecs.
+
+class converter.formats.OggFormat
+
+ Ogg container format, mostly used with Vorbis and Theora.
+
+class converter.formats.WebmFormat
+
+ WebM is Google’s variant of Matroska containing only VP8 for video and Vorbis for audio content.
+
+
+## Audio and video codecs
+-------------------------
+
+class converter.avcodecs.AacCodec
+
+ AAC audio codec.
+
+class converter.avcodecs.Ac3Codec
+
+ AC3 audio codec.
+
+class converter.avcodecs.AudioCodec
+
+ Base audio codec class handles general audio options. Possible parameters are:
+
+ codec (string) - audio codec name
+ channels (integer) - number of audio channels
+ bitrate (integer) - stream bitrate
+ samplerate (integer) - sample rate (frequency)
+
+ Supported audio codecs are: null (no audio), copy (copy from original), vorbis, aac, mp3, mp2
+
+class converter.avcodecs.AudioCopyCodec
+
+ Copy audio stream directly from the source.
+
+class converter.avcodecs.AudioNullCodec
+
+ Null audio codec (no audio).
+
+class converter.avcodecs.BaseCodec
+
+ Base audio/video codec class.
+
+class converter.avcodecs.DVBSub
+
+ DVB subtitles.
+
+class converter.avcodecs.DVDSub
+
+ DVD subtitles.
+
+class converter.avcodecs.DivxCodec
+
+ DivX video codec.
+
+class converter.avcodecs.DtsCodec
+
+ DTS audio codec.
+
+class converter.avcodecs.FdkAacCodec
+
+ AAC audio codec.
+
+class converter.avcodecs.FlacCodec
+
+ FLAC audio codec.
+
+class converter.avcodecs.FlvCodec
+
+ Flash Video codec.
+
+class converter.avcodecs.H263Codec
+
+ H.263 video codec.
+
+class converter.avcodecs.H264Codec
+
+ H.264/AVC video codec. @see http://ffmpeg.org/trac/ffmpeg/wiki/x264EncodingGuide
+
+class converter.avcodecs.MOVTextCodec
+
+ mov_text subtitle codec.
+
+class converter.avcodecs.Mp2Codec
+
+ MP2 (MPEG layer 2) audio codec.
+
+class converter.avcodecs.Mp3Codec
+
+ MP3 (MPEG layer 3) audio codec.
+
+class converter.avcodecs.Mpeg1Codec
+
+ MPEG-1 video codec.
+
+class converter.avcodecs.Mpeg2Codec
+
+ MPEG-2 video codec.
+
+class converter.avcodecs.MpegCodec
+
+ Base MPEG video codec.
+
+class converter.avcodecs.SSA
+
+ SSA (SubStation Alpha) subtitle.
+
+class converter.avcodecs.SubRip
+
+ SubRip subtitle.
+
+class converter.avcodecs.SubtitleCodec
+
+ Base subtitle codec class handles general subtitle options. Possible parameters are:
+
+ codec (string) - subtitle codec name (mov_text, subrib, ssa only supported currently)
+ language (string) - language of subtitle stream (3 char code)
+ forced (int) - force subtitles (1 true, 0 false)
+ default (int) - default subtitles (1 true, 0 false)
+
+ Supported subtitle codecs are: null (no subtitle), mov_text
+
+class converter.avcodecs.SubtitleCopyCodec
+
+ Copy subtitle stream directly from the source.
+
+class converter.avcodecs.SubtitleNullCodec
+
+ Null video codec (no video).
+
+class converter.avcodecs.TheoraCodec
+
+ Theora video codec. @see http://ffmpeg.org/trac/ffmpeg/wiki/TheoraVorbisEncodingGuide
+
+class converter.avcodecs.VideoCodec
+
+ Base video codec class handles general video options. Possible parameters are:
+
+ codec (string) - video codec name
+ bitrate (string) - stream bitrate
+ fps (integer) - frames per second
+ width (integer) - video width
+ height (integer) - video height
+
+ mode (string) - aspect preserval mode; one of:
+ stretch (default) - don’t preserve aspect
+ crop - crop extra w/h
+ pad - pad with black bars
+
+ src_width (int) - source width
+ src_height (int) - source height
+
+ Aspect preserval mode is only used if both source and both destination sizes are specified. If source dimensions are not specified, aspect settings are ignored.
+
+ If source dimensions are specified, and only one of the destination dimensions is specified, the other one is calculated to preserve the aspect ratio.
+
+ Supported video codecs are: null (no video), copy (copy directly from the source), Theora, H.264/AVC, DivX, VP8, H.263, Flv, MPEG-1, MPEG-2.
+
+class converter.avcodecs.VideoCopyCodec
+
+ Copy video stream directly from the source.
+
+class converter.avcodecs.VideoNullCodec
+
+ Null video codec (no video).
+
+class converter.avcodecs.VorbisCodec
+
+ Vorbis audio codec. @see http://ffmpeg.org/trac/ffmpeg/wiki/TheoraVorbisEncodingGuide
+
+class converter.avcodecs.Vp8Codec
+
+ Google VP8 video codec.
diff --git a/plugins/videos/avcodecs.py b/plugins/videos/avcodecs.py
new file mode 100644
index 0000000..1c853f9
--- /dev/null
+++ b/plugins/videos/avcodecs.py
@@ -0,0 +1,637 @@
+#!/usr/bin/env python
+
+
+class BaseCodec(object):
+ """
+ Base audio/video codec class.
+ """
+
+ encoder_options = {}
+ codec_name = None
+ ffmpeg_codec_name = None
+
+ def parse_options(self, opt):
+ if 'codec' not in opt or opt['codec'] != self.codec_name:
+ raise ValueError('invalid codec name')
+ return None
+
+ def _codec_specific_parse_options(self, safe):
+ return safe
+
+ def _codec_specific_produce_ffmpeg_list(self, safe):
+ return []
+
+ def safe_options(self, opts):
+ safe = {}
+
+ # Only copy options that are expected and of correct type
+ # (and do typecasting on them)
+ for k, v in opts.items():
+ if k in self.encoder_options:
+ typ = self.encoder_options[k]
+ try:
+ safe[k] = typ(v)
+ except:
+ pass
+
+ return safe
+
+
+class AudioCodec(BaseCodec):
+ """
+ Base audio codec class handles general audio options. Possible
+ parameters are:
+ * codec (string) - audio codec name
+ * channels (integer) - number of audio channels
+ * bitrate (integer) - stream bitrate
+ * samplerate (integer) - sample rate (frequency)
+
+ Supported audio codecs are: null (no audio), copy (copy from
+ original), vorbis, aac, mp3, mp2
+ """
+
+ encoder_options = {
+ 'codec': str,
+ 'channels': int,
+ 'bitrate': int,
+ 'samplerate': int
+ }
+
+ def parse_options(self, opt):
+ super(AudioCodec, self).parse_options(opt)
+
+ safe = self.safe_options(opt)
+
+ if 'channels' in safe:
+ c = safe['channels']
+ if c < 1 or c > 12:
+ del safe['channels']
+
+ if 'bitrate' in safe:
+ br = safe['bitrate']
+ if br < 8 or br > 512:
+ del safe['bitrate']
+
+ if 'samplerate' in safe:
+ f = safe['samplerate']
+ if f < 1000 or f > 50000:
+ del safe['samplerate']
+
+ safe = self._codec_specific_parse_options(safe)
+
+ optlist = ['-acodec', self.ffmpeg_codec_name]
+ if 'channels' in safe:
+ optlist.extend(['-ac', str(safe['channels'])])
+ if 'bitrate' in safe:
+ optlist.extend(['-ab', str(safe['bitrate']) + 'k'])
+ if 'samplerate' in safe:
+ optlist.extend(['-ar', str(safe['samplerate'])])
+
+ optlist.extend(self._codec_specific_produce_ffmpeg_list(safe))
+ return optlist
+
+
+class SubtitleCodec(BaseCodec):
+ """
+ Base subtitle codec class handles general subtitle options. Possible
+ parameters are:
+ * codec (string) - subtitle codec name (mov_text, subrib, ssa only supported currently)
+ * language (string) - language of subtitle stream (3 char code)
+ * forced (int) - force subtitles (1 true, 0 false)
+ * default (int) - default subtitles (1 true, 0 false)
+
+ Supported subtitle codecs are: null (no subtitle), mov_text
+ """
+
+ encoder_options = {
+ 'codec': str,
+ 'language': str,
+ 'forced': int,
+ 'default': int
+ }
+
+ def parse_options(self, opt):
+ super(SubtitleCodec, self).parse_options(opt)
+ safe = self.safe_options(opt)
+
+ if 'forced' in safe:
+ f = safe['forced']
+ if f < 0 or f > 1:
+ del safe['forced']
+
+ if 'default' in safe:
+ d = safe['default']
+ if d < 0 or d > 1:
+ del safe['default']
+
+ if 'language' in safe:
+ l = safe['language']
+ if len(l) > 3:
+ del safe['language']
+
+ safe = self._codec_specific_parse_options(safe)
+
+ optlist = ['-scodec', self.ffmpeg_codec_name]
+
+ optlist.extend(self._codec_specific_produce_ffmpeg_list(safe))
+ return optlist
+
+
+class VideoCodec(BaseCodec):
+ """
+ Base video codec class handles general video options. Possible
+ parameters are:
+ * codec (string) - video codec name
+ * bitrate (string) - stream bitrate
+ * fps (integer) - frames per second
+ * width (integer) - video width
+ * height (integer) - video height
+ * mode (string) - aspect preserval mode; one of:
+ * stretch (default) - don't preserve aspect
+ * crop - crop extra w/h
+ * pad - pad with black bars
+ * src_width (int) - source width
+ * src_height (int) - source height
+
+ Aspect preserval mode is only used if both source
+ and both destination sizes are specified. If source
+ dimensions are not specified, aspect settings are ignored.
+
+ If source dimensions are specified, and only one
+ of the destination dimensions is specified, the other one
+ is calculated to preserve the aspect ratio.
+
+ Supported video codecs are: null (no video), copy (copy directly
+ from the source), Theora, H.264/AVC, DivX, VP8, H.263, Flv,
+ MPEG-1, MPEG-2.
+ """
+
+ encoder_options = {
+ 'codec': str,
+ 'bitrate': int,
+ 'fps': int,
+ 'width': int,
+ 'height': int,
+ 'mode': str,
+ 'src_width': int,
+ 'src_height': int,
+ }
+
+ def _aspect_corrections(self, sw, sh, w, h, mode):
+ # If we don't have source info, we don't try to calculate
+ # aspect corrections
+ if not sw or not sh:
+ return w, h, None
+
+ # Original aspect ratio
+ aspect = (1.0 * sw) / (1.0 * sh)
+
+ # If we have only one dimension, we can easily calculate
+ # the other to match the source aspect ratio
+ if not w and not h:
+ return w, h, None
+ elif w and not h:
+ h = int((1.0 * w) / aspect)
+ return w, h, None
+ elif h and not w:
+ w = int(aspect * h)
+ return w, h, None
+
+ # If source and target dimensions are actually the same aspect
+ # ratio, we've got nothing to do
+ if int(aspect * h) == w:
+ return w, h, None
+
+ if mode == 'stretch':
+ return w, h, None
+
+ target_aspect = (1.0 * w) / (1.0 * h)
+
+ if mode == 'crop':
+ # source is taller, need to crop top/bottom
+ if target_aspect > aspect: # target is taller
+ h0 = int(w / aspect)
+ assert h0 > h, (sw, sh, w, h)
+ dh = (h0 - h) / 2
+ return w, h0, 'crop=%d:%d:0:%d' % (w, h, dh)
+ else: # source is wider, need to crop left/right
+ w0 = int(h * aspect)
+ assert w0 > w, (sw, sh, w, h)
+ dw = (w0 - w) / 2
+ return w0, h, 'crop=%d:%d:%d:0' % (w, h, dw)
+
+ if mode == 'pad':
+ # target is taller, need to pad top/bottom
+ if target_aspect < aspect:
+ h1 = int(w / aspect)
+ assert h1 < h, (sw, sh, w, h)
+ dh = (h - h1) / 2
+ return w, h1, 'pad=%d:%d:0:%d' % (w, h, dh) # FIXED
+ else: # target is wider, need to pad left/right
+ w1 = int(h * aspect)
+ assert w1 < w, (sw, sh, w, h)
+ dw = (w - w1) / 2
+ return w1, h, 'pad=%d:%d:%d:0' % (w, h, dw) # FIXED
+
+ assert False, mode
+
+ def parse_options(self, opt):
+ super(VideoCodec, self).parse_options(opt)
+
+ safe = self.safe_options(opt)
+
+ if 'fps' in safe:
+ f = safe['fps']
+ if f < 1 or f > 120:
+ del safe['fps']
+
+ if 'bitrate' in safe:
+ br = safe['bitrate']
+ if br < 16 or br > 15000:
+ del safe['bitrate']
+
+ w = None
+ h = None
+
+ if 'width' in safe:
+ w = safe['width']
+ if w < 16 or w > 4000:
+ w = None
+
+ if 'height' in safe:
+ h = safe['height']
+ if h < 16 or h > 3000:
+ h = None
+
+ sw = None
+ sh = None
+
+ if 'src_width' in safe and 'src_height' in safe:
+ sw = safe['src_width']
+ sh = safe['src_height']
+ if not sw or not sh:
+ sw = None
+ sh = None
+
+ mode = 'stretch'
+ if 'mode' in safe:
+ if safe['mode'] in ['stretch', 'crop', 'pad']:
+ mode = safe['mode']
+
+ ow, oh = w, h # FIXED
+ w, h, filters = self._aspect_corrections(sw, sh, w, h, mode)
+
+ safe['width'] = w
+ safe['height'] = h
+ safe['aspect_filters'] = filters
+
+ if w and h:
+ safe['aspect'] = '%d:%d' % (w, h)
+
+ safe = self._codec_specific_parse_options(safe)
+
+ w = safe['width']
+ h = safe['height']
+ filters = safe['aspect_filters']
+
+ optlist = ['-vcodec', self.ffmpeg_codec_name]
+ if 'fps' in safe:
+ optlist.extend(['-r', str(safe['fps'])])
+ if 'bitrate' in safe:
+ optlist.extend(['-vb', str(safe['bitrate']) + 'k']) # FIXED
+ if w and h:
+ optlist.extend(['-s', '%dx%d' % (w, h)])
+
+ if ow and oh:
+ optlist.extend(['-aspect', '%d:%d' % (ow, oh)])
+
+ if filters:
+ optlist.extend(['-vf', filters])
+
+ optlist.extend(self._codec_specific_produce_ffmpeg_list(safe))
+ return optlist
+
+
+class AudioNullCodec(BaseCodec):
+ """
+ Null audio codec (no audio).
+ """
+ codec_name = None
+
+ def parse_options(self, opt):
+ return ['-an']
+
+
+class VideoNullCodec(BaseCodec):
+ """
+ Null video codec (no video).
+ """
+
+ codec_name = None
+
+ def parse_options(self, opt):
+ return ['-vn']
+
+
+class SubtitleNullCodec(BaseCodec):
+ """
+ Null video codec (no video).
+ """
+
+ codec_name = None
+
+ def parse_options(self, opt):
+ return ['-sn']
+
+
+class AudioCopyCodec(BaseCodec):
+ """
+ Copy audio stream directly from the source.
+ """
+ codec_name = 'copy'
+
+ def parse_options(self, opt):
+ return ['-acodec', 'copy']
+
+
+class VideoCopyCodec(BaseCodec):
+ """
+ Copy video stream directly from the source.
+ """
+ codec_name = 'copy'
+
+ def parse_options(self, opt):
+ return ['-vcodec', 'copy']
+
+
+class SubtitleCopyCodec(BaseCodec):
+ """
+ Copy subtitle stream directly from the source.
+ """
+ codec_name = 'copy'
+
+ def parse_options(self, opt):
+ return ['-scodec', 'copy']
+
+# Audio Codecs
+class VorbisCodec(AudioCodec):
+ """
+ Vorbis audio codec.
+ @see http://ffmpeg.org/trac/ffmpeg/wiki/TheoraVorbisEncodingGuide
+ """
+ codec_name = 'vorbis'
+ ffmpeg_codec_name = 'libvorbis'
+ encoder_options = AudioCodec.encoder_options.copy()
+ encoder_options.update({
+ 'quality': int, # audio quality. Range is 0-10(highest quality)
+ # 3-6 is a good range to try. Default is 3
+ })
+
+ def _codec_specific_produce_ffmpeg_list(self, safe):
+ optlist = []
+ if 'quality' in safe:
+ optlist.extend(['-qscale:a', safe['quality']])
+ return optlist
+
+
+class AacCodec(AudioCodec):
+ """
+ AAC audio codec.
+ """
+ codec_name = 'aac'
+ ffmpeg_codec_name = 'aac'
+ aac_experimental_enable = ['-strict', 'experimental']
+
+ def _codec_specific_produce_ffmpeg_list(self, safe):
+ return self.aac_experimental_enable
+
+
+class FdkAacCodec(AudioCodec):
+ """
+ AAC audio codec.
+ """
+ codec_name = 'libfdk_aac'
+ ffmpeg_codec_name = 'libfdk_aac'
+
+
+class Ac3Codec(AudioCodec):
+ """
+ AC3 audio codec.
+ """
+ codec_name = 'ac3'
+ ffmpeg_codec_name = 'ac3'
+
+
+class FlacCodec(AudioCodec):
+ """
+ FLAC audio codec.
+ """
+ codec_name = 'flac'
+ ffmpeg_codec_name = 'flac'
+
+
+class DtsCodec(AudioCodec):
+ """
+ DTS audio codec.
+ """
+ codec_name = 'dts'
+ ffmpeg_codec_name = 'dts'
+
+
+class Mp3Codec(AudioCodec):
+ """
+ MP3 (MPEG layer 3) audio codec.
+ """
+ codec_name = 'mp3'
+ ffmpeg_codec_name = 'libmp3lame'
+
+
+class Mp2Codec(AudioCodec):
+ """
+ MP2 (MPEG layer 2) audio codec.
+ """
+ codec_name = 'mp2'
+ ffmpeg_codec_name = 'mp2'
+
+
+# Video Codecs
+class TheoraCodec(VideoCodec):
+ """
+ Theora video codec.
+ @see http://ffmpeg.org/trac/ffmpeg/wiki/TheoraVorbisEncodingGuide
+ """
+ codec_name = 'theora'
+ ffmpeg_codec_name = 'libtheora'
+ encoder_options = VideoCodec.encoder_options.copy()
+ encoder_options.update({
+ 'quality': int, # audio quality. Range is 0-10(highest quality)
+ # 5-7 is a good range to try (default is 200k bitrate)
+ })
+
+ def _codec_specific_produce_ffmpeg_list(self, safe):
+ optlist = []
+ if 'quality' in safe:
+ optlist.extend(['-qscale:v', safe['quality']])
+ return optlist
+
+
+class H264Codec(VideoCodec):
+ """
+ H.264/AVC video codec.
+ @see http://ffmpeg.org/trac/ffmpeg/wiki/x264EncodingGuide
+ """
+ codec_name = 'h264'
+ ffmpeg_codec_name = 'libx264'
+ encoder_options = VideoCodec.encoder_options.copy()
+ encoder_options.update({
+ 'preset': str, # common presets are ultrafast, superfast, veryfast,
+ # faster, fast, medium(default), slow, slower, veryslow
+ 'quality': int, # constant rate factor, range:0(lossless)-51(worst)
+ # default:23, recommended: 18-28
+ # http://mewiki.project357.com/wiki/X264_Settings#profile
+ 'profile': str, # default: not-set, for valid values see above link
+ 'tune': str, # default: not-set, for valid values see above link
+ })
+
+ def _codec_specific_produce_ffmpeg_list(self, safe):
+ optlist = []
+ if 'preset' in safe:
+ optlist.extend(['-preset', safe['preset']])
+ if 'quality' in safe:
+ optlist.extend(['-crf', safe['quality']])
+ if 'profile' in safe:
+ optlist.extend(['-profile', safe['profile']])
+ if 'tune' in safe:
+ optlist.extend(['-tune', safe['tune']])
+ return optlist
+
+
+class DivxCodec(VideoCodec):
+ """
+ DivX video codec.
+ """
+ codec_name = 'divx'
+ ffmpeg_codec_name = 'mpeg4'
+
+
+class Vp8Codec(VideoCodec):
+ """
+ Google VP8 video codec.
+ """
+ codec_name = 'vp8'
+ ffmpeg_codec_name = 'libvpx'
+
+
+class H263Codec(VideoCodec):
+ """
+ H.263 video codec.
+ """
+ codec_name = 'h263'
+ ffmpeg_codec_name = 'h263'
+
+
+class FlvCodec(VideoCodec):
+ """
+ Flash Video codec.
+ """
+ codec_name = 'flv'
+ ffmpeg_codec_name = 'flv'
+
+
+class MpegCodec(VideoCodec):
+ """
+ Base MPEG video codec.
+ """
+ # Workaround for a bug in ffmpeg in which aspect ratio
+ # is not correctly preserved, so we have to set it
+ # again in vf; take care to put it *before* crop/pad, so
+ # it uses the same adjusted dimensions as the codec itself
+ # (pad/crop will adjust it further if neccessary)
+ def _codec_specific_parse_options(self, safe):
+ w = safe['width']
+ h = safe['height']
+
+ if w and h:
+ filters = safe['aspect_filters']
+ tmp = 'aspect=%d:%d' % (w, h)
+
+ if filters is None:
+ safe['aspect_filters'] = tmp
+ else:
+ safe['aspect_filters'] = tmp + ',' + filters
+
+ return safe
+
+
+class Mpeg1Codec(MpegCodec):
+ """
+ MPEG-1 video codec.
+ """
+ codec_name = 'mpeg1'
+ ffmpeg_codec_name = 'mpeg1video'
+
+
+class Mpeg2Codec(MpegCodec):
+ """
+ MPEG-2 video codec.
+ """
+ codec_name = 'mpeg2'
+ ffmpeg_codec_name = 'mpeg2video'
+
+
+# Subtitle Codecs
+class MOVTextCodec(SubtitleCodec):
+ """
+ mov_text subtitle codec.
+ """
+ codec_name = 'mov_text'
+ ffmpeg_codec_name = 'mov_text'
+
+
+class SSA(SubtitleCodec):
+ """
+ SSA (SubStation Alpha) subtitle.
+ """
+ codec_name = 'ass'
+ ffmpeg_codec_name = 'ass'
+
+
+class SubRip(SubtitleCodec):
+ """
+ SubRip subtitle.
+ """
+ codec_name = 'subrip'
+ ffmpeg_codec_name = 'subrip'
+
+
+class DVBSub(SubtitleCodec):
+ """
+ DVB subtitles.
+ """
+ codec_name = 'dvbsub'
+ ffmpeg_codec_name = 'dvbsub'
+
+
+class DVDSub(SubtitleCodec):
+ """
+ DVD subtitles.
+ """
+ codec_name = 'dvdsub'
+ ffmpeg_codec_name = 'dvdsub'
+
+
+audio_codec_list = [
+ AudioNullCodec, AudioCopyCodec, VorbisCodec, AacCodec, Mp3Codec, Mp2Codec,
+ FdkAacCodec, Ac3Codec, DtsCodec, FlacCodec
+]
+
+video_codec_list = [
+ VideoNullCodec, VideoCopyCodec, TheoraCodec, H264Codec,
+ DivxCodec, Vp8Codec, H263Codec, FlvCodec, Mpeg1Codec,
+ Mpeg2Codec
+]
+
+subtitle_codec_list = [
+ SubtitleNullCodec, SubtitleCopyCodec, MOVTextCodec, SSA, SubRip, DVDSub,
+ DVBSub
+]
diff --git a/plugins/videos/ffmpeg.py b/plugins/videos/ffmpeg.py
new file mode 100644
index 0000000..6b14133
--- /dev/null
+++ b/plugins/videos/ffmpeg.py
@@ -0,0 +1,543 @@
+#!/usr/bin/env python
+
+import os.path
+import os
+import re
+import signal
+from subprocess import Popen, PIPE
+import logging
+import locale
+
+logger = logging.getLogger(__name__)
+
+console_encoding = locale.getdefaultlocale()[1] or 'UTF-8'
+
+
+class FFMpegError(Exception):
+ pass
+
+
+class FFMpegConvertError(Exception):
+ def __init__(self, message, cmd, output, details=None, pid=0):
+ """
+ @param message: Error message.
+ @type message: C{str}
+
+ @param cmd: Full command string used to spawn ffmpeg.
+ @type cmd: C{str}
+
+ @param output: Full stdout output from the ffmpeg command.
+ @type output: C{str}
+
+ @param details: Optional error details.
+ @type details: C{str}
+ """
+ super(FFMpegConvertError, self).__init__(message)
+
+ self.cmd = cmd
+ self.output = output
+ self.details = details
+ self.pid = pid
+
+ def __repr__(self):
+ error = self.details if self.details else self.message
+ return ('<FFMpegConvertError error="%s", pid=%s, cmd="%s">' %
+ (error, self.pid, self.cmd))
+
+ def __str__(self):
+ return self.__repr__()
+
+
+class MediaFormatInfo(object):
+ """
+ Describes the media container format. The attributes are:
+ * format - format (short) name (eg. "ogg")
+ * fullname - format full (descriptive) name
+ * bitrate - total bitrate (bps)
+ * duration - media duration in seconds
+ * filesize - file size
+ """
+
+ def __init__(self):
+ self.format = None
+ self.fullname = None
+ self.bitrate = None
+ self.duration = None
+ self.filesize = None
+
+ def parse_ffprobe(self, key, val):
+ """
+ Parse raw ffprobe output (key=value).
+ """
+ if key == 'format_name':
+ self.format = val
+ elif key == 'format_long_name':
+ self.fullname = val
+ elif key == 'bit_rate':
+ self.bitrate = MediaStreamInfo.parse_float(val, None)
+ elif key == 'duration':
+ self.duration = MediaStreamInfo.parse_float(val, None)
+ elif key == 'size':
+ self.size = MediaStreamInfo.parse_float(val, None)
+
+ def __repr__(self):
+ if self.duration is None:
+ return 'MediaFormatInfo(format=%s)' % self.format
+ return 'MediaFormatInfo(format=%s, duration=%.2f)' % (self.format,
+ self.duration)
+
+
+class MediaStreamInfo(object):
+ """
+ Describes one stream inside a media file. The general
+ attributes are:
+ * index - stream index inside the container (0-based)
+ * type - stream type, either 'audio' or 'video'
+ * codec - codec (short) name (e.g "vorbis", "theora")
+ * codec_desc - codec full (descriptive) name
+ * duration - stream duration in seconds
+ * metadata - optional metadata associated with a video or audio stream
+ * bitrate - stream bitrate in bytes/second
+ * attached_pic - (0, 1 or None) is stream a poster image? (e.g. in mp3)
+ Video-specific attributes are:
+ * video_width - width of video in pixels
+ * video_height - height of video in pixels
+ * video_fps - average frames per second
+ Audio-specific attributes are:
+ * audio_channels - the number of channels in the stream
+ * audio_samplerate - sample rate (Hz)
+ """
+
+ def __init__(self):
+ self.index = None
+ self.type = None
+ self.codec = None
+ self.codec_desc = None
+ self.duration = None
+ self.bitrate = None
+ self.video_width = None
+ self.video_height = None
+ self.video_fps = None
+ self.audio_channels = None
+ self.audio_samplerate = None
+ self.attached_pic = None
+ self.sub_forced = None
+ self.sub_default = None
+ self.metadata = {}
+
+ @staticmethod
+ def parse_float(val, default=0.0):
+ try:
+ return float(val)
+ except:
+ return default
+
+ @staticmethod
+ def parse_int(val, default=0):
+ try:
+ return int(val)
+ except:
+ return default
+
+ def parse_ffprobe(self, key, val):
+ """
+ Parse raw ffprobe output (key=value).
+ """
+
+ if key == 'index':
+ self.index = self.parse_int(val)
+ elif key == 'codec_type':
+ self.type = val
+ elif key == 'codec_name':
+ self.codec = val
+ elif key == 'codec_long_name':
+ self.codec_desc = val
+ elif key == 'duration':
+ self.duration = self.parse_float(val)
+ elif key == 'bit_rate':
+ self.bitrate = self.parse_int(val, None)
+ elif key == 'width':
+ self.video_width = self.parse_int(val)
+ elif key == 'height':
+ self.video_height = self.parse_int(val)
+ elif key == 'channels':
+ self.audio_channels = self.parse_int(val)
+ elif key == 'sample_rate':
+ self.audio_samplerate = self.parse_float(val)
+ elif key == 'DISPOSITION:attached_pic':
+ self.attached_pic = self.parse_int(val)
+
+ if key.startswith('TAG:'):
+ key = key.split('TAG:')[1]
+ value = val
+ self.metadata[key] = value
+
+ if self.type == 'audio':
+ if key == 'avg_frame_rate':
+ if '/' in val:
+ n, d = val.split('/')
+ n = self.parse_float(n)
+ d = self.parse_float(d)
+ if n > 0.0 and d > 0.0:
+ self.video_fps = float(n) / float(d)
+ elif '.' in val:
+ self.video_fps = self.parse_float(val)
+
+ if self.type == 'video':
+ if key == 'r_frame_rate':
+ if '/' in val:
+ n, d = val.split('/')
+ n = self.parse_float(n)
+ d = self.parse_float(d)
+ if n > 0.0 and d > 0.0:
+ self.video_fps = float(n) / float(d)
+ elif '.' in val:
+ self.video_fps = self.parse_float(val)
+
+ if self.type == 'subtitle':
+ if key == 'disposition:forced':
+ self.sub_forced = self.parse_int(val)
+ if key == 'disposition:default':
+ self.sub_default = self.parse_int(val)
+
+
+ def __repr__(self):
+ d = ''
+ metadata_str = ['%s=%s' % (key, value) for key, value
+ in self.metadata.items()]
+ metadata_str = ', '.join(metadata_str)
+
+ if self.type == 'audio':
+ d = 'type=%s, codec=%s, channels=%d, rate=%.0f' % (self.type,
+ self.codec, self.audio_channels, self.audio_samplerate)
+ elif self.type == 'video':
+ d = 'type=%s, codec=%s, width=%d, height=%d, fps=%.1f' % (
+ self.type, self.codec, self.video_width, self.video_height,
+ self.video_fps)
+ elif self.type == 'subtitle':
+ d = 'type=%s, codec=%s' % (self.type, self.codec)
+ if self.bitrate is not None:
+ d += ', bitrate=%d' % self.bitrate
+
+ if self.metadata:
+ value = 'MediaStreamInfo(%s, %s)' % (d, metadata_str)
+ else:
+ value = 'MediaStreamInfo(%s)' % d
+
+ return value
+
+
+class MediaInfo(object):
+ """
+ Information about media object, as parsed by ffprobe.
+ The attributes are:
+ * format - a MediaFormatInfo object
+ * streams - a list of MediaStreamInfo objects
+ """
+
+ def __init__(self, posters_as_video=True):
+ """
+ :param posters_as_video: Take poster images (mainly for audio files) as
+ A video stream, defaults to True
+ """
+ self.format = MediaFormatInfo()
+ self.posters_as_video = posters_as_video
+ self.streams = []
+
+ def parse_ffprobe(self, raw):
+ """
+ Parse raw ffprobe output.
+ """
+ in_format = False
+ current_stream = None
+
+ for line in raw.split('\n'):
+ line = line.strip()
+ if line == '':
+ continue
+ elif line == '[STREAM]':
+ current_stream = MediaStreamInfo()
+ elif line == '[/STREAM]':
+ if current_stream.type:
+ self.streams.append(current_stream)
+ current_stream = None
+ elif line == '[FORMAT]':
+ in_format = True
+ elif line == '[/FORMAT]':
+ in_format = False
+ elif '=' in line:
+ k, v = line.split('=', 1)
+ k = k.strip()
+ v = v.strip()
+ if current_stream:
+ current_stream.parse_ffprobe(k, v)
+ elif in_format:
+ self.format.parse_ffprobe(k, v)
+
+ def __repr__(self):
+ return 'MediaInfo(format=%s, streams=%s)' % (repr(self.format),
+ repr(self.streams))
+
+ @property
+ def video(self):
+ """
+ First video stream, or None if there are no video streams.
+ """
+ for s in self.streams:
+ if s.type == 'video' and (self.posters_as_video
+ or not s.attached_pic):
+ return s
+ return None
+
+ @property
+ def posters(self):
+ return [s for s in self.streams if s.attached_pic]
+
+ @property
+ def audio(self):
+ """
+ First audio stream, or None if there are no audio streams.
+ """
+ for s in self.streams:
+ if s.type == 'audio':
+ return s
+ return None
+
+
+class FFMpeg(object):
+ """
+ FFMPeg wrapper object, takes care of calling the ffmpeg binaries,
+ passing options and parsing the output.
+
+ >>> f = FFMpeg()
+ """
+ DEFAULT_JPEG_QUALITY = 4
+
+ def __init__(self, ffmpeg_path=None, ffprobe_path=None):
+ """
+ Initialize a new FFMpeg wrapper object. Optional parameters specify
+ the paths to ffmpeg and ffprobe utilities.
+ """
+
+ def which(name):
+ path = os.environ.get('PATH', os.defpath)
+ for d in path.split(':'):
+ fpath = os.path.join(d, name)
+ if os.path.exists(fpath) and os.access(fpath, os.X_OK):
+ return fpath
+ return None
+
+ if ffmpeg_path is None:
+ ffmpeg_path = 'ffmpeg'
+
+ if ffprobe_path is None:
+ ffprobe_path = 'ffprobe'
+
+ if '/' not in ffmpeg_path:
+ ffmpeg_path = which(ffmpeg_path) or ffmpeg_path
+ if '/' not in ffprobe_path:
+ ffprobe_path = which(ffprobe_path) or ffprobe_path
+
+ self.ffmpeg_path = ffmpeg_path
+ self.ffprobe_path = ffprobe_path
+
+ if not os.path.exists(self.ffmpeg_path):
+ raise FFMpegError("ffmpeg binary not found: " + self.ffmpeg_path)
+
+ if not os.path.exists(self.ffprobe_path):
+ raise FFMpegError("ffprobe binary not found: " + self.ffprobe_path)
+
+ @staticmethod
+ def _spawn(cmds):
+ logger.debug('Spawning ffmpeg with command: ' + ' '.join(cmds))
+ return Popen(cmds, shell=False, stdin=PIPE, stdout=PIPE, stderr=PIPE,
+ close_fds=True)
+
+ def probe(self, fname, posters_as_video=True):
+ """
+ Examine the media file and determine its format and media streams.
+ Returns the MediaInfo object, or None if the specified file is
+ not a valid media file.
+
+ >>> info = FFMpeg().probe('test1.ogg')
+ >>> info.format
+ 'ogg'
+ >>> info.duration
+ 33.00
+ >>> info.video.codec
+ 'theora'
+ >>> info.video.width
+ 720
+ >>> info.video.height
+ 400
+ >>> info.audio.codec
+ 'vorbis'
+ >>> info.audio.channels
+ 2
+ :param posters_as_video: Take poster images (mainly for audio files) as
+ A video stream, defaults to True
+ """
+
+ if not os.path.exists(fname):
+ return None
+
+ info = MediaInfo(posters_as_video)
+
+ p = self._spawn([self.ffprobe_path,
+ '-show_format', '-show_streams', fname])
+ stdout_data, _ = p.communicate()
+ stdout_data = stdout_data.decode(console_encoding)
+ info.parse_ffprobe(stdout_data)
+
+ if not info.format.format and len(info.streams) == 0:
+ return None
+
+ return info
+
+ def convert(self, infile, outfile, opts, timeout=10):
+ """
+ Convert the source media (infile) according to specified options
+ (a list of ffmpeg switches as strings) and save it to outfile.
+
+ Convert returns a generator that needs to be iterated to drive the
+ conversion process. The generator will periodically yield timecode
+ of currently processed part of the file (ie. at which second in the
+ content is the conversion process currently).
+
+ The optional timeout argument specifies how long should the operation
+ be blocked in case ffmpeg gets stuck and doesn't report back. See
+ the documentation in Converter.convert() for more details about this
+ option.
+
+ >>> conv = FFMpeg().convert('test.ogg', '/tmp/output.mp3',
+ ... ['-acodec libmp3lame', '-vn'])
+ >>> for timecode in conv:
+ ... pass # can be used to inform the user about conversion progress
+
+ """
+ if not os.path.exists(infile):
+ raise FFMpegError("Input file doesn't exist: " + infile)
+
+ cmds = [self.ffmpeg_path, '-i', infile]
+ cmds.extend(opts)
+ cmds.extend(['-y', outfile])
+
+ if timeout:
+ def on_sigalrm(*_):
+ signal.signal(signal.SIGALRM, signal.SIG_DFL)
+ raise Exception('timed out while waiting for ffmpeg')
+
+ signal.signal(signal.SIGALRM, on_sigalrm)
+
+ try:
+ p = self._spawn(cmds)
+ except OSError:
+ raise FFMpegError('Error while calling ffmpeg binary')
+
+ yielded = False
+ buf = ''
+ total_output = ''
+ pat = re.compile(r'time=([0-9.:]+) ')
+ while True:
+ if timeout:
+ signal.alarm(timeout)
+
+ ret = p.stderr.read(10)
+
+ if timeout:
+ signal.alarm(0)
+
+ if not ret:
+ break
+
+ ret = ret.decode(console_encoding)
+ total_output += ret
+ buf += ret
+ if '\r' in buf:
+ line, buf = buf.split('\r', 1)
+
+ tmp = pat.findall(line)
+ if len(tmp) == 1:
+ timespec = tmp[0]
+ if ':' in timespec:
+ timecode = 0
+ for part in timespec.split(':'):
+ timecode = 60 * timecode + float(part)
+ else:
+ timecode = float(tmp[0])
+ yielded = True
+ yield timecode
+
+ if timeout:
+ signal.signal(signal.SIGALRM, signal.SIG_DFL)
+
+ p.communicate() # wait for process to exit
+
+ if total_output == '':
+ raise FFMpegError('Error while calling ffmpeg binary')
+
+ cmd = ' '.join(cmds)
+ if '\n' in total_output:
+ line = total_output.split('\n')[-2]
+
+ if line.startswith('Received signal'):
+ # Received signal 15: terminating.
+ raise FFMpegConvertError(line.split(':')[0], cmd, total_output, pid=p.pid)
+ if line.startswith(infile + ': '):
+ err = line[len(infile) + 2:]
+ raise FFMpegConvertError('Encoding error', cmd, total_output,
+ err, pid=p.pid)
+ if line.startswith('Error while '):
+ raise FFMpegConvertError('Encoding error', cmd, total_output,
+ line, pid=p.pid)
+ if not yielded:
+ raise FFMpegConvertError('Unknown ffmpeg error', cmd,
+ total_output, line, pid=p.pid)
+ if p.returncode != 0:
+ raise FFMpegConvertError('Exited with code %d' % p.returncode, cmd,
+ total_output, pid=p.pid)
+
+ def thumbnail(self, fname, time, outfile, size=None, quality=DEFAULT_JPEG_QUALITY):
+ """
+ Create a thumbnal of media file, and store it to outfile
+ @param time: time point (in seconds) (float or int)
+ @param size: Size, if specified, is WxH of the desired thumbnail.
+ If not specified, the video resolution is used.
+ @param quality: quality of jpeg file in range 2(best)-31(worst)
+ recommended range: 2-6
+
+ >>> FFMpeg().thumbnail('test1.ogg', 5, '/tmp/shot.png', '320x240')
+ """
+ return self.thumbnails(fname, [(time, outfile, size, quality)])
+
+ def thumbnails(self, fname, option_list):
+ """
+ Create one or more thumbnails of video.
+ @param option_list: a list of tuples like:
+ (time, outfile, size=None, quality=DEFAULT_JPEG_QUALITY)
+ see documentation of `converter.FFMpeg.thumbnail()` for details.
+
+ >>> FFMpeg().thumbnails('test1.ogg', [(5, '/tmp/shot.png', '320x240'),
+ >>> (10, '/tmp/shot2.png', None, 5)])
+ """
+ if not os.path.exists(fname):
+ raise IOError('No such file: ' + fname)
+
+ cmds = [self.ffmpeg_path, '-i', fname, '-y', '-an']
+ for thumb in option_list:
+ if len(thumb) > 2 and thumb[2]:
+ cmds.extend(['-s', str(thumb[2])])
+
+ cmds.extend([
+ '-f', 'image2', '-vframes', '1',
+ '-ss', str(thumb[0]), thumb[1],
+ '-q:v', str(FFMpeg.DEFAULT_JPEG_QUALITY if len(thumb) < 4 else str(thumb[3])),
+ ])
+
+ p = self._spawn(cmds)
+ _, stderr_data = p.communicate()
+ if stderr_data == '':
+ raise FFMpegError('Error while calling ffmpeg binary')
+ stderr_data.decode(console_encoding)
+ if any(not os.path.exists(option[1]) for option in option_list):
+ raise FFMpegError('Error creating thumbnail: %s' % stderr_data)
diff --git a/plugins/videos/formats.py b/plugins/videos/formats.py
new file mode 100644
index 0000000..ce11c83
--- /dev/null
+++ b/plugins/videos/formats.py
@@ -0,0 +1,98 @@
+#!/usr/bin/env python
+
+
+class BaseFormat(object):
+ """
+ Base format class.
+
+ Supported formats are: ogg, avi, mkv, webm, flv, mov, mp4, mpeg
+ """
+
+ format_name = None
+ ffmpeg_format_name = None
+
+ def parse_options(self, opt):
+ if 'format' not in opt or opt.get('format') != self.format_name:
+ raise ValueError('invalid Format format')
+ return ['-f', self.ffmpeg_format_name]
+
+
+class OggFormat(BaseFormat):
+ """
+ Ogg container format, mostly used with Vorbis and Theora.
+ """
+ format_name = 'ogg'
+ ffmpeg_format_name = 'ogg'
+
+
+class AviFormat(BaseFormat):
+ """
+ Avi container format, often used vith DivX video.
+ """
+ format_name = 'avi'
+ ffmpeg_format_name = 'avi'
+
+
+class MkvFormat(BaseFormat):
+ """
+ Matroska format, often used with H.264 video.
+ """
+ format_name = 'mkv'
+ ffmpeg_format_name = 'matroska'
+
+
+class WebmFormat(BaseFormat):
+ """
+ WebM is Google's variant of Matroska containing only
+ VP8 for video and Vorbis for audio content.
+ """
+ format_name = 'webm'
+ ffmpeg_format_name = 'webm'
+
+
+class FlvFormat(BaseFormat):
+ """
+ Flash Video container format.
+ """
+ format_name = 'flv'
+ ffmpeg_format_name = 'flv'
+
+
+class MovFormat(BaseFormat):
+ """
+ Mov container format, used mostly with H.264 video
+ content, often for mobile platforms.
+ """
+ format_name = 'mov'
+ ffmpeg_format_name = 'mov'
+
+
+class Mp4Format(BaseFormat):
+ """
+ Mp4 container format, the default Format for H.264
+ video content.
+ """
+ format_name = 'mp4'
+ ffmpeg_format_name = 'mp4'
+
+
+class MpegFormat(BaseFormat):
+ """
+ MPEG(TS) container, used mainly for MPEG 1/2 video codecs.
+ """
+ format_name = 'mpg'
+ ffmpeg_format_name = 'mpegts'
+
+
+class Mp3Format(BaseFormat):
+ """
+ Mp3 container, used audio-only mp3 files
+ """
+ format_name = 'mp3'
+ ffmpeg_format_name = 'mp3'
+
+
+format_list = [
+ OggFormat, AviFormat, MkvFormat, WebmFormat, FlvFormat,
+ MovFormat, Mp4Format, MpegFormat, Mp3Format
+]
diff --git a/plugins/videos/videos.py b/plugins/videos/videos.py
new file mode 100644
index 0000000..67a27bc
--- /dev/null
+++ b/plugins/videos/videos.py
@@ -0,0 +1,691 @@
+from __future__ import unicode_literals
+
+import datetime
+import itertools
+import json
+import logging
+import multiprocessing
+import os
+import pprint
+import re
+import sys
+
+from .avcodecs import video_codec_list, audio_codec_list, subtitle_codec_list
+from .formats import format_list
+from .ffmpeg import FFMpeg, FFMpegError, FFMpegConvertError
+
+# Add pelican support
+from pelican.generators import ArticlesGenerator
+from pelican.generators import PagesGenerator
+from pelican.settings import DEFAULT_CONFIG
+from pelican import signals
+from pelican.utils import pelican_open
+
+logger = logging.getLogger(__name__)
+
+class ConverterError(Exception):
+ pass
+
+class Converter(object):
+ """
+ Converter class, encapsulates formats and codecs.
+
+ >>> c = Converter()
+ """
+
+ def __init__(self, ffmpeg_path=None, ffprobe_path=None):
+ """
+ Initialize a new Converter object.
+ """
+
+ self.ffmpeg = FFMpeg(ffmpeg_path=ffmpeg_path,
+ ffprobe_path=ffprobe_path)
+ self.video_codecs = {}
+ self.audio_codecs = {}
+ self.subtitle_codecs = {}
+ self.formats = {}
+
+ for cls in audio_codec_list:
+ name = cls.codec_name
+ self.audio_codecs[name] = cls
+
+ for cls in video_codec_list:
+ name = cls.codec_name
+ self.video_codecs[name] = cls
+
+ for cls in subtitle_codec_list:
+ name = cls.codec_name
+ self.subtitle_codecs[name] = cls
+
+ for cls in format_list:
+ name = cls.format_name
+ self.formats[name] = cls
+
+ def parse_options(self, opt, twopass=None):
+ """
+ Parse format/codec options and prepare raw ffmpeg option list.
+ """
+ if not isinstance(opt, dict):
+ raise ConverterError('Invalid output specification')
+
+ if 'format' not in opt:
+ raise ConverterError('Format not specified')
+
+ f = opt['format']
+ if f not in self.formats:
+ raise ConverterError('Requested unknown format: ' + str(f))
+
+ format_options = self.formats[f]().parse_options(opt)
+ if format_options is None:
+ raise ConverterError('Unknown container format error')
+
+ if 'audio' not in opt and 'video' not in opt:
+ raise ConverterError('Neither audio nor video streams requested')
+
+ # audio options
+ if 'audio' not in opt or twopass == 1:
+ opt_audio = {'codec': None}
+ else:
+ opt_audio = opt['audio']
+ if not isinstance(opt_audio, dict) or 'codec' not in opt_audio:
+ raise ConverterError('Invalid audio codec specification')
+
+ c = opt_audio['codec']
+ if c not in self.audio_codecs:
+ raise ConverterError('Requested unknown audio codec ' + str(c))
+
+ audio_options = self.audio_codecs[c]().parse_options(opt_audio)
+ if audio_options is None:
+ raise ConverterError('Unknown audio codec error')
+
+ # video options
+ if 'video' not in opt:
+ opt_video = {'codec': None}
+ else:
+ opt_video = opt['video']
+ if not isinstance(opt_video, dict) or 'codec' not in opt_video:
+ raise ConverterError('Invalid video codec specification')
+
+ c = opt_video['codec']
+ if c not in self.video_codecs:
+ raise ConverterError('Requested unknown video codec ' + str(c))
+
+ video_options = self.video_codecs[c]().parse_options(opt_video)
+ if video_options is None:
+ raise ConverterError('Unknown video codec error')
+
+ if 'subtitle' not in opt:
+ opt_subtitle = {'codec': None}
+ else:
+ opt_subtitle = opt['subtitle']
+ if not isinstance(opt_subtitle, dict) or 'codec' not in opt_subtitle:
+ raise ConverterError('Invalid subtitle codec specification')
+
+ c = opt_subtitle['codec']
+ if c not in self.subtitle_codecs:
+ raise ConverterError('Requested unknown subtitle codec ' + str(c))
+
+ subtitle_options = self.subtitle_codecs[c]().parse_options(opt_subtitle)
+ if subtitle_options is None:
+ raise ConverterError('Unknown subtitle codec error')
+
+ if 'map' in opt:
+ m = opt['map']
+ if not type(m) == int:
+ raise ConverterError('map needs to be int')
+ else:
+ format_options.extend(['-map', str(m)])
+
+
+ # aggregate all options
+ optlist = audio_options + video_options + subtitle_options + format_options
+
+ if twopass == 1:
+ optlist.extend(['-pass', '1'])
+ elif twopass == 2:
+ optlist.extend(['-pass', '2'])
+
+ return optlist
+
+ def convert(self, infile, outfile, options, twopass=False, timeout=10):
+ """
+ Convert media file (infile) according to specified options, and
+ save it to outfile. For two-pass encoding, specify the pass (1 or 2)
+ in the twopass parameter.
+
+ Options should be passed as a dictionary. The keys are:
+ * format (mandatory, string) - container format; see
+ formats.BaseFormat for list of supported formats
+ * audio (optional, dict) - audio codec and options; see
+ avcodecs.AudioCodec for list of supported options
+ * video (optional, dict) - video codec and options; see
+ avcodecs.VideoCodec for list of supported options
+ * map (optional, int) - can be used to map all content of stream 0
+
+ Multiple audio/video streams are not supported. The output has to
+ have at least an audio or a video stream (or both).
+
+ Convert returns a generator that needs to be iterated to drive the
+ conversion process. The generator will periodically yield timecode
+ of currently processed part of the file (ie. at which second in the
+ content is the conversion process currently).
+
+ The optional timeout argument specifies how long should the operation
+ be blocked in case ffmpeg gets stuck and doesn't report back. This
+ doesn't limit the total conversion time, just the amount of time
+ Converter will wait for each update from ffmpeg. As it's usually
+ less than a second, the default of 10 is a reasonable default. To
+ disable the timeout, set it to None. You may need to do this if
+ using Converter in a threading environment, since the way the
+ timeout is handled (using signals) has special restriction when
+ using threads.
+
+ >>> conv = Converter().convert('test1.ogg', '/tmp/output.mkv', {
+ ... 'format': 'mkv',
+ ... 'audio': { 'codec': 'aac' },
+ ... 'video': { 'codec': 'h264' }
+ ... })
+
+ >>> for timecode in conv:
+ ... pass # can be used to inform the user about the progress
+ """
+
+ if not isinstance(options, dict):
+ raise ConverterError('Invalid options')
+
+ if not os.path.exists(infile):
+ raise ConverterError("Source file doesn't exist: " + infile)
+
+ info = self.ffmpeg.probe(infile)
+ if info is None:
+ raise ConverterError("Can't get information about source file")
+
+ if not info.video and not info.audio:
+ raise ConverterError('Source file has no audio or video streams')
+
+ if info.video and 'video' in options:
+ options = options.copy()
+ v = options['video'] = options['video'].copy()
+ v['src_width'] = info.video.video_width
+ v['src_height'] = info.video.video_height
+
+ if info.format.duration < 0.01:
+ raise ConverterError('Zero-length media')
+
+ if twopass:
+ optlist1 = self.parse_options(options, 1)
+ for timecode in self.ffmpeg.convert(infile, outfile, optlist1,
+ timeout=timeout):
+ yield int((50.0 * timecode) / info.format.duration)
+
+ optlist2 = self.parse_options(options, 2)
+ for timecode in self.ffmpeg.convert(infile, outfile, optlist2,
+ timeout=timeout):
+ yield int(50.0 + (50.0 * timecode) / info.format.duration)
+ else:
+ optlist = self.parse_options(options, twopass)
+ for timecode in self.ffmpeg.convert(infile, outfile, optlist,
+ timeout=timeout):
+ yield int((100.0 * timecode) / info.format.duration)
+
+ def probe(self, fname, posters_as_video=True):
+ """
+ Examine the media file. See the documentation of
+ converter.FFMpeg.probe() for details.
+
+ :param posters_as_video: Take poster images (mainly for audio files) as
+ A video stream, defaults to True
+ """
+ return self.ffmpeg.probe(fname, posters_as_video)
+
+ def thumbnail(self, fname, time, outfile, size=None, quality=FFMpeg.DEFAULT_JPEG_QUALITY):
+ """
+ Create a thumbnail of the media file. See the documentation of
+ converter.FFMpeg.thumbnail() for details.
+ """
+ return self.ffmpeg.thumbnail(fname, time, outfile, size, quality)
+
+ def thumbnails(self, fname, option_list):
+ """
+ Create one or more thumbnail of the media file. See the documentation
+ of converter.FFMpeg.thumbnails() for details.
+ """
+ return self.ffmpeg.thumbnails(fname, option_list)
+
+def initialized(pelican):
+ p = os.path.expanduser('~/Videos')
+
+ DEFAULT_CONFIG.setdefault('VIDEO_LIBRARY', p)
+ DEFAULT_CONFIG.setdefault('VIDEO_GALLERY', (720, 400, 100))
+ DEFAULT_CONFIG.setdefault('VIDEO_ARTICLE', (720, 400, 100))
+ DEFAULT_CONFIG.setdefault('VIDEO_FORMAT', 'mp4')
+ DEFAULT_CONFIG.setdefault('VIDEO_VCODEC', 'h264')
+ DEFAULT_CONFIG.setdefault('VIDEO_ACODEC', 'aac')
+ DEFAULT_CONFIG.setdefault('VIDEO_SAMPLERATE', 11025)
+ DEFAULT_CONFIG.setdefault('VIDEO_CHANNELS', 2)
+ DEFAULT_CONFIG.setdefault('VIDEO_CONVERT_JOBS', 1)
+ DEFAULT_CONFIG.setdefault('VIDEO_EXCLUDE', [])
+ DEFAULT_CONFIG.setdefault('VIDEO_EXCLUDEALL', False)
+
+ DEFAULT_CONFIG['queue_convert'] = {}
+ DEFAULT_CONFIG['created_galleries'] = {}
+ DEFAULT_CONFIG['plugin_dir'] = os.path.dirname(os.path.realpath(__file__))
+
+ if pelican:
+ pelican.settings.setdefault('VIDEO_LIBRARY', p)
+ pelican.settings.setdefault('VIDEO_GALLERY', (720, 400, 100))
+ pelican.settings.setdefault('VIDEO_ARTICLE', (720, 400, 100))
+ pelican.settings.setdefault('VIDEO_FORMAT', 'mp4')
+ pelican.settings.setdefault('VIDEO_VCODEC', 'h264')
+ pelican.settings.setdefault('VIDEO_ACODEC', 'aac')
+ pelican.settings.setdefault('VIDEO_SAMPLERATE', 11025)
+ pelican.settings.setdefault('VIDEO_CHANNELS', 2)
+ pelican.settings.setdefault('VIDEO_CONVERT_JOBS', 1)
+ # Need to change type, VIDEO_EXCLUDE must be iterable
+ if pelican.settings['VIDEO_EXCLUDE'] is None:
+ pelican.settings['VIDEO_EXCLUDE'] = []
+
+ video_excludeall = pelican.settings['VIDEO_EXCLUDEALL'] == '1'
+ pelican.settings['VIDEO_EXCLUDEALL'] = video_excludeall
+
+def read_notes(filename, msg=None):
+ notes = {}
+ try:
+ with pelican_open(filename) as text:
+ for line in text.splitlines():
+ if line.startswith('#'):
+ continue
+ m = line.split(':', 1)
+ if len(m) > 1:
+ pic = m[0].strip()
+ note = m[1].strip()
+ if pic and note:
+ notes[pic] = note
+ else:
+ notes[line] = ''
+ except Exception as e:
+ if msg:
+ logger.info('videos: {} at file {}'.format(msg, filename))
+
+ debug_msg = 'videos: read_notes issue: {} at file {} {}'
+ logger.debug(debug_msg.format(msg, filename, e))
+ return notes
+
+def build_license(license, author):
+ year = datetime.datetime.now().year
+ license_file = os.path.join(DEFAULT_CONFIG['plugin_dir'], 'licenses.json')
+
+ with open(license_file) as data_file:
+ licenses = json.load(data_file)
+
+ if any(license in k for k in licenses):
+ return licenses[license]['Text'].format(Author = author,
+ Year = year,
+ URL = licenses[license]['URL'])
+ else:
+ license_line = 'Copyright {Year} {Author}, All Rights Reserved'
+ return license_line.format(Author = author, Year = year)
+
+def convert_worker(orig, converted, spec, settings):
+ directory = os.path.split(converted)[0]
+
+ if not os.path.exists(directory):
+ try:
+ os.makedirs(directory)
+ except Exception:
+ logger.exception('videos: could not create {}'.format(directory))
+ else:
+ debug_msg = 'videos: directory already exists at {}'
+ logger.debug(debug_msg.format(os.path.split(converted)[0]))
+
+ try:
+ c = Converter() # FIXME: is Converter thread safe?
+ conv = c.convert(orig, converted, spec)
+ info_msg = '-> videos: converting ({:3d}%) | {}'
+ for t in conv:
+ print(info_msg.format(t, '*' * int(t/2)), end='\r')
+ print(info_msg.format(100, '*' * int(t/2) + ' Done'), end='\n')
+ except Exception as e:
+ error_msg = 'videos: could not convert {} {}'
+ logger.exception(error_msg.format(orig, pprint.pformat(e)))
+
+def sort_input_spec(input_spec):
+ # H > W
+ if input_spec[1] > input_spec[0]:
+ return (input_spec[1], input_spec[0], input_spec[2])
+ return input_spec
+
+def compute_output_spec(video_info, input_vspec, settings):
+ # To make sure that original format is preserved, then regardless
+ # of the original format, always sort what was requested.
+ input_vspec = sort_input_spec(input_vspec)
+ output_spec = {
+ 'format': settings['VIDEO_FORMAT'],
+ 'audio': {
+ 'codec': settings['VIDEO_ACODEC'],
+ 'samplerate': settings['VIDEO_SAMPLERATE'],
+ 'channels' : settings['VIDEO_CHANNELS']
+ },
+ 'video': {
+ 'codec': settings['VIDEO_VCODEC'],
+ 'width': input_vspec[0],
+ 'height': input_vspec[1],
+ 'fps' : video_info[2]
+ }
+ }
+
+ # portrait
+ if video_info[1] > video_info[0]:
+ output_spec['video']['width'] = input_vspec[1]
+ output_spec['video']['height'] = input_vspec[0]
+
+ # landscape or square
+ return output_spec
+
+def get_video_info(info):
+ try:
+ if info is None:
+ logger.error('videos: no media info')
+ return None
+
+ # The API doesn't support more than 2 streams per file
+ v_stream = info.streams[0]
+ a_stream = info.streams[1]
+
+ if v_stream is None or a_stream is None:
+ logger.error('videos: no AV stream')
+ return None
+
+ return (v_stream.video_width, v_stream.video_height, v_stream.video_fps)
+
+ except Exception as e:
+ logger.exception('videos: none probed info {}'.format(pprint.pformat(e)))
+
+def enqueue_convert(orig, converted, input_vspec, settings):
+ # Unfortunately need to compute width and height now, as templates
+ # need to be aware of the actual spec being computed.
+ try:
+ c = Converter()
+ info = c.probe(orig)
+ video_info = get_video_info(info)
+ output_spec = compute_output_spec(video_info, input_vspec, settings)
+ except Exception as e:
+ error_msg = 'videos: could not probe {} {}'
+ logger.exception(error_msg.format(orig, pprint.pformat(e)))
+
+ if converted not in DEFAULT_CONFIG['queue_convert']:
+ DEFAULT_CONFIG['queue_convert'][converted] = (orig, output_spec)
+ return (output_spec['video']['width'], output_spec['video']['height'])
+
+ if DEFAULT_CONFIG['queue_convert'][converted] != (orig, output_spec):
+ error_msg = 'videos: convert conflict for {}, {}-{} is not {}-{}'
+ item_one = DEFAULT_CONFIG['queue_convert'][converted][0]
+ item_two = DEFAULT_CONFIG['queue_convert'][converted][1]
+ logger.error(error_msg.format(converted, item_one, item_two,
+ orig, output_spec))
+
+def convert_videos(generator, writer):
+ if generator.settings['VIDEO_EXCLUDEALL']:
+ logger.warning('videos: skip all galleries')
+ return
+
+ if generator.settings['VIDEO_CONVERT_JOBS'] == -1:
+ generator.settings['VIDEO_CONVERT_JOBS'] = 1
+ debug = True
+ else:
+ debug = False
+
+ pool = multiprocessing.Pool(generator.settings['VIDEO_CONVERT_JOBS'])
+ for converted, what in DEFAULT_CONFIG['queue_convert'].items():
+ converted = os.path.join(generator.output_path, converted)
+ abs_path = os.path.split(converted)[0]
+ basename = os.path.basename(abs_path)
+
+ if basename in generator.settings['VIDEO_EXCLUDE']:
+ logger.warning('videos: skip gallery: {}'.format(basename))
+ continue
+
+ orig, spec = what
+ if (not os.path.isfile(converted) or
+ os.path.getmtime(orig) > os.path.getmtime(converted)):
+ if debug:
+ logger.debug('videos: synchronous convert: {}'.format(orig))
+ convert_worker(orig, converted, spec, generator.settings)
+ else:
+ logger.debug('videos: asynchronous convert: {}'.format(orig))
+ pool.apply_async(convert_worker,
+ (orig, converted, spec, generator.settings))
+
+ pool.close()
+ pool.join()
+
+def detect_content(content):
+ hrefs = None
+
+ def replacer(m):
+ what = m.group('what')
+ value = m.group('value')
+ tag = m.group('tag')
+ output = m.group(0)
+
+ if what in ('video'):
+ if value.startswith('/'):
+ value = value[1:]
+
+ path = os.path.join(os.path.expanduser(settings['VIDEO_LIBRARY']),
+ value)
+
+ if os.path.isfile(path):
+ video_prefix = os.path.splitext(value)[0].lower()
+
+ if what == 'video':
+ ext = settings['VIDEO_FORMAT']
+ video_article = video_prefix + 'a.' + ext
+ enqueue_convert(path, os.path.join('videos', video_article),
+ settings['VIDEO_ARTICLE'], settings)
+
+ output = ''.join((
+ '<',
+ m.group('tag'),
+ m.group('attrs_before'),
+ m.group('src'),
+ '=',
+ m.group('quote'),
+ os.path.join(settings['SITEURL'], 'videos',
+ video_article),
+ m.group('quote'),
+ m.group('attrs_after'),
+ ))
+ else:
+ logger.error('videos: no video {}'.format(path))
+
+ return output
+
+ if hrefs is None:
+ regex = r"""
+ <\s*
+ (?P<tag>[^\s\>]+) # detect the tag
+ (?P<attrs_before>[^\>]*)
+ (?P<src>href|src) # match tag with src and href attr
+ \s*=
+ (?P<quote>["\']) # require value to be quoted
+ (?P<path>{0}(?P<value>.*?)) # the url value
+ (?P=quote)
+ (?P<attrs_after>[^\>]*>)
+ """.format(content.settings['INTRASITE_LINK_REGEX'])
+ hrefs = re.compile(regex, re.X)
+
+ if content._content and ('{video}' in content._content):
+ settings = content.settings
+ content._content = hrefs.sub(replacer, content._content)
+
+def galleries_string_decompose(gallery_string):
+ splitter_regex = re.compile(r'[\s,]*?({video}|{filename})')
+ title_regex = re.compile(r'{(.+)}')
+ galleries = map(unicode.strip if
+ sys.version_info.major == 2 else
+ str.strip,
+ filter(None, splitter_regex.split(gallery_string)))
+ galleries = [gallery[1:] if
+ gallery.startswith('/') else
+ gallery for gallery in galleries]
+
+ if len(galleries) % 2 == 0 and ' ' not in galleries:
+ galleries = zip(zip(['type'] * len(galleries[0::2]),
+ galleries[0::2]),
+ zip(['location'] * len(galleries[0::2]),
+ galleries[1::2]))
+ galleries = [dict(gallery) for gallery in galleries]
+
+ for gallery in galleries:
+ title = re.search(title_regex, gallery['location'])
+ if title:
+ gallery['title'] = title.group(1)
+ gallery['location'] = re.sub(title_regex, '',
+ gallery['location']).strip()
+ else:
+ gallery['title'] = DEFAULT_CONFIG['VIDEO_GALLERY_TITLE']
+ return galleries
+ else:
+ error_msg = 'videos: unexpected gallery location format!\n{}'
+ logger.error(error_msg.format(pprint.pformat(galleries)))
+
+def process_gallery(generator, content, location):
+ galleries = galleries_string_decompose(location)
+ content.video_gallery = []
+
+ for gallery in galleries:
+ if gallery['location'] in DEFAULT_CONFIG['created_galleries']:
+ item = DEFAULT_CONFIG['created_galleries'][gallery]
+ content.video_gallery.append((gallery['location'], item))
+ continue
+
+ if gallery['type'] == '{video}':
+ path = generator.settings['VIDEO_LIBRARY']
+ dir_gallery = os.path.join(os.path.expanduser(path), gallery['location'])
+ rel_gallery = gallery['location']
+ elif gallery['type'] == '{filename}':
+ base_path = os.path.join(generator.path, content.relative_dir)
+ dir_gallery = os.path.join(base_path, gallery['location'])
+ rel_gallery = os.path.join(content.relative_dir, gallery['location'])
+
+ if os.path.isdir(dir_gallery):
+ logger.info('videos: gallery detected: {}'.format(rel_gallery))
+ dir_video = os.path.join('videos', rel_gallery.lower())
+
+ try:
+ captions = read_notes(os.path.join(dir_gallery, 'captions.txt'),
+ msg='videos: no captions for gallery')
+ blacklist = read_notes(os.path.join(dir_gallery, 'blacklist.txt'),
+ msg='videos: no blacklist for gallery')
+ except Exception as e:
+ logger.debug('photos: exception {}'.format(pprint.pformat(e)))
+
+ # HTML5 videos tag has several attribs. Here used only:
+ # [({{ title }}, ({{ video }}, {{ width }}, {{ height }}, {{ format }}))]
+ content_gallery = []
+ tag_title = gallery['title']
+
+ # Go over all the videos in dir_gallery
+ for old_video in sorted(os.listdir(dir_gallery)):
+ if old_video.startswith('.'):
+ continue
+ if old_video.endswith('.txt'):
+ continue
+ if old_video in blacklist:
+ continue
+
+ tag_format = generator.settings['VIDEO_FORMAT']
+ # tag_name currently not passed to the template
+ tag_name = os.path.splitext(old_video)[0].lower() + '.' + tag_format
+ # relative path from /video/...
+ tag_path = os.path.join(dir_video, tag_name)
+ tag_spec = enqueue_convert(os.path.join(dir_gallery, old_video),
+ tag_path,
+ generator.settings['VIDEO_GALLERY'],
+ generator.settings)
+
+ # Add more tag if needed, note: order matters!
+ content_gallery.append((tag_path, tag_spec[0], tag_spec[1], tag_format))
+
+ content.video_gallery.append((tag_title, content_gallery))
+ debug_msg = 'videos: gallery data: {}'
+ logger.debug(debug_msg.format(pprint.pformat(content.video_gallery)))
+ DEFAULT_CONFIG['created_galleries']['gallery'] = content_gallery
+ else:
+ error_msg = 'videos: gallery does not exist: {} at {}'
+ logger.error(error_msg.format(gallery['location'], dir_gallery))
+
+def detect_gallery(generator, content):
+ if 'video-gallery' in content.metadata:
+ gallery = content.metadata.get('video-gallery')
+ if gallery.startswith('{video}') or gallery.startswith('{filename}'):
+ process_gallery(generator, content, gallery)
+ elif gallery:
+ error_msg = 'videos: gallery tag not recognized: {}'
+ logger.error(error_msg.format(gallery))
+
+def video_clipper(x):
+ return x[8:] if x[8] == '/' else x[7:]
+
+def file_clipper(x):
+ return x[11:] if x[10] == '/' else x[10:]
+
+def process_video(generator, content, video_input):
+ if video_input.startswith('{video}'):
+ path = generator.settings['VIDEO_LIBRARY']
+ path = os.path.join(os.path.expanduser(path), video_clipper(video_input))
+ video_name = video_clipper(video_input)
+ elif video_input.startswith('{filename}'):
+ path = os.path.join(generator.path, content.relative_dir,
+ file_clipper(video_input))
+ video_name = file_clipper(video_input)
+
+ if os.path.isfile(path):
+ ext = generator.settings['VIDEO_FORMAT']
+ video = os.path.splitext(video_name)[0].lower() + 'a.' + ext
+ content.video_file = (os.path.basename(video_name).lower(),
+ os.path.join('videos', video))
+
+ enqueue_convert(path, os.path.join('videos', video),
+ generator.settings['VIDEO_ARTICLE'],
+ settings)
+ else:
+ error_msg = 'videos: no video for {} at {}'
+ logger.error(error_msg.format(content.source_path, path))
+
+def detect_video(generator, content):
+ video = content.metadata.get('video', None)
+ if video:
+ if video.startswith('{video}') or video.startswith('{filename}'):
+ process_video(generator, content, video)
+ else:
+ error_msg = 'videos: video tag not recognized: {}'
+ logger.error(error_msg.format(video))
+
+def detect_videos_and_galleries(generators):
+ """Runs generator on both pages and articles."""
+ for generator in generators:
+ if isinstance(generator, ArticlesGenerator):
+ for article in itertools.chain(generator.articles,
+ generator.translations,
+ generator.drafts):
+ detect_video(generator, article)
+ detect_gallery(generator, article)
+ elif isinstance(generator, PagesGenerator):
+ for page in itertools.chain(generator.pages,
+ generator.translations,
+ generator.hidden_pages):
+ detect_video(generator, page)
+ detect_gallery(generator, page)
+
+def register():
+ """Uses the new style of registration based on GitHub Pelican issue #314."""
+ signals.initialized.connect(initialized)
+ try:
+ signals.content_object_init.connect(detect_content)
+ signals.all_generators_finalized.connect(detect_videos_and_galleries)
+ signals.article_writer_finalized.connect(convert_videos)
+ except Exception as e:
+ except_msg = 'videos: plugin failed to execute: {}'
+ logger.exception(except_msg.format(pprint.pformat(e)))
diff --git a/theme/templates/article.html b/theme/templates/article.html
index 11e32c5..d8bd049 100644
--- a/theme/templates/article.html
+++ b/theme/templates/article.html
@@ -38,6 +38,22 @@
{% if not DISPLAY_META_ABOVE_ARTICLE %}
{% include "modules/article_meta.html" %}
{% endif %}
+ {% if article.video_gallery %}
+ <div class="gallery">
+ {% for title, gallery in article.video_gallery %}
+ <h1>{{ title }}</h1>
+ {% for video, width, height, format in gallery %}
+ <video width="{{ width }}" height="{{ height }}" controls>
+ <source src="{{ SITEURL }}/{{ video }}" type="video/{{ format }}">
+ Your browser does not support the video tag.
+ </video>
+ {% endfor %}
+ {% endfor %}
+ </div>
+ {% endif %}
+ {% if not DISPLAY_META_ABOVE_ARTICLE %}
+ {% include "modules/article_meta.html" %}
+ {% endif %}
{% if DISQUS_SITENAME %}
<div id="article_comments">