Merge pull request #2726 from lbryio/add_video_transcoding
added video file validation and optimization when publishing streams (using `--validate_file` and `--optimize_file` arguments)
This commit is contained in:
commit
d745c04fe6
9 changed files with 612 additions and 6 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -5,6 +5,7 @@
|
|||
/.tox
|
||||
/.coverage*
|
||||
/lbry-venv
|
||||
/venv
|
||||
|
||||
lbry.egg-info
|
||||
__pycache__
|
||||
|
@ -13,5 +14,4 @@ _trial_temp/
|
|||
/tests/integration/blockchain/files
|
||||
/tests/.coverage.*
|
||||
|
||||
/lbry/wallet/bin
|
||||
/venv
|
||||
/lbry/wallet/bin
|
|
@ -46,6 +46,8 @@ test:datanetwork-integration:
|
|||
test:blockchain-integration:
|
||||
stage: test
|
||||
script:
|
||||
- apt-get update
|
||||
- apt-get install -y --no-install-recommends ffmpeg
|
||||
- pip install tox-travis
|
||||
- tox -e blockchain
|
||||
|
||||
|
|
15
lbry/conf.py
15
lbry/conf.py
|
@ -462,7 +462,20 @@ class BaseConfig:
|
|||
self.persisted.save()
|
||||
|
||||
|
||||
class CLIConfig(BaseConfig):
|
||||
class TranscodeConfig(BaseConfig):
|
||||
|
||||
ffmpeg_folder = String('The path to ffmpeg and ffprobe', '')
|
||||
video_encoder = String('FFmpeg codec and parameters for the video encoding. '
|
||||
'Example: libaom-av1 -crf 25 -b:v 0 -strict experimental',
|
||||
'libx264 -crf 18 -vf "format=yuv420p"')
|
||||
audio_encoder = String('FFmpeg codec and parameters for the audio encoding. '
|
||||
'Example: libopus -b:a 128k',
|
||||
'aac -b:a 192k')
|
||||
volume_filter = String('FFmpeg filter for audio normalization.', '-af loudnorm')
|
||||
volume_analysis_time = Integer('Maximum seconds into the file that we examine audio volume (0 to disable).', '240')
|
||||
|
||||
|
||||
class CLIConfig(TranscodeConfig):
|
||||
|
||||
api = String('Host name and port for lbrynet daemon API.', 'localhost:5279', metavar='HOST:PORT')
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import sys
|
|||
import shutil
|
||||
import signal
|
||||
import pathlib
|
||||
import platform
|
||||
import json
|
||||
import asyncio
|
||||
import argparse
|
||||
|
@ -262,6 +263,9 @@ def setup_logging(logger: logging.Logger, args: argparse.Namespace, conf: Config
|
|||
|
||||
|
||||
def run_daemon(args: argparse.Namespace, conf: Config):
|
||||
if sys.version_info < (3, 8) and platform.system() == "Windows":
|
||||
# TODO: remove after we move to requiring Python 3.8
|
||||
asyncio.set_event_loop(asyncio.ProactorEventLoop())
|
||||
loop = asyncio.get_event_loop()
|
||||
if args.verbose is not None:
|
||||
loop.set_debug(True)
|
||||
|
|
|
@ -44,6 +44,7 @@ from lbry.extras.daemon.componentmanager import ComponentManager
|
|||
from lbry.extras.daemon.json_response_encoder import JSONResponseEncoder
|
||||
from lbry.extras.daemon import comment_client
|
||||
from lbry.extras.daemon.undecorated import undecorated
|
||||
from lbry.file_analysis import VideoFileAnalyzer
|
||||
from lbry.schema.claim import Claim
|
||||
from lbry.schema.url import URL
|
||||
|
||||
|
@ -296,6 +297,7 @@ class Daemon(metaclass=JSONRPCServerType):
|
|||
|
||||
def __init__(self, conf: Config, component_manager: typing.Optional[ComponentManager] = None):
|
||||
self.conf = conf
|
||||
self._video_file_analyzer = VideoFileAnalyzer(conf)
|
||||
self._node_id = None
|
||||
self._installation_id = None
|
||||
self.session_id = base58.b58encode(utils.generate_id()).decode()
|
||||
|
@ -851,6 +853,7 @@ class Daemon(metaclass=JSONRPCServerType):
|
|||
"""
|
||||
|
||||
connection_code = await self.get_connection_status()
|
||||
ffmpeg_status = await self._video_file_analyzer.status()
|
||||
|
||||
response = {
|
||||
'installation_id': self.installation_id,
|
||||
|
@ -861,6 +864,7 @@ class Daemon(metaclass=JSONRPCServerType):
|
|||
'code': connection_code,
|
||||
'message': CONNECTION_MESSAGES[connection_code],
|
||||
},
|
||||
'ffmpeg_status': ffmpeg_status
|
||||
}
|
||||
for component in self.component_manager.components:
|
||||
status = await component.get_status()
|
||||
|
@ -2808,6 +2812,7 @@ class Daemon(metaclass=JSONRPCServerType):
|
|||
|
||||
Usage:
|
||||
publish (<name> | --name=<name>) [--bid=<bid>] [--file_path=<file_path>]
|
||||
[--validate_file] [--optimize_file]
|
||||
[--fee_currency=<fee_currency>] [--fee_amount=<fee_amount>] [--fee_address=<fee_address>]
|
||||
[--title=<title>] [--description=<description>] [--author=<author>]
|
||||
[--tags=<tags>...] [--languages=<languages>...] [--locations=<locations>...]
|
||||
|
@ -2823,6 +2828,11 @@ class Daemon(metaclass=JSONRPCServerType):
|
|||
--name=<name> : (str) name of the content (can only consist of a-z A-Z 0-9 and -(dash))
|
||||
--bid=<bid> : (decimal) amount to back the claim
|
||||
--file_path=<file_path> : (str) path to file to be associated with name.
|
||||
--validate_file : (bool) validate that the video container and encodings match
|
||||
common web browser support or that optimization succeeds if specified.
|
||||
FFmpeg is required
|
||||
--optimize_file : (bool) transcode the video & audio if necessary to ensure
|
||||
common web browser support. FFmpeg is required
|
||||
--fee_currency=<fee_currency> : (string) specify fee currency
|
||||
--fee_amount=<fee_amount> : (decimal) content download fee
|
||||
--fee_address=<fee_address> : (str) address where to send fee payments, will use
|
||||
|
@ -2994,12 +3004,13 @@ class Daemon(metaclass=JSONRPCServerType):
|
|||
self, name, bid, file_path, allow_duplicate_name=False,
|
||||
channel_id=None, channel_name=None, channel_account_id=None,
|
||||
account_id=None, wallet_id=None, claim_address=None, funding_account_ids=None,
|
||||
preview=False, blocking=False, **kwargs):
|
||||
preview=False, blocking=False, validate_file=False, optimize_file=False, **kwargs):
|
||||
"""
|
||||
Make a new stream claim and announce the associated file to lbrynet.
|
||||
|
||||
Usage:
|
||||
stream_create (<name> | --name=<name>) (<bid> | --bid=<bid>) (<file_path> | --file_path=<file_path>)
|
||||
[--validate_file] [--optimize_file]
|
||||
[--allow_duplicate_name=<allow_duplicate_name>]
|
||||
[--fee_currency=<fee_currency>] [--fee_amount=<fee_amount>] [--fee_address=<fee_address>]
|
||||
[--title=<title>] [--description=<description>] [--author=<author>]
|
||||
|
@ -3016,6 +3027,11 @@ class Daemon(metaclass=JSONRPCServerType):
|
|||
--name=<name> : (str) name of the content (can only consist of a-z A-Z 0-9 and -(dash))
|
||||
--bid=<bid> : (decimal) amount to back the claim
|
||||
--file_path=<file_path> : (str) path to file to be associated with name.
|
||||
--validate_file : (bool) validate that the video container and encodings match
|
||||
common web browser support or that optimization succeeds if specified.
|
||||
FFmpeg is required
|
||||
--optimize_file : (bool) transcode the video & audio if necessary to ensure
|
||||
common web browser support. FFmpeg is required
|
||||
--allow_duplicate_name=<allow_duplicate_name> : (bool) create new claim even if one already exists with
|
||||
given name. default: false.
|
||||
--fee_currency=<fee_currency> : (string) specify fee currency
|
||||
|
@ -3107,6 +3123,8 @@ class Daemon(metaclass=JSONRPCServerType):
|
|||
f"Use --allow-duplicate-name flag to override."
|
||||
)
|
||||
|
||||
file_path = await self._video_file_analyzer.verify_or_repair(validate_file, optimize_file, file_path)
|
||||
|
||||
claim = Claim()
|
||||
claim.stream.update(file_path=file_path, sd_hash='0' * 96, **kwargs)
|
||||
tx = await Transaction.claim_create(
|
||||
|
|
355
lbry/file_analysis.py
Normal file
355
lbry/file_analysis.py
Normal file
|
@ -0,0 +1,355 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
|
||||
from lbry.conf import TranscodeConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoFileAnalyzer:
|
||||
|
||||
def __init__(self, conf: TranscodeConfig):
|
||||
self._conf = conf
|
||||
self._available_encoders = ""
|
||||
self._ffmpeg_installed = False
|
||||
self._which = None
|
||||
|
||||
async def _execute(self, command, arguments):
|
||||
args = shlex.split(arguments)
|
||||
process = await asyncio.create_subprocess_exec(self._conf.ffmpeg_folder + command, *args,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await process.communicate() # returns when the streams are closed
|
||||
return stdout.decode() + stderr.decode(), process.returncode
|
||||
|
||||
async def _verify_executable(self, name):
|
||||
try:
|
||||
version, code = await self._execute(name, "-version")
|
||||
except Exception as e:
|
||||
log.warning("Unable to run %s, but it was requested. Message: %s", name, str(e))
|
||||
code = -1
|
||||
version = ""
|
||||
if code != 0 or not version.startswith(name):
|
||||
raise FileNotFoundError(f"Unable to locate or run {name}. Please install FFmpeg "
|
||||
f"and ensure that it is callable via PATH or conf.ffmpeg_folder")
|
||||
return version
|
||||
|
||||
async def _verify_ffmpeg_installed(self):
|
||||
if self._ffmpeg_installed:
|
||||
return
|
||||
await self._verify_executable("ffprobe")
|
||||
version = await self._verify_executable("ffmpeg")
|
||||
self._which = shutil.which(f"{self._conf.ffmpeg_folder}ffmpeg")
|
||||
self._ffmpeg_installed = True
|
||||
log.debug("Using %s at %s", version.splitlines()[0].split(" Copyright")[0], self._which)
|
||||
|
||||
async def status(self, reset=False):
|
||||
if reset:
|
||||
self._available_encoders = ""
|
||||
self._ffmpeg_installed = False
|
||||
self._which = None
|
||||
|
||||
installed = True
|
||||
try:
|
||||
await self._verify_ffmpeg_installed()
|
||||
except FileNotFoundError:
|
||||
installed = False
|
||||
|
||||
return {
|
||||
"available": installed,
|
||||
"which": self._which,
|
||||
"analyze_audio_volume": int(self._conf.volume_analysis_time) > 0
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _verify_container(scan_data: json):
|
||||
container = scan_data["format"]["format_name"]
|
||||
log.debug(" Detected container is %s", container)
|
||||
if not {"webm", "mp4", "3gp", "ogg"}.intersection(container.split(",")):
|
||||
return "Container format is not in the approved list of WebM, MP4. " \
|
||||
f"Actual: {container} [{scan_data['format']['format_long_name']}]"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _verify_video_encoding(scan_data: json):
|
||||
for stream in scan_data["streams"]:
|
||||
if stream["codec_type"] != "video":
|
||||
continue
|
||||
codec = stream["codec_name"]
|
||||
log.debug(" Detected video codec is %s, format is %s", codec, stream["pix_fmt"])
|
||||
if not {"h264", "vp8", "vp9", "av1", "theora"}.intersection(codec.split(",")):
|
||||
return "Video codec is not in the approved list of H264, VP8, VP9, AV1, Theora. " \
|
||||
f"Actual: {codec} [{stream['codec_long_name']}]"
|
||||
|
||||
if "h264" in codec.split(",") and stream["pix_fmt"] != "yuv420p":
|
||||
return "Video codec is H264, but its pixel format does not match the approved yuv420p. " \
|
||||
f"Actual: {stream['pix_fmt']}"
|
||||
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _verify_bitrate(scan_data: json):
|
||||
if "bit_rate" not in scan_data["format"]:
|
||||
return ""
|
||||
|
||||
bit_rate = float(scan_data["format"]["bit_rate"])
|
||||
log.debug(" Detected bitrate is %s Mbps", str(bit_rate / 1000000.0))
|
||||
pixels = -1.0
|
||||
for stream in scan_data["streams"]:
|
||||
if stream["codec_type"] == "video":
|
||||
pieces = stream["r_frame_rate"].split('/', 1)
|
||||
frame_rate = float(pieces[0]) if len(pieces) == 1 \
|
||||
else float(pieces[0]) / float(pieces[1])
|
||||
pixels = max(pixels, float(stream["height"]) * float(stream["width"]) * frame_rate)
|
||||
|
||||
if pixels > 0.0 and pixels / bit_rate < 3.0:
|
||||
return "Bits per second is excessive for this data; this may impact web streaming performance. " \
|
||||
f"Actual: {str(bit_rate / 1000000.0)} Mbps"
|
||||
|
||||
return ""
|
||||
|
||||
async def _verify_fast_start(self, scan_data: json, video_file):
|
||||
container = scan_data["format"]["format_name"]
|
||||
if {"webm", "ogg"}.intersection(container.split(",")):
|
||||
return ""
|
||||
|
||||
result, _ = await self._execute("ffprobe", f'-v debug "{video_file}"')
|
||||
iterator = re.finditer(r"\s+seeks:(\d+)\s+", result)
|
||||
for match in iterator:
|
||||
if int(match.group(1)) != 0:
|
||||
return "Video stream descriptors are not at the start of the file (the faststart flag was not used)."
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _verify_audio_encoding(scan_data: json):
|
||||
for stream in scan_data["streams"]:
|
||||
if stream["codec_type"] != "audio":
|
||||
continue
|
||||
codec = stream["codec_name"]
|
||||
log.debug(" Detected audio codec is %s", codec)
|
||||
if not {"aac", "mp3", "flac", "vorbis", "opus"}.intersection(codec.split(",")):
|
||||
return "Audio codec is not in the approved list of AAC, FLAC, MP3, Vorbis, and Opus. " \
|
||||
f"Actual: {codec} [{stream['codec_long_name']}]"
|
||||
|
||||
return ""
|
||||
|
||||
async def _verify_audio_volume(self, seconds, video_file):
|
||||
try:
|
||||
validate_volume = int(seconds) > 0
|
||||
except ValueError:
|
||||
validate_volume = False
|
||||
|
||||
if not validate_volume:
|
||||
return ""
|
||||
|
||||
result, _ = await self._execute("ffmpeg", f'-i "{video_file}" -t {seconds} '
|
||||
f'-af volumedetect -vn -sn -dn -f null "{os.devnull}"')
|
||||
try:
|
||||
mean_volume = float(re.search(r"mean_volume:\s+([-+]?\d*\.\d+|\d+)", result).group(1))
|
||||
max_volume = float(re.search(r"max_volume:\s+([-+]?\d*\.\d+|\d+)", result).group(1))
|
||||
except Exception as e:
|
||||
log.debug(" Failure in volume analysis. Message: %s", str(e))
|
||||
return ""
|
||||
|
||||
if max_volume < -5.0 and mean_volume < -22.0:
|
||||
return "Audio is at least five dB lower than prime. " \
|
||||
f"Actual max: {max_volume}, mean: {mean_volume}"
|
||||
|
||||
log.debug(" Detected audio volume has mean, max of %f, %f dB", mean_volume, max_volume)
|
||||
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _compute_crf(scan_data):
|
||||
height = 240.0
|
||||
for stream in scan_data["streams"]:
|
||||
if stream["codec_type"] == "video":
|
||||
height = max(height, float(stream["height"]))
|
||||
|
||||
# https://developers.google.com/media/vp9/settings/vod/
|
||||
return int(-0.011 * height + 40)
|
||||
|
||||
async def _get_video_encoder(self, scan_data):
|
||||
# use what the user said if it's there:
|
||||
# if it's not there, use h264 if we can because it's way faster than the others
|
||||
# if we don't have h264 use vp9; it's fairly compatible even though it's slow
|
||||
|
||||
if not self._available_encoders:
|
||||
self._available_encoders, _ = await self._execute("ffmpeg", "-encoders -v quiet")
|
||||
|
||||
encoder = self._conf.video_encoder.split(" ", 1)[0]
|
||||
if re.search(fr"^\s*V..... {encoder} ", self._available_encoders, re.MULTILINE):
|
||||
return self._conf.video_encoder
|
||||
|
||||
if re.search(r"^\s*V..... libx264 ", self._available_encoders, re.MULTILINE):
|
||||
if encoder:
|
||||
log.warning(" Using libx264 since the requested encoder was unavailable. Requested: %s", encoder)
|
||||
return 'libx264 -crf 19 -vf "format=yuv420p"'
|
||||
|
||||
if not encoder:
|
||||
encoder = "libx264"
|
||||
|
||||
if re.search(r"^\s*V..... libvpx-vp9 ", self._available_encoders, re.MULTILINE):
|
||||
log.warning(" Using libvpx-vp9 since the requested encoder was unavailable. Requested: %s", encoder)
|
||||
crf = self._compute_crf(scan_data)
|
||||
return f"libvpx-vp9 -crf {crf} -b:v 0"
|
||||
|
||||
if re.search(r"^\s*V..... libtheora", self._available_encoders, re.MULTILINE):
|
||||
log.warning(" Using libtheora since the requested encoder was unavailable. Requested: %s", encoder)
|
||||
return "libtheora -q:v 7"
|
||||
|
||||
raise Exception(f"The video encoder is not available. Requested: {encoder}")
|
||||
|
||||
async def _get_audio_encoder(self, extension):
|
||||
# if the video encoding is theora or av1/vp8/vp9 use opus (or fallback to vorbis)
|
||||
# or we don't have a video encoding but we have an ogg or webm container use opus
|
||||
# if we need to use opus/vorbis see if the conf file has it else use our own params
|
||||
# else use the user-set value if it exists
|
||||
# else use aac
|
||||
|
||||
wants_opus = extension != "mp4"
|
||||
if not self._available_encoders:
|
||||
self._available_encoders, _ = await self._execute("ffmpeg", "-encoders -v quiet")
|
||||
|
||||
encoder = self._conf.audio_encoder.split(" ", 1)[0]
|
||||
if wants_opus and 'opus' in encoder:
|
||||
return self._conf.audio_encoder
|
||||
|
||||
if wants_opus and re.search(r"^\s*A..... libopus ", self._available_encoders, re.MULTILINE):
|
||||
return "libopus -b:a 160k"
|
||||
|
||||
if wants_opus and 'vorbis' in encoder:
|
||||
return self._conf.audio_encoder
|
||||
|
||||
if wants_opus and re.search(r"^\s*A..... libvorbis ", self._available_encoders, re.MULTILINE):
|
||||
return "libvorbis -q:a 6"
|
||||
|
||||
if re.search(fr"^\s*A..... {encoder} ", self._available_encoders, re.MULTILINE):
|
||||
return self._conf.audio_encoder
|
||||
|
||||
if re.search(r"^\s*A..... aac ", self._available_encoders, re.MULTILINE):
|
||||
return "aac -b:a 192k"
|
||||
|
||||
raise Exception(f"The audio encoder is not available. Requested: {encoder or 'aac'}")
|
||||
|
||||
async def _get_volume_filter(self):
|
||||
return self._conf.volume_filter if self._conf.volume_filter else "-af loudnorm"
|
||||
|
||||
@staticmethod
|
||||
def _get_best_container_extension(scan_data, video_encoder):
|
||||
# the container is chosen by the video format
|
||||
# if we are theora-encoded, we want ogg
|
||||
# if we are vp8/vp9/av1 we want webm
|
||||
# use mp4 for anything else
|
||||
|
||||
if not video_encoder: # not re-encoding video
|
||||
for stream in scan_data["streams"]:
|
||||
if stream["codec_type"] != "video":
|
||||
continue
|
||||
codec = stream["codec_name"].split(",")
|
||||
if "theora" in codec:
|
||||
return "ogg"
|
||||
if {"vp8", "vp9", "av1"}.intersection(codec):
|
||||
return "webm"
|
||||
|
||||
if "theora" in video_encoder:
|
||||
return "ogg"
|
||||
elif re.search(r"vp[89x]|av1", video_encoder.split(" ", 1)[0]):
|
||||
return "webm"
|
||||
return "mp4"
|
||||
|
||||
async def _get_scan_data(self, validate, file_path):
|
||||
result, _ = await self._execute("ffprobe",
|
||||
f'-v quiet -print_format json -show_format -show_streams "{file_path}"')
|
||||
try:
|
||||
scan_data = json.loads(result)
|
||||
except Exception as e:
|
||||
log.debug("Failure in JSON parsing ffprobe results. Message: %s", str(e))
|
||||
if validate:
|
||||
raise Exception(f'Invalid video file: {file_path}')
|
||||
log.info("Unable to optimize %s . FFmpeg output was unreadable.", file_path)
|
||||
return
|
||||
|
||||
if "format" not in scan_data:
|
||||
if validate:
|
||||
raise FileNotFoundError(f'Unexpected or absent video file contents at: {file_path}')
|
||||
log.info("Unable to optimize %s . FFmpeg output is missing the format section.", file_path)
|
||||
return
|
||||
|
||||
return scan_data
|
||||
|
||||
async def verify_or_repair(self, validate, repair, file_path):
|
||||
if not validate and not repair:
|
||||
return file_path
|
||||
|
||||
await self._verify_ffmpeg_installed()
|
||||
scan_data = await self._get_scan_data(validate, file_path)
|
||||
|
||||
fast_start_msg = await self._verify_fast_start(scan_data, file_path)
|
||||
log.debug("Analyzing %s:", file_path)
|
||||
log.debug(" Detected faststart is %s", "false" if fast_start_msg else "true")
|
||||
container_msg = self._verify_container(scan_data)
|
||||
bitrate_msg = self._verify_bitrate(scan_data)
|
||||
video_msg = self._verify_video_encoding(scan_data)
|
||||
audio_msg = self._verify_audio_encoding(scan_data)
|
||||
volume_msg = await self._verify_audio_volume(self._conf.volume_analysis_time, file_path)
|
||||
messages = [container_msg, bitrate_msg, fast_start_msg, video_msg, audio_msg, volume_msg]
|
||||
|
||||
if not any(messages):
|
||||
return file_path
|
||||
|
||||
if not repair:
|
||||
errors = ["Streamability verification failed:"]
|
||||
errors.extend(filter(None, messages))
|
||||
raise Exception("\n ".join(errors))
|
||||
|
||||
# the plan for transcoding:
|
||||
# we have to re-encode the video if it is in a nonstandard format
|
||||
# we also re-encode if we are h264 but not yuv420p (both errors caught in video_msg)
|
||||
# we also re-encode if our bitrate is too high
|
||||
|
||||
try:
|
||||
transcode_command = [f'-i "{file_path}" -y -c:s copy -c:d copy -c:v']
|
||||
|
||||
video_encoder = ""
|
||||
if video_msg or bitrate_msg:
|
||||
video_encoder = await self._get_video_encoder(scan_data)
|
||||
transcode_command.append(video_encoder)
|
||||
else:
|
||||
transcode_command.append("copy")
|
||||
|
||||
transcode_command.append("-movflags +faststart -c:a")
|
||||
path = pathlib.Path(file_path)
|
||||
extension = self._get_best_container_extension(scan_data, video_encoder)
|
||||
|
||||
if audio_msg or volume_msg:
|
||||
audio_encoder = await self._get_audio_encoder(extension)
|
||||
transcode_command.append(audio_encoder)
|
||||
if volume_msg:
|
||||
volume_filter = await self._get_volume_filter()
|
||||
transcode_command.append(volume_filter)
|
||||
else:
|
||||
transcode_command.append("copy")
|
||||
|
||||
# TODO: put it in a temp folder and delete it after we upload?
|
||||
output = path.parent / f"{path.stem}_fixed.{extension}"
|
||||
transcode_command.append(f'"{output}"')
|
||||
|
||||
ffmpeg_command = " ".join(transcode_command)
|
||||
log.info("Proceeding on transcode via: ffmpeg %s", ffmpeg_command)
|
||||
result, code = await self._execute("ffmpeg", ffmpeg_command)
|
||||
if code != 0:
|
||||
raise Exception(f"Failure to complete the transcode command. Output: {result}")
|
||||
except Exception as e:
|
||||
if validate:
|
||||
raise
|
||||
log.info("Unable to transcode %s . Message: %s", file_path, str(e))
|
||||
# TODO: delete partial output file here if it exists?
|
||||
return file_path
|
||||
|
||||
return str(output)
|
62
scripts/check_video.py
Executable file
62
scripts/check_video.py
Executable file
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import platform
|
||||
import sys
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
import lbry.wallet # needed to make the following line work (it's a bug):
|
||||
from lbry.conf import TranscodeConfig
|
||||
from lbry.file_analysis import VideoFileAnalyzer
|
||||
|
||||
|
||||
def enable_logging():
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.DEBUG)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
formatter = logging.Formatter('%(message)s') # %(asctime)s - %(levelname)s -
|
||||
handler.setFormatter(formatter)
|
||||
root.addHandler(handler)
|
||||
|
||||
|
||||
async def process_video(analyzer, video_file):
|
||||
try:
|
||||
await analyzer.verify_or_repair(True, False, video_file)
|
||||
print("No concerns. Ship it!")
|
||||
except FileNotFoundError as e:
|
||||
print(str(e))
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
transcode = input("Would you like to make a repaired clone now? [y/N] ")
|
||||
if transcode == "y":
|
||||
try:
|
||||
new_video_file = await analyzer.verify_or_repair(True, True, video_file)
|
||||
print("Successfully created ", new_video_file)
|
||||
except Exception as e:
|
||||
print("Unable to complete the transcode. Message: ", str(e))
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: check_video.py <path to video file>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
enable_logging()
|
||||
|
||||
video_file = sys.argv[1]
|
||||
conf = TranscodeConfig()
|
||||
analyzer = VideoFileAnalyzer(conf)
|
||||
if sys.version_info < (3, 8) and platform.system() == "Windows":
|
||||
# TODO: remove after we move to requiring Python 3.8
|
||||
asyncio.set_event_loop(asyncio.ProactorEventLoop())
|
||||
try:
|
||||
asyncio.run(process_video(analyzer, video_file))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
151
tests/integration/blockchain/test_transcoding.py
Normal file
151
tests/integration/blockchain/test_transcoding.py
Normal file
|
@ -0,0 +1,151 @@
|
|||
import logging
|
||||
import pathlib
|
||||
import time
|
||||
|
||||
from .test_claim_commands import ClaimTestCase
|
||||
from lbry.conf import TranscodeConfig
|
||||
from lbry.file_analysis import VideoFileAnalyzer
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MeasureTime:
|
||||
def __init__(self, text):
|
||||
print(text, end="...", flush=True)
|
||||
|
||||
def __enter__(self):
|
||||
self.start = time.perf_counter()
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
end = time.perf_counter()
|
||||
print(f" done in {end - self.start:.6f}s", flush=True)
|
||||
|
||||
|
||||
class TranscodeValidation(ClaimTestCase):
|
||||
|
||||
def make_name(self, name, extension=""):
|
||||
path = pathlib.Path(self.video_file_name)
|
||||
return path.parent / f"{path.stem}_{name}{extension or path.suffix}"
|
||||
|
||||
async def asyncSetUp(self):
|
||||
await super().asyncSetUp()
|
||||
self.conf = TranscodeConfig()
|
||||
self.conf.volume_analysis_time = 0 # disable it as the test file isn't very good here
|
||||
self.analyzer = VideoFileAnalyzer(self.conf)
|
||||
file_ogg = self.make_name("ogg", ".ogg")
|
||||
self.video_file_ogg = str(file_ogg)
|
||||
if not file_ogg.exists():
|
||||
command = f'-i "{self.video_file_name}" -c:v libtheora -q:v 4 -c:a libvorbis -q:a 4 ' \
|
||||
f'-c:s copy -c:d copy "{file_ogg}"'
|
||||
with MeasureTime(f"Creating {file_ogg.name}"):
|
||||
output, code = await self.analyzer._execute("ffmpeg", command)
|
||||
self.assertEqual(code, 0, output)
|
||||
|
||||
file_webm = self.make_name("webm", ".webm")
|
||||
self.video_file_webm = str(file_webm)
|
||||
if not file_webm.exists():
|
||||
command = f'-i "{self.video_file_name}" -c:v libvpx-vp9 -crf 36 -b:v 0 -cpu-used 2 ' \
|
||||
f'-c:a libopus -b:a 128k -c:s copy -c:d copy "{file_webm}"'
|
||||
with MeasureTime(f"Creating {file_webm.name}"):
|
||||
output, code = await self.analyzer._execute("ffmpeg", command)
|
||||
self.assertEqual(code, 0, output)
|
||||
|
||||
async def test_should_work(self):
|
||||
new_file_name = await self.analyzer.verify_or_repair(True, False, self.video_file_name)
|
||||
self.assertEqual(self.video_file_name, new_file_name)
|
||||
new_file_name = await self.analyzer.verify_or_repair(True, False, self.video_file_ogg)
|
||||
self.assertEqual(self.video_file_ogg, new_file_name)
|
||||
new_file_name = await self.analyzer.verify_or_repair(True, False, self.video_file_webm)
|
||||
self.assertEqual(self.video_file_webm, new_file_name)
|
||||
|
||||
async def test_volume(self):
|
||||
self.conf.volume_analysis_time = 200
|
||||
with self.assertRaisesRegex(Exception, "lower than prime"):
|
||||
await self.analyzer.verify_or_repair(True, False, self.video_file_name)
|
||||
|
||||
async def test_container(self):
|
||||
file_name = self.make_name("bad_container", ".avi")
|
||||
if not file_name.exists():
|
||||
command = f'-i "{self.video_file_name}" -c copy -map 0 "{file_name}"'
|
||||
with MeasureTime(f"Creating {file_name.name}"):
|
||||
output, code = await self.analyzer._execute("ffmpeg", command)
|
||||
self.assertEqual(code, 0, output)
|
||||
|
||||
with self.assertRaisesRegex(Exception, "Container format is not in the approved list"):
|
||||
await self.analyzer.verify_or_repair(True, False, file_name)
|
||||
|
||||
fixed_file = await self.analyzer.verify_or_repair(True, True, file_name)
|
||||
pathlib.Path(fixed_file).unlink()
|
||||
|
||||
async def test_video_codec(self):
|
||||
file_name = self.make_name("bad_video_codec_1")
|
||||
if not file_name.exists():
|
||||
command = f'-i "{self.video_file_name}" -c copy -map 0 -c:v libx265 -preset superfast "{file_name}"'
|
||||
with MeasureTime(f"Creating {file_name.name}"):
|
||||
output, code = await self.analyzer._execute("ffmpeg", command)
|
||||
self.assertEqual(code, 0, output)
|
||||
|
||||
with self.assertRaisesRegex(Exception, "Video codec is not in the approved list"):
|
||||
await self.analyzer.verify_or_repair(True, False, file_name)
|
||||
with self.assertRaisesRegex(Exception, "faststart flag was not used"):
|
||||
await self.analyzer.verify_or_repair(True, False, file_name)
|
||||
|
||||
fixed_file = await self.analyzer.verify_or_repair(True, True, file_name)
|
||||
pathlib.Path(fixed_file).unlink()
|
||||
|
||||
async def test_video_format(self):
|
||||
file_name = self.make_name("bad_video_format_1")
|
||||
if not file_name.exists():
|
||||
command = f'-i "{self.video_file_name}" -c copy -map 0 -c:v libx264 ' \
|
||||
f'-vf format=yuv444p "{file_name}"'
|
||||
with MeasureTime(f"Creating {file_name.name}"):
|
||||
output, code = await self.analyzer._execute("ffmpeg", command)
|
||||
self.assertEqual(code, 0, output)
|
||||
|
||||
with self.assertRaisesRegex(Exception, "pixel format does not match the approved"):
|
||||
await self.analyzer.verify_or_repair(True, False, file_name)
|
||||
|
||||
fixed_file = await self.analyzer.verify_or_repair(True, True, file_name)
|
||||
pathlib.Path(fixed_file).unlink()
|
||||
|
||||
async def test_audio_codec(self):
|
||||
file_name = self.make_name("bad_audio_codec_1", ".mkv")
|
||||
if not file_name.exists():
|
||||
command = f'-i "{self.video_file_name}" -c copy -map 0 -c:a pcm_s16le "{file_name}"'
|
||||
with MeasureTime(f"Creating {file_name.name}"):
|
||||
output, code = await self.analyzer._execute("ffmpeg", command)
|
||||
self.assertEqual(code, 0, output)
|
||||
|
||||
with self.assertRaisesRegex(Exception, "Audio codec is not in the approved list"):
|
||||
await self.analyzer.verify_or_repair(True, False, file_name)
|
||||
|
||||
fixed_file = await self.analyzer.verify_or_repair(True, True, file_name)
|
||||
pathlib.Path(fixed_file).unlink()
|
||||
|
||||
async def test_extension_choice(self):
|
||||
|
||||
scan_data = await self.analyzer._get_scan_data(True, self.video_file_name)
|
||||
extension = self.analyzer._get_best_container_extension(scan_data, "")
|
||||
self.assertEqual(extension, pathlib.Path(self.video_file_name).suffix[1:])
|
||||
|
||||
scan_data = await self.analyzer._get_scan_data(True, self.video_file_ogg)
|
||||
extension = self.analyzer._get_best_container_extension(scan_data, "")
|
||||
self.assertEqual(extension, "ogg")
|
||||
|
||||
scan_data = await self.analyzer._get_scan_data(True, self.video_file_webm)
|
||||
extension = self.analyzer._get_best_container_extension(scan_data, "")
|
||||
self.assertEqual(extension, "webm")
|
||||
|
||||
extension = self.analyzer._get_best_container_extension("", "libx264 -crf 23")
|
||||
self.assertEqual("mp4", extension)
|
||||
|
||||
extension = self.analyzer._get_best_container_extension("", "libvpx-vp9 -crf 23")
|
||||
self.assertEqual("webm", extension)
|
||||
|
||||
extension = self.analyzer._get_best_container_extension("", "libtheora")
|
||||
self.assertEqual("ogg", extension)
|
||||
|
||||
async def test_no_ffmpeg(self):
|
||||
self.conf.ffmpeg_folder = "I don't really exist/"
|
||||
with self.assertRaisesRegex(Exception, "Unable to locate"):
|
||||
await self.analyzer.verify_or_repair(True, False, self.video_file_name)
|
|
@ -199,8 +199,9 @@ class WalletEncryptionAndSynchronization(CommandTestCase):
|
|||
await self.confirm_tx(sendtxid, self.daemon2.ledger)
|
||||
|
||||
def assertWalletEncrypted(self, wallet_path, encrypted):
|
||||
wallet = json.load(open(wallet_path))
|
||||
self.assertEqual(wallet['accounts'][0]['private_key'][1:4] != 'prv', encrypted)
|
||||
with open(wallet_path) as opened:
|
||||
wallet = json.load(opened)
|
||||
self.assertEqual(wallet['accounts'][0]['private_key'][1:4] != 'prv', encrypted)
|
||||
|
||||
async def test_sync(self):
|
||||
daemon, daemon2 = self.daemon, self.daemon2
|
||||
|
|
Loading…
Reference in a new issue