Skip to content

Commit

Permalink
Merge pull request #420 from ksfeldman/additional_video_options
Browse files Browse the repository at this point in the history
Additional video options
  • Loading branch information
saimn committed Feb 8, 2021
2 parents 79789e3 + 1e65fc5 commit b265929
Show file tree
Hide file tree
Showing 6 changed files with 129 additions and 42 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Expand Up @@ -17,3 +17,5 @@ htmlcov/
output/
sigal/version.py
tags
venv
ffmpeg2pass-0.log
1 change: 1 addition & 0 deletions AUTHORS
Expand Up @@ -29,6 +29,7 @@ alphabetical order):
- Juan A. Suarez Romero
- Julien Voisin
- Kai Fricke
- Keith Feldman
- Keith Johnson
- Kevin Murray
- Lukas Vacek
Expand Down
4 changes: 4 additions & 0 deletions sigal/settings.py
@@ -1,6 +1,7 @@
# Copyright (c) 2009-2020 - Simon Conseil
# Copyright (c) 2013 - Christophe-Marie Duquesne
# Copyright (c) 2017 - Mate Lakat
# Copyright (c) 2021 - Keith Feldman

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
Expand Down Expand Up @@ -54,6 +55,7 @@
'medias_sort_attr': 'filename',
'medias_sort_reverse': False,
'mp4_options': ['-crf', '23', '-strict', '-2'],
'mp4_options_second_pass': None,
'orig_dir': 'original',
'orig_link': False,
'rel_link': False,
Expand All @@ -78,10 +80,12 @@
'video_converter': 'ffmpeg',
'video_extensions': ['.mov', '.avi', '.mp4', '.webm', '.ogv', '.3gp'],
'video_format': 'webm',
'video_always_convert': False,
'video_size': (480, 360),
'watermark': '',
'webm_options': ['-crf', '10', '-b:v', '1.6M',
'-qmin', '4', '-qmax', '63'],
'webm_options_second_pass': None,
'write_html': True,
'zip_gallery': False,
'zip_media_format': 'resized',
Expand Down
18 changes: 18 additions & 0 deletions sigal/templates/sigal.conf.py
Expand Up @@ -171,14 +171,32 @@
# webm_options = ['-crf', '10', '-b:v', '1.6M',
# '-qmin', '4', '-qmax', '63']

# Webm options for 2-pass encoding
# Options used to encode the webm video on the second pass.
# Set to None by default, set to an array if a second pass is desired.
# webm_options_second_pass = None


# MP4 options
# Options used to encode the mp4 video. You may want to read
# https://trac.ffmpeg.org/wiki/Encode/H.264
# mp4_options = ['-crf', '23' ]

# MP4 options for 2-pass encoding
# Options used to encode the mp4 video on the second pass.
# Set to None by default, set to an array if a second pass is desired.
# mp4_options_second_pass = None


# Size of resized video (default: (480, 360))
# Set this to None if no resizing is desired on the video.
# video_size = (480, 360)

# If the desired video extension and filename are the same, the video will
# not be converted. If a transcode to different quality is required,
# set this to True to force convert it. False by default.
# video_always_convert = False

# -------------
# Miscellaneous
# -------------
Expand Down
107 changes: 73 additions & 34 deletions sigal/video.py
@@ -1,5 +1,6 @@
# Copyright (c) 2013 - Christophe-Marie Duquesne
# Copyright (c) 2013-2020 - Simon Conseil
# Copyright (c) 2021 - Keith Feldman

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
Expand Down Expand Up @@ -35,7 +36,7 @@ class SubprocessException(Exception):
pass


def check_subprocess(cmd, source, outname):
def check_subprocess(cmd, source, outname=None):
"""Run the command to resize the video and remove the output file if the
processing fails.
Expand All @@ -46,22 +47,21 @@ def check_subprocess(cmd, source, outname):
stderr=subprocess.PIPE)
except KeyboardInterrupt:
logger.debug('Process terminated, removing file %s', outname)
if os.path.isfile(outname):
if outname and os.path.isfile(outname):
os.remove(outname)
raise

if res.returncode:
logger.debug('STDOUT:\n %s', res.stdout.decode('utf8'))
logger.debug('STDERR:\n %s', res.stderr.decode('utf8'))
if os.path.isfile(outname):
if outname and os.path.isfile(outname):
logger.debug('Removing file %s', outname)
os.remove(outname)
raise SubprocessException('Failed to process ' + source)


def video_size(source, converter='ffmpeg'):
"""Returns the dimensions of the video."""

"""Return the dimensions of the video."""
res = subprocess.run([converter, '-i', source], stderr=subprocess.PIPE)
stderr = res.stderr.decode('utf8')
pattern = re.compile(r'Stream.*Video.* ([0-9]+)x([0-9]+)')
Expand All @@ -77,31 +77,21 @@ def video_size(source, converter='ffmpeg'):
x, y = y, x
return x, y


def generate_video(source, outname, settings, options=None):
"""Video processor.
def get_resize_options(source, converter, output_size):
"""Figure out resize options for video from src and dst sizes.
:param source: path to a video
:param outname: path to the generated video
:param settings: settings dict
:param options: array of options passed to ffmpeg
"""
logger = logging.getLogger(__name__)

# Don't transcode if source is in the required format and
# has fitting datedimensions, copy instead.
converter = settings['video_converter']
w_src, h_src = video_size(source, converter=converter)
w_dst, h_dst = settings['video_size']
w_dst, h_dst = output_size
logger.debug('Video size: %i, %i -> %i, %i', w_src, h_src, w_dst, h_dst)

base, src_ext = splitext(source)
base, dst_ext = splitext(outname)
if dst_ext == src_ext and w_src <= w_dst and h_src <= h_dst:
logger.debug('Video is smaller than the max size, copying it instead')
shutil.copy(source, outname)
return
# do not resize if input dimensions are smaller than output dimensions
if w_src <= w_dst and h_src <= h_dst:
return []

# http://stackoverflow.com/questions/8218363/maintaining-ffmpeg-aspect-ratio
# + I made a drawing on paper to figure this out
Expand All @@ -112,25 +102,77 @@ def generate_video(source, outname, settings, options=None):
# biggest fitting dimension is width
resize_opt = ['-vf', "scale=%i:trunc(ow/a/2)*2" % w_dst]

# do not resize if input dimensions are smaller than output dimensions
if w_src <= w_dst and h_src <= h_dst:
resize_opt = []
return resize_opt


def _get_empty_if_none_else_variable(variable):
return [] if not variable else variable


def generate_video_pass(converter, source, options, outname=None):
"""Run a single pass of encoding.
:param source: source video
:param options: options to pass to encoder
:param outname: if multi-pass, this is None on the first pass
"""
logger = logging.getLogger(__name__)
outname_opt = [] if not outname else [outname]
# Encoding options improved, thanks to
# http://ffmpeg.org/trac/ffmpeg/wiki/vpxEncodingGuide
cmd = [converter, '-i', source, '-y'] # -y to overwrite output files
if options is not None:
cmd += options
cmd += resize_opt + [outname]

cmd += options + outname_opt
logger.debug('Processing video: %s', ' '.join(cmd))
check_subprocess(cmd, source, outname)
check_subprocess(cmd, source, outname=outname)


def generate_video(source, outname, settings):
"""Video processor.
:param source: path to a video
:param outname: path to the generated video
:param settings: settings dict
:param options: array of options passed to ffmpeg
"""
logger = logging.getLogger(__name__)

video_format = settings.get('video_format')
options = settings.get(video_format + '_options')
second_pass_options = settings.get(video_format + '_options_second_pass')
video_always_convert = settings.get('video_always_convert')
converter = settings['video_converter']

resize_opt = []
if settings.get("video_size"):
resize_opt = get_resize_options(source, converter,
settings['video_size'])

base, src_ext = splitext(source)
base, dst_ext = splitext(outname)

if dst_ext == src_ext and not resize_opt and not video_always_convert:
logger.debug('For %s, the source and destination extension are the " \
"same, there is no resizing to be done, and " \
"video_always_convert is False, so the output is " \
" being copied', outname)
shutil.copy(source, outname)
return

final_pass_options = _get_empty_if_none_else_variable(options) + resize_opt
if second_pass_options:
generate_video_pass(converter, source, final_pass_options)
final_second_pass_options = _get_empty_if_none_else_variable(
second_pass_options) + resize_opt
generate_video_pass(converter, source,
final_second_pass_options, outname)
else:
generate_video_pass(converter, source, final_pass_options, outname)


def generate_thumbnail(source, outname, box, delay, fit=True, options=None,
converter='ffmpeg'):
"""Create a thumbnail image for the video source, based on ffmpeg."""

logger = logging.getLogger(__name__)
tmpfile = outname + ".tmp.jpg"

Expand Down Expand Up @@ -159,7 +201,6 @@ def generate_thumbnail(source, outname, box, delay, fit=True, options=None,

def process_video(media):
"""Process a video: resize, create thumbnail."""

logger = logging.getLogger(__name__)
settings = media.settings

Expand All @@ -175,9 +216,7 @@ def process_video(media):
logger.error('Invalid video_format. Please choose one of: %s',
valid_formats)
raise ValueError

generate_video(media.src_path, media.dst_path, settings,
options=settings.get(video_format + '_options'))
generate_video(media.src_path, media.dst_path, settings)
except Exception:
if logger.getEffectiveLevel() == logging.DEBUG:
raise
Expand Down
39 changes: 31 additions & 8 deletions tests/test_video.py
Expand Up @@ -5,7 +5,8 @@
from sigal.gallery import Video
from sigal.settings import Status, create_settings
from sigal.video import (generate_thumbnail, generate_video, process_video,
video_size)
video_size, get_resize_options, generate_video_pass)
from unittest.mock import patch

CURRENT_DIR = os.path.dirname(__file__)
SRCDIR = os.path.join(CURRENT_DIR, 'sample', 'pictures')
Expand Down Expand Up @@ -52,8 +53,7 @@ def test_generate_video_fit_height(tmpdir, fmt):
base, ext = os.path.splitext(TEST_VIDEO)
dstfile = str(tmpdir.join(base + '.' + fmt))
settings = create_settings(video_size=(80, 100), video_format=fmt)
generate_video(SRCFILE, dstfile, settings,
options=settings[fmt + '_options'])
generate_video(SRCFILE, dstfile, settings)

size_src = video_size(SRCFILE)
size_dst = video_size(dstfile)
Expand All @@ -70,8 +70,7 @@ def test_generate_video_fit_width(tmpdir, fmt):
base, ext = os.path.splitext(TEST_VIDEO)
dstfile = str(tmpdir.join(base + '.' + fmt))
settings = create_settings(video_size=(100, 50), video_format=fmt)
generate_video(SRCFILE, dstfile, settings,
options=settings[fmt + '_options'])
generate_video(SRCFILE, dstfile, settings)

size_src = video_size(SRCFILE)
size_dst = video_size(dstfile)
Expand All @@ -83,14 +82,38 @@ def test_generate_video_fit_width(tmpdir, fmt):

@pytest.mark.parametrize("fmt", ['webm', 'mp4', 'ogv'])
def test_generate_video_dont_enlarge(tmpdir, fmt):
"""video dimensions should not be enlarged"""
"""Video dimensions should not be enlarged."""

base, ext = os.path.splitext(TEST_VIDEO)
dstfile = str(tmpdir.join(base + '.' + fmt))
settings = create_settings(video_size=(1000, 1000), video_format=fmt)
generate_video(SRCFILE, dstfile, settings,
options=settings.get(fmt + '_options'))
generate_video(SRCFILE, dstfile, settings)
size_src = video_size(SRCFILE)
size_dst = video_size(dstfile)

assert size_src == size_dst


@patch('sigal.video.generate_video_pass')
@pytest.mark.parametrize("fmt", ['webm', 'mp4'])
def test_second_pass_video(mock_generate_video_pass, fmt, tmpdir):
"""Video should be run through ffmpeg."""
base, ext = os.path.splitext(TEST_VIDEO)
dstfile = str(tmpdir.join(base + '.' + fmt))
settings_1 = '-c:v libvpx-vp9 -b:v 0 -crf 30 -pass 1 -an -f null dev/null'
settings_2 = '-c:v libvpx-vp9 -b:v 0 -crf 30 -pass 2 -f {}'.format(fmt)
settings_opts = {'video_size': (100, 50), 'video_format': fmt,
fmt + '_options': settings_1.split(" "),
fmt + '_options_second_pass': settings_2.split(" ")}

settings = create_settings(**settings_opts)
generate_video(SRCFILE, dstfile, settings)
call_args_list = mock_generate_video_pass.call_args_list
# The method is called twice
assert len(call_args_list) == 2
# The first call to the method should have 3 args, without the outname
args, kwargs = call_args_list[0]
assert len(args) == 3
# The second call to the method should have 4 args, with the outname
args, kwargs = call_args_list[1]
assert len(args) == 4

0 comments on commit b265929

Please sign in to comment.