# Copyright 2017 Russell Anderson, Philip Starkey
#
# This file is part of autoscrub.
#
# autoscrub is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# autoscrub is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with autoscrub. If not, see <http://www.gnu.org/licenses/>.
try:
from __version__ import __version__
except ImportError:
# Version file has not been autogenerated from build process:
__version__ = None
import os
import re
from subprocess import Popen, call, PIPE
import math
from functools import reduce
import six
NUL = os.devnull
__terminal_encoding = 'utf-8'
[docs]def set_terminal_encoding(encoding):
""" Sets the encoding used for communicating with ffmpeg and ffprobe
.. note::Only applies to Python 3.
Sets the default value for the :code:`encoding` keyword argument passed
to :code:`subprocess.Popen`. This chould match your system encoding, and
is unlikely to need changing.
"""
__terminal_encoding = encoding
def _agnostic_Popen(*args, **kwargs):
if six.PY3 and 'encoding' not in kwargs:
kwargs['encoding'] = __terminal_encoding
return Popen(*args, **kwargs)
[docs]def hhmmssd_to_seconds(s):
"""Convert a :code:`'[hh:]mm:ss[.d]'` string to seconds.
Arguments:
s: A string in the format :code:`'[hh:]mm:ss[.d]'`. The hours and decimal seconds are optional.
Returns:
The number of seconds as a float.
"""
assert isinstance(s, six.string_types)
return reduce(lambda t60, x: t60 * 60 + x, map(float, s.split(':')))
[docs]def ffprobe(filename):
"""Runs ffprobe on :code:`filename` and returns the log output from stderr.
Arguments:
filename: The filepath passed to ffprobe.
Returns:
The output of the ffprobe command.
"""
command = 'ffprobe -i "%s"' % filename
p = _agnostic_Popen(command, stdout=PIPE, stderr=PIPE)
stdout, stderr = p.communicate()
return stderr
[docs]def ffmpeg(filename, args=[], output_path=None, output_type=None):
"""Runs ffmpeg on filename with the specified args.
Arguments:
filename: The filepath passed to the :code:`-i` option of ffmpeg.
Keyword Arguments:
args: A list of additional arguments to pass to ffmpeg.
output_path: The filepath to append to the end of the ffmpeg command,
designating the output file for the ffmpeg result. If left
as the default (:code:`None`) it appends :code:`_processed`
to the end of the filename and preserves input file extension unless :code:`output_type` is specified.
output_type: Determines the output file type. Specify as a string
containing the required file extension. This is ignored if
:code:`output_path` is specified.
Returns:
The :code:`output_path` where the output of ffmpeg was written.
"""
command = ['ffmpeg', '-i', '%s' % filename.replace('\\', '/')]
command += args
if output_path is None:
filename_prefix, file_extension = os.path.splitext(filename)
if output_type is not None:
file_extension = output_type
output_path = filename_prefix + '_processed' + file_extension
command += ['%s' % output_path.replace('\\', '/')]
print(' '.join(command))
p = _agnostic_Popen(command)
stdout, stderr = p.communicate()
return output_path
[docs]def findDuration(log_output):
"""Finds the duration in seconds from ffprobe log_output.
Arguments:
log_output: The output of ffprobe, as returned by :func:`autoscrub.ffprobe`.
Returns:
A float containing duration in seconds or None if the duration could not be determined.
"""
matches = re.findall('Duration: +([\d\:\.]+)', log_output)
if matches:
duration = matches[0]
seconds = hhmmssd_to_seconds(duration)
return seconds
else:
return None
[docs]def getDuration(filename):
"""Runs ffprobe on filename and extracts duration in seconds.
Arguments:
filename: The filepath of the media file you wish to process.
Returns:
A float containing duration in seconds or None if the duration could not be determined.
"""
ffprobe_log = ffprobe(filename)
return findDuration(ffprobe_log)
[docs]def findSampleRate(log_output):
"""Finds the audio sample rate in Hz from ffprobe log_output.
Arguments:
log_output: The output of ffprobe, as returned by :func:`autoscrub.ffprobe`.
Returns:
A float containing audio sample rate in Hz or None if the sample rate could not be determined.
"""
matches = re.findall(', ([\d]+) Hz', log_output)
if matches:
return int(matches[-1])
else:
return None
[docs]def getSampleRate(filename):
"""Runs ffprobe on filename and extracts audio sample rate in Hz.
Arguments:
filename: The filepath of the media file you wish to process.
Returns:
A float containing audio sample rate in Hz or None if the sample rate could not be determined.
"""
ffprobe_log = ffprobe(filename)
return findSampleRate(ffprobe_log)
[docs]def findSilences(log_output):
"""Extract silences from ffmpeg log_output when using the silencedetect filter.
Arguments:
log_output: The output of the ffmpeg silencedetect filter, as returned by :func:`autoscrub.getSilences`.
Returns:
a list of silence dictionaries, with keys::
silence_start: the timestamp of the detected silent interval in seconds
silence_end: the timestamp of the detected silent interval in seconds
silence_duration: duration of the silent interval in seconds
"""
matches = re.findall(r"(silence_[a-z]+): ([\-\d\.]+)", log_output)
matches = [(k, float(v)) for (k, v) in matches]
if matches:
return [dict(matches[i:i + 3]) for i in six.moves.xrange(0, len(matches), 3)]
else:
return []
[docs]def getSilences(filename, input_threshold_dB=-18.0, silence_duration=2.0, save_silences=True):
"""Runs the ffmpeg filter silencedetect with the specified settings.
Arguments:
filename: the path to the video file to examine
Keyword Arguments:
input_threshold: instantaneous level (in dB) to detect silences with
(default -18).
silence_duration: seconds for which level mustn't exceed threshold to
declare silence (default 2).
save_silences: print the above timestamps to CSV file (default = True).
Returns:
a list of silence dictionaries, with keys::
silence_start: the timestamp of the detected silent interval in seconds
silence_end: the timestamp of the detected silent interval in seconds
silence_duration: duration of the silent interval in seconds
"""
command = 'ffmpeg -i "%s" -af silencedetect=n=%.1fdB:d=%s -f null %s' % (filename, input_threshold_dB, silence_duration, NUL)
p = _agnostic_Popen(command, stdout=PIPE, stderr=PIPE)
stdout, stderr = p.communicate()
silences = findSilences(stderr)
if save_silences:
filename_prefix, file_extension = os.path.splitext(filename)
silence_path = '%s_silences.csv' % filename_prefix
with open(silence_path, 'w') as f:
for silence in silences:
ti = silence['silence_start']
tf = silence['silence_end'] if 'silence_end' in silence else ''
dt = silence['silence_duration'] if 'silence_duration' in silence else ''
f.write('%s,%s,%s\n' % (ti, tf, dt))
return silences
[docs]def findLoudness(log_output):
"""Extract loudness (key, value) pairs from ffmpeg log_output when using the ebur128 filter.
Arguments:
log_output: The output of the ffmpeg ebur128 filter, as returned by :func:`autoscrub.getLoudness`.
Returns:
A loudness dictionary with keys::
I: integrated loudness in dBLUFS
LRA: loudness range in dBLUFS
LRA high:
LRA low:
Threshold:
"""
log_split = re.split(r"Parsed_ebur128.+", log_output)
if len(log_split) > 1:
summary = log_split[-1]
matches = re.findall(r"([A-Z][A-Za-z ]*): +([\-\d\.]+)", summary)
if matches:
return dict([(k, float(v)) for (k, v) in matches])
return None
[docs]def getLoudness(filename):
"""Runs the ffmpeg ebur128 filter on filename.
Arguments:
filename: the path to the video file to examine.
Returns:
A loudness dictionary with keys::
I: integrated loudness in dBLUFS
LRA: loudness range in dBLUFS
LRA high:
LRA low:
Threshold:
"""
command = 'ffmpeg -i "%s" -c:v copy -af ebur128 -f null %s' % (filename, NUL)
p = _agnostic_Popen(command, stdout=PIPE, stderr=PIPE)
stdout, stderr = p.communicate()
return findLoudness(stderr)
[docs]def matchLoudness(filename, target_lufs=-18, output_path=None):
"""
Applies the volume ffmpeg filter in an attempt to change the audio volume to match the specified target.
Arguments:
filename: the path to the video file to examine.
Keyword Arguments:
target_lufs: The target LUFS for the output audio (default: -18)
output_path: the filepath at which to write the resultant file. If no
output path is specified, it follows the conventions of
:func:`autoscrub.ffmpeg`.
Returns:
The :code:`output_path` where the output of ffmpeg was written.
"""
input_loudness = getLoudness(filename)
input_lufs = input_loudness['I']
gain = target_lufs - input_lufs
print('Input loudness = %.1f LUFS; Gain to apply = %.1f dB' % (input_lufs, gain))
output_path = ffmpeg(filename, ['-c:v', 'copy', '-af', 'volume=%.1fdB' % gain], output_path)
output_loudness = getLoudness(output_path)
output_lufs = output_loudness['I']
print('Output loudness = %.1f LUFS; Error = %.1f dB' % (output_lufs, target_lufs-output_lufs))
return output_path
[docs]def trim(input_path, tstart=0, tstop=None, output_path=None, overwrite=None, codec='copy', output_type=None):
"""Extract contents of input_path between tstart and tstop.
Arguments:
input_path: The path to the media file to process
Keyword Arguments:
tstart: A integer/float in seconds, or a '[hh:]mm:ss[.d]' string
(default 0)
tstop: A integer/float in seconds, or a '[hh:]mm:ss[.d]' string
(default None)
output_path: Defaults to appending '_trimmed' to input_path
overwrite: Optionally specify addition of -y or -n flag to ffmpeg
codec: Specify the codec to use in the encoding of the output file (default: copy).
output_type: Determines the output file type. Specify as a string
containing the required file extension. This is ignored if
:code:`output_path` is specified.
Returns:
The :code:`output_path` where the output of ffmpeg was written.
"""
folder, filename = os.path.split(input_path)
if not isinstance(tstart, six.string_types):
tstart = '%.4f' % float(tstart)
if tstop and not isinstance(tstop, six.string_types):
tstop = '%.4f' % float(tstop)
command = ['ffmpeg', '-i', filename]
if hhmmssd_to_seconds(tstart) > 0:
command += ['-ss', tstart]
if tstop is not None:
command += ['-to', tstop]
if codec == 'copy':
command += ['-c', 'copy']
else:
command += codec
if overwrite is not None:
command.append('-y' if overwrite==True else '-n')
if output_path is None:
filename_prefix, file_extension = os.path.splitext(filename)
if output_type is not None:
file_extension = output_type
output_path = filename_prefix + '_trimmed' + file_extension
command.append(output_path)
try:
p = _agnostic_Popen(command, cwd=folder if folder else '.')
stdout, stderr = p.communicate()
return os.path.join(folder, output_path)
except Exception as e:
print(e)
return None
[docs]def trimSegments(input_path, trimpts, output_path=None, output_type=None, **kwargs):
"""Extract segments of a file using a list of :code:`(tstart, tstop)`
tuples. Each segment is saved as a file of the same type as the original.
Arguments:
input_path: The path to the media file to process
Keyword Arguments:
trimpts: A list of :code:`(tstart, tstop)` tuples. See :func:`trim` for the supported formats of :code:`tstart` and :code:`tstop`.
output_path: The folder in which to save the segments. Defaults to the folder 'temp' in the current working directory.
output_type: Determines the output file type. Specify as a string
containing the required file extension.
kwargs: A list of additional keyword arguments to pass to :func:`trim`.
Note that :code:`tstart`, :code:`tstop` and :code:`output_path` cannot be specified as additional keyword arguments as they are already specified explicitly when :code:`trimSegments` calls :code:`trim`.
Returns:
A list of paths to each segment created.
"""
folder, filename = os.path.split(input_path)
filename_prefix, file_extension = os.path.splitext(filename)
if output_type is not None:
file_extension = output_type
temp_folder = output_path if output_path else os.path.join(folder, 'temp')
if not os.path.exists(temp_folder):
os.mkdir(temp_folder)
segment_paths = []
for i, (tstart, tstop) in enumerate(trimpts):
segment_file = filename_prefix + '_%03i' % i + file_extension
segment_path = os.path.join(temp_folder, segment_file)
trim(input_path, tstart, tstop, segment_path, **kwargs)
print('Trimmed segment %03i of %s (from %s to %s).' % (i, filename, tstart, tstop))
segment_paths.append(segment_path)
return segment_paths
[docs]def concatFileList(concat_path, output_path, overwrite=None):
"""Take a file list for the ffmpeg concat demuxer and save to
:code:`output_path`. The concat file (located at :code:`concat_path`)
must contain lines of the form::
file '/path/to/file1'
file '/path/to/file2'
file '/path/to/file3'
This avoids a re-encode and can be used with formats that do not support
file level concatenation.
Arguments:
concat_path: the filepath containing the list of media files to
concatenate
output_path: the filepath at which to write the result of the
concatenation
Keyword Arguments:
overwrite: If :code:`True`, overwrites the :code:`output_path` with no
prompt. If :code:`False`, the function will fail if the
:code:`output_path` exists. Defaults to :code:`None`
(prompts user for input)
Returns:
:code:`output_path` if successful or :code:`None`.
"""
command = 'ffmpeg -safe 0 -f concat -i "%s" -c copy' % concat_path
if overwrite is not None:
command += ' -y ' if overwrite==True else ' -n '
command += ' "%s"' % output_path
print(command)
try:
p = _agnostic_Popen(command)
stdout, stderr = p.communicate()
return output_path
except Exception as e:
print(e)
return None
[docs]def concatSegments(segment_paths, output_path=None, overwrite=None):
"""Concatenate a list of inputs (:code:`segment_paths`) using the ffmpeg
concat demuxer. A concat file will be created of the form::
file '/path/to/file1'
file '/path/to/file2'
file '/path/to/file3'
This avoids a re-encode and can be used with formats that do not support file level concatenation.
Arguments:
segment_paths: A list of filepaths to concatenate
Keyword Arguments:
output_path: the filepath at which to write the concat file. If left
as the default (:code:`None`) it appends :code:`_concat`
to the end of the filename and preserves the input file extension.
overwrite: If :code:`True`, overwrites the :code:`output_path` with no
prompt. If :code:`False`, the function will fail if the
:code:`output_path` exists. Defaults to :code:`None`
(prompts user for input).
Returns:
The :code:`output_path` where the output of ffmpeg was written.
"""
folder, first_path = os.path.split(segment_paths[0])
first_prefix, file_extension = os.path.splitext(first_path)
filename_prefix = '_'.join(first_prefix.split('_')[:-1])
concat_file = ''.join(filename_prefix) + '_concat.txt'
concat_path = os.path.join(folder, concat_file)
if not os.path.exists(concat_path) or overwrite:
with open(concat_path, 'w') as f:
f.write('\n'.join(["file '%s'" % path for path in segment_paths]))
if not output_path:
output_path = os.path.join(folder, filename_prefix + '_concat' + file_extension)
return concatFileList(concat_path, output_path, overwrite)
[docs]def silenceFilterGraph(silences, factor, delay=0.25, audio_rate=44100, hasten_audio=None, silent_volume=1.0,
v_in='[0:v]', a_in='[0:a]', v_out='[v]', a_out='[a]'):
"""Generate a filtergraph string (for processing with the -filter_complex
flag of ffmpeg) using the trim and atrim filters to speed up periods in the
video designated by a list of silence dictionaries, where each silence dictionary contains keys::
silence_start: the timestamp of the detected silent interval in seconds
silence_end: the timestamp of the detected silent interval in seconds
silence_duration: duration of the silent interval in seconds
Arguments:
silences: A list of silence dictionaries generated from getSilences
factor: to speed up video during (a subset of) each silent interval
Keyword arguments:
delay: to omit from silent intervals when changing speed (default 0.25s)
audio_rate: Sample rate of audio input (in Hz, default 44100) used in
asetrate/aresample filters when hasten_audio=True
hasten_audio: None, 'pitch' or 'tempo'. Speed up audio during silent
segment by either increasing pitch (with asetrate and
aresample filters) or tempo (with atempo filter).
silent_volume: scale the volume during silent segments (default 1.0;
no scaling)
v_in: The named filtergraph video input pad. Defaults to :code:`[0:v]`
(see the `FFmpeg filter documentation`_).
a_in: The named filtergraph audio input pad. Defaults to :code:`[0:a]`
(see the `FFmpeg filter documentation`_).
v_out: The named filtergraph video output pad. Defaults to :code:`[v]`
(see the `FFmpeg filter documentation`_).
a_out: The named filtergraph audio output pad. Defaults to :code:`[a]`
(see the `FFmpeg filter documentation`_).
Returns:
The generated filtergraph as a string
.. _`FFmpeg filter documentation`: http://ffmpeg.org/ffmpeg-filters.html#Filtergraph-syntax-1
"""
# Omit silences at the start/end of the file
if len(silences) > 0:
if 'silence_end' not in silences[-1]:
silences = silences[:-1]
if len(silences) > 0:
if silences[0]['silence_start'] <= 0.:
silences = silences[1:]
# Timestamp of end of most recently processed segment
tf_last = 0
# Container for calls to trim (video) and atrim (audio) filters
vstrings = []
astrings = []
# String to call concat filter with
concat_string = ''
# Number of silences to process
n = len(silences)
# Number of segments (only because any beginning/end silences are gone)
n_segs = 2*n + 1
# Generate 4 x filtergraph lines for each silence
for i, s in enumerate(silences):
# Number segments in filtergraph from 1 to n_segs
i += 1
# Cast end of last segment to string
t0 = '%.4f' % tf_last
# Begin trim (& speedup) delay seconds after silence_start
ti = '%.4f' % (s['silence_start'] + delay)
# End trim (& speedup) delay seconds before silence_end
tf = '%.4f' % (s['silence_end'] - delay)
# Predicted duration of sped up segment based on above and factor
ta = '%.4f' % (s['silence_start'] + delay + (s['silence_duration'] - 2*delay)/factor)
# Trim video before this silence (regular speed)
vstrings.append('%strim=%s:%s,setpts=PTS-STARTPTS[v%i];' % (v_in, t0, ti, (2*i-1)))
# Trim video during this silence and speed up using setpts
vstrings.append('%strim=%s:%s,setpts=(PTS-STARTPTS)/%i[v%i];' % (v_in, ti, tf, factor, (2*i)))
# Trim video before this silence (regular speed)
astrings.append('%satrim=%s:%s,asetpts=PTS-STARTPTS[a%i];' % (a_in, t0, ti, (2*i-1)))
if hasten_audio == 'pitch':
# Speed up audio during silent segment with asetrate and aresample filters (increases pitch)
astrings.append('%satrim=%s:%s,asetpts=PTS-STARTPTS,asetrate=%i,aresample=%i,volume=%.3f[a%i];' % (a_in, ti, tf, (factor*audio_rate), audio_rate, silent_volume, (2*i)))
elif hasten_audio == 'tempo':
# speed up audio during silent segment with atempo (increases tempo)
q = math.log(factor, 2)
tempos = ['atempo=2.0']*int(q)
if q != int(q):
tempos.append('atempo=%.3f/%d'%(factor, 2**int(q)))
tempo_str = ','.join(tempos)
astrings.append('%satrim=%s:%s,%s,volume=%.3f[a%i];' % (a_in, ti, tf, tempo_str, silent_volume, (2*i)))
else:
# Use first 1/factor samples of silence for audio (no pitch increase)
astrings.append('%satrim=%s:%s,asetpts=PTS-STARTPTS,volume=%.3f[a%i];' % (a_in, ti, ta, silent_volume, (2*i)))
# Append these streams to the concat filter input
concat_string += '[v%i][a%i][v%i][a%i]' % ((2*i-1), (2*i-1), (2*i), (2*i))
tf_last = s['silence_end'] - delay
# Trim the final segment (regular speed) without specifying the end time
vstrings.append('%strim=start=%.4f,setpts=PTS-STARTPTS[v%i];' % (v_in, tf_last, n_segs))
astrings.append('%satrim=start=%.4f,asetpts=PTS-STARTPTS[a%i];' % (a_in, tf_last, n_segs))
# Finish the concat filter call
concat_string += '[v%i][a%i]concat=n=%i:v=1:a=1%s%s;' % (n_segs, n_segs, n_segs, v_out, a_out)
# Collect lines of the filter script after the trim/atrim calls
return '\n'.join(vstrings + astrings + [concat_string])
[docs]def resizeFilterGraph(v_in='[0:v]', width=1920, height=1080, pad=True,
mode='decrease', v_out='[v]'):
"""Generate a filtergraph string (for processing with the -filter_complex
flag of ffmpeg) using the scale and pad filters to scale & pad the video
for width x height display, with optional padding.
Keyword arguments:
v_in: The named filtergraph video input pad. Defaults to :code:`[0:v]`
(see `FFmpeg filter documentation`_).
width: of display on which the output stream must fit (default 1920).
height: of display on which the output stream must fit (default 1080).
pad: add letter- or pillar-boxes to the output as required to fill
width x height.
mode: argument of ffmpeg scale filter (default 'decrease').
v_out: The named filtergraph video output pad. Defaults to :code:`[v]`
(see `FFmpeg filter documentation`_).
Returns:
The generated filtergraph as a string.
.. _`FFmpeg filter documentation`: http://ffmpeg.org/ffmpeg-filters.html#Filtergraph-syntax-1
"""
vstrings = []
v_scaled = '[scaled]' if pad else v_out
vstrings.append('%sscale=w=%i:h=%i:force_original_aspect_ratio=%s%s;' % (v_in, width, height, mode, v_scaled))
if pad:
vstrings.append('%spad=%s:%s:(ow-iw)/2:(oh-ih)/2%s;' % (v_scaled, width, height, v_out))
return '\n'.join(vstrings)
[docs]def panGainAudioGraph(a_in='[0:a]', duplicate_ch='left', gain=0, a_out='[a]'):
"""Generate a filtergraph string (for processing with the -filter_complex
flag of ffmpeg) using the pan and volume filters to duplicate audio from
one stereo channel to another, and optionally change the volume by gain.
Keyword arguments:
a_in: The named filtergraph audio input pad. Defaults to :code:`[0:a]`
(see `FFmpeg filter documentation`_).
duplicate_ch: 'left', 'right', or None/False specify whether to
duplicate a stereo channel of input audio stream
(default 'left').
gain: to apply (in dB) to the audio stream using the volume filter.
a_out: The named filtergraph audio output pad. Defaults to :code:`[a]`
(see `FFmpeg filter documentation`_).
Returns:
The generated filtergraph as a string.
.. _`FFmpeg filter documentation`: http://ffmpeg.org/ffmpeg-filters.html#Filtergraph-syntax-1
"""
head = a_in
tail = a_out + ';'
astrings = []
if isinstance(duplicate_ch, six.string_types):
if duplicate_ch.lower() == 'left':
# Duplicate left channel of input on right channel
astrings.append('pan=stereo|c0=c0|c1=c0')
if duplicate_ch.lower() == 'right':
# Duplicate right channel of input on left channel
astrings.append('pan=stereo|c0=c1|c1=c1')
if gain:
astrings.append('volume=%.1fdB' % gain)
if len(astrings):
return head + ','.join(astrings) + tail
else:
return None
[docs]def generateFilterGraph(silences, factor, delay=0.25, rescale=True, pan_audio='left', gain=0, audio_rate=44100, hasten_audio=None, silent_volume=1.0):
"""Generate a filtergraph string (for processing with the -filter_complex
flag of ffmpeg) using the trim and atrim filters to speed up periods in the
video designated by a list of silence dictionaries. This function calls :func:`autoscrub.silenceFilterGraph`, :func:`autoscrub.resizeFilterGraph` and :func:`panGainAudioGraph` as appropriate.
Arguments:
silences: A list of silence dictionaries generated from :func:`autoscrub.getSilences`
factor: to speed up video during (a subset of) each silent interval
Keyword arguments:
delay: to omit from silent intervals when changing speed (default 0.25s)
rescale: Scale and pad the video (pillar- or letter-box as required) for
1920 x 1080 display (default True)
pan_audio: 'left', 'right', or None/False specify whether to duplicate a
stereo channel of input audio stream (default 'left')
gain: in dB to apply when pan_audio is 'left' or 'right'
audio_rate: Sample rate of audio input (in Hz, default 44100) used in
asetrate/aresample filters when :code:`hasten_audio=True`.
hasten_audio: None, 'pitch' or 'tempo'. Speed up audio during silent
segment by either increasing pitch (with asetrate and
aresample filters) or tempo (with atempo filter).
silent_volume: scale the volume during silent segments (default 1.0;
no scaling).
Returns:
The generated filtergraph as a string.
"""
filter_graph = silenceFilterGraph(silences, factor, audio_rate=audio_rate, hasten_audio=hasten_audio, silent_volume=silent_volume, delay=delay,
v_out='[vn]' if rescale else '[v]', a_out='[an]' if gain or pan_audio else '[a]')
if rescale:
filter_graph += '\n' + resizeFilterGraph(v_in='[vn]')
if pan_audio or gain:
filter_graph += '\n' + panGainAudioGraph(a_in='[an]', duplicate_ch=pan_audio, gain=gain)
if filter_graph.endswith(';'):
filter_graph = filter_graph[:-1]
return filter_graph
[docs]def writeFilterGraph(filter_script_path, silences, factor, **kwargs):
"""Generates a filtergraph string (using :func:`autoscrub.generateFilterGraph`) and writes it to a file.
Arguments:
filter_script_path: Path to save the filter script .
silences: A list of silence dictionaries generated from :func:`autoscrub.getSilences`.
factor: to speed up video during (a subset of) each silent interval.
Keyword Arguments:
kwargs: Accepts keyword arguments of :func:`autoscrub.generateFilterGraph`.
Returns:
:code:`None`
"""
filter_graph = generateFilterGraph(silences, factor, **kwargs)
with open(filter_script_path, 'w') as f:
f.write(filter_graph)
[docs]def ffmpegComplexFilter(input_path, filter_script_path, output_path=NUL, run_command=True, overwrite=None):
"""Executes the ffmpeg command and processes a complex filter
Prepare and execute (if run_command) ffmpeg command for processing
input_path with an ffmpeg filter_complex string (filtergraph) in
filter_script_path, and save to output_path. As this requires re-encoding,
video and audio settings are chosen to be compliant with YouTube's
'streamable content' specifications, available at (as of April 2017)
https://support.google.com/youtube/answer/1722171
Arguments:
input_path: The path to the video file to process.
filter_script_path: The path to the filter script.
Keyword Arguments:
output_path: The path to save the processed video (defaults to
os.devnull).
run_command: If False, simply prepare and return the command for
debugging or later use (default: True).
overwrite: Optionally specify addition of -y or -n flag to ffmpeg
which is useful for unattended scripting (default None).
Returns:
:code:`output_path` if :code:`run_command=True` otherwise returns the fully formatted ffmpeg command to run as a string.
"""
header = 'ffmpeg -i "%s"' % input_path
youtube_video = '-c:v libx264 -crf 20 -bf 2 -flags +cgop -g 15 -pix_fmt yuv420p -movflags +faststart' # -tune stillimage
youtube_audio = '-c:a aac -r:a 48000 -b:a 192k'
youtube_other = '-strict -2'
filter_command = '-filter_complex_script "%s" -map [v] -map [a]' % filter_script_path
tail = '"%s"' % output_path
if overwrite is not None:
tail = ('-y ' + tail) if overwrite==True else ('-n ' + tail)
command_list = [header, youtube_video, youtube_audio, youtube_other, filter_command, tail]
command = ' '.join(command_list)
print(command)
if run_command:
p = _agnostic_Popen(command)
stdout, stderr = p.communicate()
return output_path
else:
return command
if __name__ == '__main__':
# Loudness normalisation
target_lufs = -18.0
# Silence detection
target_threshold_dB = -18.0 # should be close or equal to above
silence_duration = 2.0 # should be greater than or equal to 2 (seconds)
# Filepaths
# input_path = 'lecture.mp4'
# input_path = "C:\\Users\\russ\\Documents\\Teaching\\PHS3051\\LectureRecordings\\2017\\Lecture2\\ModernOpticsLecture2.trec"
# input_path = "C:\\Users\\russ\\Documents\\Teaching\\PHS3051\\LectureRecordings\\2017\\Lecture3\\ModernOpticsLecture3.trec"
# input_path = "C:\\Users\\rander\\Documents\\Teaching\\PHS3051Optics\\LectureRecordings\\2017\\Lecture4\\ModernOpticsLecture4.trec"
# input_path = "C:\\Users\\rander\\Documents\\Teaching\\PHS3051Optics\\LectureRecordings\\2017\\Lecture5\\ModernOpticsLecture5.trec"
input_path = "C:\\Users\\rander\\Documents\\Teaching\\PHS3051Optics\\LectureRecordings\\2017\\Lecture6\\ModernOpticsLecture6.trec"
suffix = 'scrub'
# Flags
overwrite = True
run_command = True
rescale = True
# pan_audio = False
pan_audio = 'left'
factor = 8
hasten_audio = 'tempo'
# Implementation
folder, filename = os.path.split(input_path)
filename_prefix, file_extension = os.path.splitext(filename)
output_path = '%s_%s.mp4' % (filename_prefix, suffix)
filter_script_path = '%s.filter-script' % filename_prefix
if folder is not '':
os.chdir(folder)
if not os.path.exists(filter_script_path) or overwrite:
print('============ Processing %s ==========' % filename)
print('\nGetting audio sample rate...')
input_sample_rate = getSampleRate(filename)
print("Measured sample rate = %d Hz"%input_sample_rate)
print('\nChecking loudness of file...')
loudness = getLoudness(filename)
input_lufs = loudness['I']
gain = target_lufs - input_lufs
# Apply gain correction if pan_audio is used (when one stereo channel is silent)
if pan_audio:
gain -= 3
input_threshold_dB = input_lufs + target_threshold_dB - target_lufs
print('Measured loudness = %.1f LUFS; Silence threshold = %.1f dB; Gain to apply = %.1f dB' % (input_lufs, input_threshold_dB, gain))
print('\nSearching for silence...')
silences = getSilences(filename, input_threshold_dB, silence_duration)
durations = [s['silence_duration'] for s in silences if 'silence_duration' in s]
mean_duration = sum(durations)/len(durations)
print('Found %i silences of average duration %.1f seconds.' % (len(silences), mean_duration))
print('\nGenerating ffmpeg filter_complex script...')
writeFilterGraph(filter_script_path, silences, factor=factor, audio_rate=input_sample_rate, pan_audio=pan_audio, gain=gain, rescale=rescale, hasten_audio=hasten_audio)
else:
print('\nUsing existing filter_complex script....')
print('\nRequired ffmpeg command:')
result = ffmpegComplexFilter(input_path, filter_script_path, output_path, run_command, overwrite)