hydrus/hydrus/core/HydrusVideoHandling.py

1152 lines
33 KiB
Python
Raw Normal View History

2020-07-29 20:52:44 +00:00
import numpy
import os
import re
2021-10-06 20:59:30 +00:00
import struct
2020-07-29 20:52:44 +00:00
import subprocess
2020-04-22 21:00:35 +00:00
from hydrus.core import HydrusAudioHandling
from hydrus.core import HydrusConstants as HC
from hydrus.core import HydrusData
from hydrus.core import HydrusExceptions
from hydrus.core import HydrusText
2020-06-17 21:31:54 +00:00
from hydrus.core import HydrusThreading
2013-02-19 00:11:43 +00:00
2019-06-19 22:08:48 +00:00
FFMPEG_MISSING_ERROR_PUBBED = False
FFMPEG_NO_CONTENT_ERROR_PUBBED = False
2020-07-15 20:52:09 +00:00
if HC.PLATFORM_WINDOWS:
2014-05-28 21:03:24 +00:00
2020-07-15 20:52:09 +00:00
FFMPEG_PATH = os.path.join( HC.BIN_DIR, 'ffmpeg.exe' )
2014-05-28 21:03:24 +00:00
2020-07-15 20:52:09 +00:00
else:
2014-05-28 21:03:24 +00:00
2020-07-15 20:52:09 +00:00
FFMPEG_PATH = os.path.join( HC.BIN_DIR, 'ffmpeg' )
2014-05-28 21:03:24 +00:00
2016-12-07 22:12:52 +00:00
if not os.path.exists( FFMPEG_PATH ):
FFMPEG_PATH = os.path.basename( FFMPEG_PATH )
2014-05-28 21:03:24 +00:00
2017-06-28 20:23:21 +00:00
def CheckFFMPEGError( lines ):
2018-06-20 20:20:22 +00:00
if len( lines ) == 0:
2020-05-27 21:27:52 +00:00
raise HydrusExceptions.DamagedOrUnusualFileException( 'Could not parse that file--no FFMPEG output given.' )
2018-06-20 20:20:22 +00:00
2017-06-28 20:23:21 +00:00
if "No such file or directory" in lines[-1]:
raise IOError( "File not found!" )
if 'Invalid data' in lines[-1]:
2020-05-27 21:27:52 +00:00
raise HydrusExceptions.DamagedOrUnusualFileException( 'FFMPEG could not parse.' )
2017-06-28 20:23:21 +00:00
2021-11-03 20:49:56 +00:00
def GetAPNGACTLChunk( file_header_bytes: bytes ):
2021-10-06 20:59:30 +00:00
2021-11-03 20:49:56 +00:00
apng_actl_chunk_header = b'acTL'
apng_phys_chunk_header = b'pHYs'
first_guess_header = file_header_bytes[ 37:128 ]
if first_guess_header.startswith( apng_actl_chunk_header ):
return first_guess_header
elif first_guess_header.startswith( apng_phys_chunk_header ):
# aha, some weird other png chunk
# https://wiki.mozilla.org/APNG_Specification
if apng_actl_chunk_header in first_guess_header:
i = first_guess_header.index( apng_actl_chunk_header )
return first_guess_header[i:]
return None
def GetAPNGNumFrames( apng_actl_bytes ):
( num_frames, ) = struct.unpack( '>I', apng_actl_bytes[ 4 : 8 ] )
2021-10-06 20:59:30 +00:00
return num_frames
2016-12-14 21:19:07 +00:00
def GetFFMPEGVersion():
2017-11-15 22:35:49 +00:00
2016-12-14 21:19:07 +00:00
cmd = [ FFMPEG_PATH, '-version' ]
2020-06-17 21:31:54 +00:00
HydrusData.CheckProgramIsNotShuttingDown()
2016-12-14 21:19:07 +00:00
try:
2019-01-16 22:40:53 +00:00
sbp_kwargs = HydrusData.GetSubprocessKWArgs( text = True )
2019-01-09 22:59:03 +00:00
2019-10-16 20:47:55 +00:00
process = subprocess.Popen( cmd, stdin = subprocess.PIPE, stdout = subprocess.PIPE, stderr = subprocess.PIPE, **sbp_kwargs )
2019-01-09 22:59:03 +00:00
except FileNotFoundError:
2019-10-09 22:03:03 +00:00
return 'no ffmpeg found at path "{}"'.format( FFMPEG_PATH )
2016-12-14 21:19:07 +00:00
except Exception as e:
2019-01-09 22:59:03 +00:00
HydrusData.ShowException( e )
2019-10-09 22:03:03 +00:00
return 'unable to execute ffmpeg at path "{}"'.format( FFMPEG_PATH )
2016-12-14 21:19:07 +00:00
2020-06-17 21:31:54 +00:00
( stdout, stderr ) = HydrusThreading.SubprocessCommunicate( process )
2016-12-14 21:19:07 +00:00
2019-06-19 22:08:48 +00:00
del process
2016-12-14 21:19:07 +00:00
2019-06-19 22:08:48 +00:00
lines = stdout.splitlines()
2016-12-14 21:19:07 +00:00
if len( lines ) > 0:
# typically 'ffmpeg version [VERSION] Copyright ...
top_line = lines[0]
if top_line.startswith( 'ffmpeg version ' ):
top_line = top_line.replace( 'ffmpeg version ', '' )
if ' ' in top_line:
version_string = top_line.split( ' ' )[0]
return version_string
2019-06-05 19:42:39 +00:00
message = 'FFMPEG was recently contacted to fetch version information. While FFMPEG could be found, the response could not be understood. Significant debug information has been printed to the log, which hydrus_dev would be interested in.'
HydrusData.ShowText( message )
message += os.linesep * 2
message += str( sbp_kwargs )
message += os.linesep * 2
message += str( os.environ )
message += os.linesep * 2
2019-06-19 22:08:48 +00:00
message += 'STDOUT Response: {}'.format( stdout )
message += os.linesep * 2
message += 'STDERR Response: {}'.format( stderr )
2019-06-05 19:42:39 +00:00
HydrusData.Print( message )
2019-06-19 22:08:48 +00:00
global FFMPEG_NO_CONTENT_ERROR_PUBBED
FFMPEG_NO_CONTENT_ERROR_PUBBED = True
2016-12-14 21:19:07 +00:00
return 'unknown'
2017-06-28 20:23:21 +00:00
# bits of this were originally cribbed from moviepy
2019-04-24 22:18:50 +00:00
def GetFFMPEGInfoLines( path, count_frames_manually = False, only_first_second = False ):
2017-06-28 20:23:21 +00:00
# open the file in a pipe, provoke an error, read output
cmd = [ FFMPEG_PATH, "-i", path ]
2019-04-24 22:18:50 +00:00
if only_first_second:
cmd.insert( 1, '-t' )
cmd.insert( 2, '1' )
2017-06-28 20:23:21 +00:00
if count_frames_manually:
2019-03-27 22:01:02 +00:00
# added -an here to remove audio component, which was sometimes causing convert fails on single-frame music webms
2017-06-28 20:23:21 +00:00
if HC.PLATFORM_WINDOWS:
2019-04-24 22:18:50 +00:00
cmd += [ "-vf", "scale=-2:120", "-an", "-f", "null", "NUL" ]
2017-06-28 20:23:21 +00:00
else:
2019-04-24 22:18:50 +00:00
cmd += [ "-vf", "scale=-2:120", "-an", "-f", "null", "/dev/null" ]
2017-06-28 20:23:21 +00:00
2019-02-06 22:41:35 +00:00
sbp_kwargs = HydrusData.GetSubprocessKWArgs()
2017-06-28 20:23:21 +00:00
2020-06-17 21:31:54 +00:00
HydrusData.CheckProgramIsNotShuttingDown()
2019-03-20 21:22:10 +00:00
try:
2019-10-16 20:47:55 +00:00
process = subprocess.Popen( cmd, bufsize = 10**5, stdin = subprocess.PIPE, stdout = subprocess.PIPE, stderr = subprocess.PIPE, **sbp_kwargs )
2019-03-20 21:22:10 +00:00
except FileNotFoundError as e:
2019-05-15 20:35:00 +00:00
global FFMPEG_MISSING_ERROR_PUBBED
if not FFMPEG_MISSING_ERROR_PUBBED:
message = 'FFMPEG, which hydrus uses to parse and render video, was not found! This may be due to it not being available on your system, or hydrus being unable to find it.'
message += os.linesep * 2
if HC.PLATFORM_WINDOWS:
message += 'You are on Windows, so there should be a copy of ffmpeg.exe in your install_dir/bin folder. If not, please check if your anti-virus has removed it and restore it through a new install.'
else:
message += 'If you are certain that FFMPEG is installed on your OS and accessible in your PATH, please let hydrus_dev know, as this problem is likely due to an environment problem. You may be able to solve this problem immediately by putting a static build of the ffmpeg executable in your install_dir/bin folder.'
message += os.linesep * 2
message += 'You can check your current FFMPEG status through help->about.'
HydrusData.ShowText( message )
FFMPEG_MISSING_ERROR_PUBBED = True
raise FileNotFoundError( 'Cannot interact with video because FFMPEG not found--are you sure it is installed? Full error: ' + str( e ) )
2019-03-20 21:22:10 +00:00
2017-06-28 20:23:21 +00:00
2020-06-17 21:31:54 +00:00
( stdout, stderr ) = HydrusThreading.SubprocessCommunicate( process )
2019-06-19 22:08:48 +00:00
data_bytes = stderr
2017-06-28 20:23:21 +00:00
2019-06-19 22:08:48 +00:00
if len( data_bytes ) == 0:
global FFMPEG_NO_CONTENT_ERROR_PUBBED
if not FFMPEG_NO_CONTENT_ERROR_PUBBED:
message = 'FFMPEG, which hydrus uses to parse and render video, did not return any data on a recent file metadata check! More debug info has been written to the log.'
message += os.linesep * 2
message += 'You can check this info again through help->about.'
HydrusData.ShowText( message )
message += os.linesep * 2
message += str( sbp_kwargs )
message += os.linesep * 2
message += str( os.environ )
message += os.linesep * 2
message += 'STDOUT Response: {}'.format( stdout )
message += os.linesep * 2
message += 'STDERR Response: {}'.format( stderr )
HydrusData.DebugPrint( message )
FFMPEG_NO_CONTENT_ERROR_PUBBED = True
raise HydrusExceptions.DataMissing( 'Cannot interact with video because FFMPEG did not return any content.' )
2017-06-28 20:23:21 +00:00
2019-06-19 22:08:48 +00:00
del process
2017-06-28 20:23:21 +00:00
2019-02-13 22:26:43 +00:00
( text, encoding ) = HydrusText.NonFailingUnicodeDecode( data_bytes, 'utf-8' )
2019-02-06 22:41:35 +00:00
lines = text.splitlines()
2017-06-28 20:23:21 +00:00
2019-07-10 22:38:30 +00:00
CheckFFMPEGError( lines )
2017-06-28 20:23:21 +00:00
return lines
2021-10-06 20:59:30 +00:00
def GetFFMPEGAPNGProperties( path ):
with open( path, 'rb' ) as f:
file_header_bytes = f.read( 256 )
2021-11-03 20:49:56 +00:00
apng_actl_bytes = GetAPNGACTLChunk( file_header_bytes )
if apng_actl_bytes is None:
raise HydrusExceptions.DamagedOrUnusualFileException( 'This APNG had an unusual file header!' )
num_frames = GetAPNGNumFrames( apng_actl_bytes )
2021-10-06 20:59:30 +00:00
lines = GetFFMPEGInfoLines( path )
2021-11-03 20:49:56 +00:00
resolution = ParseFFMPEGVideoResolution( lines, png_ok = True )
2021-10-06 20:59:30 +00:00
2021-11-03 20:49:56 +00:00
( fps, confident_fps ) = ParseFFMPEGFPS( lines, png_ok = True )
2021-10-06 20:59:30 +00:00
if not confident_fps:
fps = 24
duration = num_frames / fps
duration_in_ms = int( duration * 1000 )
has_audio = False
return ( resolution, duration_in_ms, num_frames, has_audio )
2019-04-24 22:18:50 +00:00
def GetFFMPEGVideoProperties( path, force_count_frames_manually = False ):
2014-06-25 20:37:06 +00:00
2020-09-16 20:46:54 +00:00
lines_for_first_second = GetFFMPEGInfoLines( path, count_frames_manually = True, only_first_second = True )
2014-06-25 20:37:06 +00:00
2020-09-16 20:46:54 +00:00
( has_video, video_format ) = ParseFFMPEGVideoFormat( lines_for_first_second )
2020-01-02 03:05:35 +00:00
if not has_video:
2017-06-28 20:23:21 +00:00
2020-05-27 21:27:52 +00:00
raise HydrusExceptions.DamagedOrUnusualFileException( 'File did not appear to have a video stream!' )
2017-06-28 20:23:21 +00:00
2014-06-25 20:37:06 +00:00
2020-09-16 20:46:54 +00:00
resolution = ParseFFMPEGVideoResolution( lines_for_first_second )
2014-06-25 20:37:06 +00:00
2020-09-16 20:46:54 +00:00
( file_duration_in_s, stream_duration_in_s ) = ParseFFMPEGDuration( lines_for_first_second )
2019-04-24 22:18:50 +00:00
# this will have to be fixed when I add audio, and dynamically accounted for on dual vid/audio rendering
duration = stream_duration_in_s
2014-06-25 20:37:06 +00:00
2020-09-16 20:46:54 +00:00
( fps, confident_fps ) = ParseFFMPEGFPS( lines_for_first_second )
2019-04-24 22:18:50 +00:00
if duration is None and not confident_fps:
2017-06-28 20:23:21 +00:00
2019-04-24 22:18:50 +00:00
# ok default to fall back on
( fps, confident_fps ) = ( 24, True )
2017-06-28 20:23:21 +00:00
2019-04-24 22:18:50 +00:00
2020-05-06 21:31:41 +00:00
if fps is None or fps == 0:
fps = 1
2019-05-15 20:35:00 +00:00
if duration is None:
force_count_frames_manually = True
else:
num_frames_estimate = int( duration * fps )
# if file is big or long, don't try to force a manual count when one not explicitly asked for
# we don't care about a dropped frame on a 10min vid tbh
2020-09-16 20:46:54 +00:00
num_frames_seems_ok_to_count = duration < 15 or num_frames_estimate < 2400
2019-05-15 20:35:00 +00:00
file_is_ok_size = os.path.getsize( path ) < 128 * 1024 * 1024
if num_frames_seems_ok_to_count and file_is_ok_size:
last_frame_has_unusual_duration = num_frames_estimate != duration * fps
unusual_video_start = file_duration_in_s != stream_duration_in_s
if not confident_fps or last_frame_has_unusual_duration or unusual_video_start:
force_count_frames_manually = True
2019-04-24 22:18:50 +00:00
2019-05-15 20:35:00 +00:00
if force_count_frames_manually:
2017-06-28 20:23:21 +00:00
2019-04-24 22:18:50 +00:00
lines = GetFFMPEGInfoLines( path, count_frames_manually = True )
2017-06-28 20:23:21 +00:00
num_frames = ParseFFMPEGNumFramesManually( lines )
2019-04-24 22:18:50 +00:00
if duration is None:
2017-06-28 20:23:21 +00:00
2019-04-24 22:18:50 +00:00
duration = num_frames / fps
2017-06-28 20:23:21 +00:00
2019-04-24 22:18:50 +00:00
else:
num_frames = int( duration * fps )
2017-06-28 20:23:21 +00:00
duration_in_ms = int( duration * 1000 )
2014-06-25 20:37:06 +00:00
2021-08-18 21:10:01 +00:00
has_audio = VideoHasAudio( path, lines_for_first_second )
return ( resolution, duration_in_ms, num_frames, has_audio )
2014-06-25 20:37:06 +00:00
2017-06-28 20:23:21 +00:00
def GetMime( path ):
2016-08-17 20:07:22 +00:00
2017-06-28 20:23:21 +00:00
lines = GetFFMPEGInfoLines( path )
2016-08-17 20:07:22 +00:00
2017-06-28 20:23:21 +00:00
try:
2016-08-17 20:07:22 +00:00
2017-06-28 20:23:21 +00:00
mime_text = ParseFFMPEGMimeText( lines )
2016-08-17 20:07:22 +00:00
2020-05-27 21:27:52 +00:00
except HydrusExceptions.UnsupportedFileException:
2016-02-24 21:42:54 +00:00
2017-06-28 20:23:21 +00:00
return HC.APPLICATION_UNKNOWN
2016-02-24 21:42:54 +00:00
2017-06-28 20:23:21 +00:00
2020-01-02 03:05:35 +00:00
( has_video, video_format ) = ParseFFMPEGVideoFormat( lines )
( has_audio, audio_format ) = HydrusAudioHandling.ParseFFMPEGAudio( lines )
2017-06-28 20:23:21 +00:00
if 'matroska' in mime_text or 'webm' in mime_text:
2016-06-01 20:04:15 +00:00
2019-01-09 22:59:03 +00:00
# a webm has at least vp8/vp9 video and optionally vorbis audio
2016-06-01 20:04:15 +00:00
2019-01-09 22:59:03 +00:00
has_webm_video = False
2019-03-06 23:06:22 +00:00
has_webm_audio = False
2019-01-09 22:59:03 +00:00
2020-01-02 03:05:35 +00:00
if has_video:
2019-01-09 22:59:03 +00:00
webm_video_formats = ( 'vp8', 'vp9' )
has_webm_video = True in ( webm_video_format in video_format for webm_video_format in webm_video_formats )
2019-03-06 23:06:22 +00:00
if has_audio:
2019-01-09 22:59:03 +00:00
2019-03-06 23:06:22 +00:00
webm_audio_formats = ( 'vorbis', 'opus' )
has_webm_audio = True in ( webm_audio_format in audio_format for webm_audio_format in webm_audio_formats )
2019-01-09 22:59:03 +00:00
2019-03-13 21:04:21 +00:00
else:
# no audio at all is not a vote against webm
has_webm_audio = True
2019-01-09 22:59:03 +00:00
if has_webm_video and has_webm_audio:
return HC.VIDEO_WEBM
else:
return HC.VIDEO_MKV
2016-02-24 21:42:54 +00:00
2017-06-28 20:23:21 +00:00
elif mime_text in ( 'mpeg', 'mpegvideo', 'mpegts' ):
2017-05-24 20:28:24 +00:00
2017-06-28 20:23:21 +00:00
return HC.VIDEO_MPEG
2017-05-24 20:28:24 +00:00
2017-06-28 20:23:21 +00:00
elif mime_text == 'flac':
2017-05-24 20:28:24 +00:00
2017-06-28 20:23:21 +00:00
return HC.AUDIO_FLAC
2017-05-24 20:28:24 +00:00
2021-07-28 21:12:00 +00:00
elif mime_text == 'wav':
return HC.AUDIO_WAVE
2017-06-28 20:23:21 +00:00
elif mime_text == 'mp3':
2017-05-24 20:28:24 +00:00
2017-06-28 20:23:21 +00:00
return HC.AUDIO_MP3
2017-05-24 20:28:24 +00:00
2020-01-16 02:08:23 +00:00
elif mime_text == 'tta':
return HC.AUDIO_TRUEAUDIO
2017-07-05 21:09:28 +00:00
elif 'mp4' in mime_text:
2020-01-02 03:05:35 +00:00
if has_audio and ( not has_video or 'mjpeg' in video_format ):
return HC.AUDIO_M4A
else:
return HC.VIDEO_MP4
2017-07-05 21:09:28 +00:00
2017-06-28 20:23:21 +00:00
elif mime_text == 'ogg':
2016-12-07 22:12:52 +00:00
2017-06-28 20:23:21 +00:00
return HC.AUDIO_OGG
2016-12-07 22:12:52 +00:00
2020-01-16 02:08:23 +00:00
elif 'rm' in mime_text:
if ParseFFMPEGHasVideo( lines ):
return HC.VIDEO_REALMEDIA
else:
return HC.AUDIO_REALMEDIA
2017-06-28 20:23:21 +00:00
elif mime_text == 'asf':
2016-12-07 22:12:52 +00:00
2017-06-28 20:23:21 +00:00
if ParseFFMPEGHasVideo( lines ):
2016-12-07 22:12:52 +00:00
2017-06-28 20:23:21 +00:00
return HC.VIDEO_WMV
2016-12-07 22:12:52 +00:00
else:
2017-06-28 20:23:21 +00:00
return HC.AUDIO_WMA
2016-12-07 22:12:52 +00:00
2014-06-18 21:53:48 +00:00
2017-06-28 20:23:21 +00:00
return HC.APPLICATION_UNKNOWN
2017-06-14 21:19:11 +00:00
2017-06-28 20:23:21 +00:00
def HasVideoStream( path ):
2016-08-17 20:07:22 +00:00
2017-06-28 20:23:21 +00:00
lines = GetFFMPEGInfoLines( path )
2017-06-07 22:05:15 +00:00
2017-06-28 20:23:21 +00:00
return ParseFFMPEGHasVideo( lines )
2014-06-18 21:53:48 +00:00
2021-07-28 21:12:00 +00:00
def RenderImageToPNGPath( path, temp_png_path ):
# -y to overwrite the temp path
cmd = [ FFMPEG_PATH, '-y', "-i", path, temp_png_path ]
sbp_kwargs = HydrusData.GetSubprocessKWArgs()
HydrusData.CheckProgramIsNotShuttingDown()
try:
process = subprocess.Popen( cmd, bufsize = 10**5, stdin = subprocess.PIPE, stdout = subprocess.PIPE, stderr = subprocess.PIPE, **sbp_kwargs )
except FileNotFoundError as e:
global FFMPEG_MISSING_ERROR_PUBBED
if not FFMPEG_MISSING_ERROR_PUBBED:
message = 'FFMPEG, which hydrus uses to parse and render video, was not found! This may be due to it not being available on your system, or hydrus being unable to find it.'
message += os.linesep * 2
if HC.PLATFORM_WINDOWS:
message += 'You are on Windows, so there should be a copy of ffmpeg.exe in your install_dir/bin folder. If not, please check if your anti-virus has removed it and restore it through a new install.'
else:
message += 'If you are certain that FFMPEG is installed on your OS and accessible in your PATH, please let hydrus_dev know, as this problem is likely due to an environment problem. You may be able to solve this problem immediately by putting a static build of the ffmpeg executable in your install_dir/bin folder.'
message += os.linesep * 2
message += 'You can check your current FFMPEG status through help->about.'
HydrusData.ShowText( message )
FFMPEG_MISSING_ERROR_PUBBED = True
raise FileNotFoundError( 'Cannot interact with video because FFMPEG not found--are you sure it is installed? Full error: ' + str( e ) )
( stdout, stderr ) = HydrusThreading.SubprocessCommunicate( process )
2017-06-28 20:23:21 +00:00
def ParseFFMPEGDuration( lines ):
2014-06-18 21:53:48 +00:00
# get duration (in seconds)
2014-06-25 20:37:06 +00:00
# Duration: 00:00:02.46, start: 0.033000, bitrate: 1069 kb/s
2014-06-18 21:53:48 +00:00
try:
2017-06-28 20:23:21 +00:00
2019-10-09 22:03:03 +00:00
# had a vid with 'Duration:' in title, ha ha, so now a regex
line = [ l for l in lines if re.search( r'^\s*Duration:', l ) is not None ][0]
2017-06-28 20:23:21 +00:00
if 'Duration: N/A' in line:
2019-04-24 22:18:50 +00:00
return ( None, None )
2017-06-28 20:23:21 +00:00
2014-06-25 20:37:06 +00:00
if 'start:' in line:
2016-08-24 18:36:56 +00:00
m = re.search( '(start\\: )' + '-?[0-9]+\\.[0-9]*', line )
2014-06-25 20:37:06 +00:00
start_offset = float( line[ m.start() + 7 : m.end() ] )
2016-08-24 18:36:56 +00:00
if abs( start_offset ) > 1.0: # once had a file with start offset of 957499 seconds jej
start_offset = 0
else:
start_offset = 0
2014-06-25 20:37:06 +00:00
2014-06-18 21:53:48 +00:00
match = re.search("[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9]", line)
2021-04-07 21:26:45 +00:00
hms = [ float( float_string ) for float_string in line[match.start()+1:match.end()].split(':') ]
2014-06-18 21:53:48 +00:00
2017-06-28 20:23:21 +00:00
if len( hms ) == 1:
duration = hms[0]
elif len( hms ) == 2:
duration = 60 * hms[0] + hms[1]
elif len( hms ) ==3:
duration = 3600 * hms[0] + 60 * hms[1] + hms[2]
2019-01-23 22:19:16 +00:00
if duration == 0:
2019-04-24 22:18:50 +00:00
return ( None, None )
2019-01-23 22:19:16 +00:00
2019-04-24 22:18:50 +00:00
file_duration = duration + start_offset
stream_duration = duration
2017-06-28 20:23:21 +00:00
2019-04-24 22:18:50 +00:00
return ( file_duration, stream_duration )
2017-06-28 20:23:21 +00:00
except:
2020-05-27 21:27:52 +00:00
raise HydrusExceptions.DamagedOrUnusualFileException( 'Error reading duration!' )
2017-06-28 20:23:21 +00:00
2021-11-03 20:49:56 +00:00
def ParseFFMPEGFPS( lines, png_ok = False ):
2017-06-28 20:23:21 +00:00
try:
2021-11-03 20:49:56 +00:00
line = ParseFFMPEGVideoLine( lines, png_ok = png_ok )
2018-10-17 21:00:09 +00:00
2021-11-03 20:49:56 +00:00
( possible_results, confident ) = ParseFFMPEGFPSPossibleResults( line )
2017-06-28 20:23:21 +00:00
2021-11-03 20:49:56 +00:00
if len( possible_results ) == 0:
2017-06-28 20:23:21 +00:00
2021-11-03 20:49:56 +00:00
fps = 1
confident = False
2017-06-28 20:23:21 +00:00
2021-11-03 20:49:56 +00:00
else:
2017-06-28 20:23:21 +00:00
2021-11-03 20:49:56 +00:00
fps = min( possible_results )
2017-06-28 20:23:21 +00:00
2018-10-17 21:00:09 +00:00
2021-11-03 20:49:56 +00:00
return ( fps, confident )
2018-10-17 21:00:09 +00:00
2021-11-03 20:49:56 +00:00
except:
2018-10-17 21:00:09 +00:00
2021-11-03 20:49:56 +00:00
raise HydrusExceptions.DamagedOrUnusualFileException( 'Error estimating framerate!' )
2017-06-28 20:23:21 +00:00
2021-11-03 20:49:56 +00:00
def ParseFFMPEGFPSFromFirstSecond( lines_for_first_second ):
try:
2019-04-24 22:18:50 +00:00
2021-11-03 20:49:56 +00:00
line = ParseFFMPEGVideoLine( lines_for_first_second )
( possible_results, confident ) = ParseFFMPEGFPSPossibleResults( line )
num_frames_in_first_second = ParseFFMPEGNumFramesManually( lines_for_first_second )
2019-04-24 22:18:50 +00:00
2018-10-17 21:00:09 +00:00
if len( possible_results ) == 0:
2019-04-24 22:18:50 +00:00
fps = num_frames_in_first_second
confident = False
2018-10-17 21:00:09 +00:00
else:
2019-05-15 20:35:00 +00:00
# in some cases, fps is 0.77 and tbr is incorrectly 20. extreme values cause bad results. let's default to slowest, but test our actual first second for most legit-looking
2018-10-17 21:00:09 +00:00
2020-09-16 20:46:54 +00:00
sensible_first_second = 1 <= num_frames_in_first_second <= 288
2019-04-24 22:18:50 +00:00
2019-05-15 20:35:00 +00:00
fps = min( possible_results )
2019-04-24 22:18:50 +00:00
2019-05-15 20:35:00 +00:00
fps_matches_with_first_second = False
2019-04-24 22:18:50 +00:00
2019-05-15 20:35:00 +00:00
for possible_fps in possible_results:
if num_frames_in_first_second - 1 <= possible_fps and possible_fps <= num_frames_in_first_second + 1:
fps = possible_fps
fps_matches_with_first_second = True
break
2018-10-17 21:00:09 +00:00
2019-05-15 20:35:00 +00:00
confident = sensible_first_second and fps_matches_with_first_second
2019-04-24 22:18:50 +00:00
2020-05-06 21:31:41 +00:00
if fps is None or fps == 0:
fps = 1
confident = False
2019-04-24 22:18:50 +00:00
return ( fps, confident )
2014-06-25 20:37:06 +00:00
2014-06-18 21:53:48 +00:00
except:
2017-06-28 20:23:21 +00:00
2020-05-27 21:27:52 +00:00
raise HydrusExceptions.DamagedOrUnusualFileException( 'Error estimating framerate!' )
2017-06-28 20:23:21 +00:00
2021-11-03 20:49:56 +00:00
def ParseFFMPEGFPSPossibleResults( video_line ):
# get the frame rate
possible_results = set()
match = re.search("( [0-9]*.| )[0-9]* tbr", video_line)
if match is not None:
tbr = video_line[match.start():match.end()].split(' ')[1]
tbr_fps_is_likely_garbage = match is None or tbr.endswith( 'k' ) or float( tbr ) > 144
if not tbr_fps_is_likely_garbage:
possible_results.add( float( tbr ) )
#
match = re.search("( [0-9]*.| )[0-9]* fps", video_line)
if match is not None:
fps = video_line[match.start():match.end()].split(' ')[1]
fps_is_likely_garbage = match is None or fps.endswith( 'k' ) or float( fps ) > 144
if not fps_is_likely_garbage:
possible_results.add( float( fps ) )
possible_results.discard( 0 )
confident = len( possible_results ) <= 1
return ( possible_results, confident )
2017-06-28 20:23:21 +00:00
def ParseFFMPEGHasVideo( lines ):
try:
video_line = ParseFFMPEGVideoLine( lines )
2020-05-27 21:27:52 +00:00
except HydrusExceptions.UnsupportedFileException:
2017-06-28 20:23:21 +00:00
return False
return True
def ParseFFMPEGMimeText( lines ):
2016-08-17 20:07:22 +00:00
try:
( input_line, ) = [ l for l in lines if l.startswith( 'Input #0' ) ]
# Input #0, matroska, webm, from 'm.mkv':
text = input_line[10:]
mime_text = text.split( ', from' )[0]
2017-06-28 20:23:21 +00:00
return mime_text
2016-08-17 20:07:22 +00:00
except:
2020-05-27 21:27:52 +00:00
raise HydrusExceptions.DamagedOrUnusualFileException( 'Error reading mime!' )
2016-08-17 20:07:22 +00:00
2017-06-28 20:23:21 +00:00
def ParseFFMPEGNumFramesManually( lines ):
2019-05-15 20:35:00 +00:00
frame_lines = [ l for l in lines if l.startswith( 'frame=' ) ]
2019-04-24 22:18:50 +00:00
if len( frame_lines ) == 0:
2017-05-31 21:50:53 +00:00
2020-05-27 21:27:52 +00:00
raise HydrusExceptions.DamagedOrUnusualFileException( 'Video appears to be broken and non-renderable--perhaps a damaged single-frame video?' )
2017-05-31 21:50:53 +00:00
2019-04-24 22:18:50 +00:00
2019-05-15 20:35:00 +00:00
final_line = frame_lines[-1] # there will be many progress rows, counting up as the file renders. we hence want the final one
l = final_line
2019-04-24 22:18:50 +00:00
2019-05-15 20:35:00 +00:00
l = l.replace( 'frame=', '' )
2019-04-24 22:18:50 +00:00
2019-05-15 20:35:00 +00:00
while l.startswith( ' ' ):
2017-06-28 20:23:21 +00:00
2019-05-15 20:35:00 +00:00
l = l[1:]
2019-04-24 22:18:50 +00:00
try:
2017-05-31 21:50:53 +00:00
2019-05-15 20:35:00 +00:00
frames_string = l.split( ' ' )[0]
2017-06-28 20:23:21 +00:00
2019-04-24 22:18:50 +00:00
num_frames = int( frames_string )
2017-06-28 20:23:21 +00:00
except:
2020-05-27 21:27:52 +00:00
raise HydrusExceptions.DamagedOrUnusualFileException( 'Video was unable to render correctly--could not parse ffmpeg output line: "{}"'.format( final_line ) )
2017-06-28 20:23:21 +00:00
2019-04-24 22:18:50 +00:00
return num_frames
2019-01-09 22:59:03 +00:00
def ParseFFMPEGVideoFormat( lines ):
2020-01-02 03:05:35 +00:00
try:
line = ParseFFMPEGVideoLine( lines )
2020-05-27 21:27:52 +00:00
except HydrusExceptions.UnsupportedFileException:
2020-01-02 03:05:35 +00:00
return ( False, 'unknown' )
2019-01-09 22:59:03 +00:00
try:
2019-08-07 22:59:53 +00:00
match = re.search( r'(?<=Video\:\s).+?(?=,)', line )
2019-01-09 22:59:03 +00:00
video_format = match.group()
except:
video_format = 'unknown'
2020-01-02 03:05:35 +00:00
return ( True, video_format )
2019-01-09 22:59:03 +00:00
2021-11-03 20:49:56 +00:00
def ParseFFMPEGVideoLine( lines, png_ok = False ):
if png_ok:
bad_video_formats = [ 'jpg' ]
else:
bad_video_formats = [ 'png', 'jpg' ]
2017-05-31 21:50:53 +00:00
2014-06-18 21:53:48 +00:00
# get the output line that speaks about video
2019-01-16 22:40:53 +00:00
# the ^\sStream is to exclude the 'title' line, when it exists, includes the string 'Video: ', ha ha
2021-11-03 20:49:56 +00:00
lines_video = [ l for l in lines if re.search( r'^\s*Stream', l ) is not None and 'Video: ' in l and True not in ( 'Video: {}'.format( bad_video_format ) in l for bad_video_format in bad_video_formats ) ] # mp3 says it has a 'png' video stream
2014-06-18 21:53:48 +00:00
2017-06-28 20:23:21 +00:00
if len( lines_video ) == 0:
2020-05-27 21:27:52 +00:00
raise HydrusExceptions.DamagedOrUnusualFileException( 'Could not find video information!' )
2017-06-28 20:23:21 +00:00
line = lines_video[0]
return line
2021-11-03 20:49:56 +00:00
def ParseFFMPEGVideoResolution( lines, png_ok = False ):
2014-06-18 21:53:48 +00:00
2017-06-28 20:23:21 +00:00
try:
2021-11-03 20:49:56 +00:00
line = ParseFFMPEGVideoLine( lines, png_ok = png_ok )
2014-06-18 21:53:48 +00:00
# get the size, of the form 460x320 (w x h)
match = re.search(" [0-9]*x[0-9]*(,| )", line)
2014-06-25 20:37:06 +00:00
2021-04-07 21:26:45 +00:00
resolution_string = line[match.start():match.end()-1]
( width_string, height_string ) = resolution_string.split( 'x' )
width = int( width_string )
height = int( height_string )
2014-06-25 20:37:06 +00:00
2020-07-29 20:52:44 +00:00
sar_match = re.search( "[\\[\\s]SAR [0-9]*:[0-9]* ", line )
2017-11-01 20:37:39 +00:00
if sar_match is not None:
# ' SAR 2:3 '
sar_string = line[ sar_match.start() : sar_match.end() ]
# '2:3'
sar_string = sar_string[5:-1]
2021-04-07 21:26:45 +00:00
( sar_width_string, sar_height_string ) = sar_string.split( ':' )
2017-11-01 20:37:39 +00:00
2021-04-07 21:26:45 +00:00
sar_width = int( sar_width_string )
sar_height = int( sar_height_string )
2017-11-01 20:37:39 +00:00
2021-04-07 21:26:45 +00:00
width *= sar_width
width //= sar_height
2017-11-01 20:37:39 +00:00
2021-04-07 21:26:45 +00:00
return ( width, height )
2017-06-28 20:23:21 +00:00
except:
2020-05-27 21:27:52 +00:00
raise HydrusExceptions.DamagedOrUnusualFileException( 'Error parsing resolution!' )
2014-06-25 20:37:06 +00:00
2014-05-28 21:03:24 +00:00
2021-08-18 21:10:01 +00:00
def VideoHasAudio( path, info_lines ):
( audio_found, audio_format ) = HydrusAudioHandling.ParseFFMPEGAudio( info_lines )
if not audio_found:
return False
# just because video metadata has an audio stream doesn't mean it has audio. some vids have silent audio streams lmao
# so, let's read it as PCM and see if there is any noise
# this obviously only works for single audio stream vids, we'll adapt this if someone discovers a multi-stream mkv with a silent channel that doesn't work here
cmd = [ FFMPEG_PATH ]
# this is perhaps not sensible for eventual playback and I should rather go for wav file-like and feed into python 'wave' in order to maintain stereo/mono and so on and have easy chunk-reading
cmd.extend( [ '-i', path,
'-loglevel', 'quiet',
'-f', 's16le',
'-' ] )
sbp_kwargs = HydrusData.GetSubprocessKWArgs()
HydrusData.CheckProgramIsNotShuttingDown()
try:
process = subprocess.Popen( cmd, bufsize = 65536, stdin = subprocess.PIPE, stdout = subprocess.PIPE, stderr = subprocess.PIPE, **sbp_kwargs )
except FileNotFoundError as e:
HydrusData.ShowText( 'Cannot render audio--FFMPEG not found!' )
raise
# silent PCM data is just 00 bytes
# every now and then, you'll get a couple ffs for some reason, but this is not legit audio data
try:
chunk_of_pcm_data = process.stdout.read( 65536 )
while len( chunk_of_pcm_data ) > 0:
# iterating over bytes gives you ints, recall
if True in ( b != 0 and b != 255 for b in chunk_of_pcm_data ):
return True
chunk_of_pcm_data = process.stdout.read( 65536 )
return False
finally:
process.terminate()
process.stdout.close()
process.stderr.close()
2014-06-25 20:37:06 +00:00
# This was built from moviepy's FFMPEG_VideoReader
class VideoRendererFFMPEG( object ):
2015-11-18 22:44:07 +00:00
2014-06-25 20:37:06 +00:00
def __init__( self, path, mime, duration, num_frames, target_resolution, pix_fmt = "rgb24" ):
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
self._path = path
self._mime = mime
2019-01-09 22:59:03 +00:00
self._duration = duration / 1000.0
2014-06-25 20:37:06 +00:00
self._num_frames = num_frames
2014-05-28 21:03:24 +00:00
self._target_resolution = target_resolution
2015-03-04 22:44:32 +00:00
self.lastread = None
2019-01-09 22:59:03 +00:00
self.fps = self._num_frames / self._duration
2014-05-28 21:03:24 +00:00
2016-07-06 21:13:15 +00:00
if self.fps == 0:
self.fps = 24
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
self.pix_fmt = pix_fmt
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
if pix_fmt == 'rgba': self.depth = 4
else: self.depth = 3
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
( x, y ) = self._target_resolution
2015-11-25 22:00:57 +00:00
2014-06-25 20:37:06 +00:00
bufsize = self.depth * x * y
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
self.process = None
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
self.bufsize = bufsize
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
self.initialize()
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
2019-03-06 23:06:22 +00:00
def close( self ):
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
if self.process is not None:
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
self.process.terminate()
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
self.process.stdout.close()
self.process.stderr.close()
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
self.process = None
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
def initialize( self, start_index = 0 ):
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
self.close()
2017-06-28 20:23:21 +00:00
if self._mime in ( HC.IMAGE_APNG, HC.IMAGE_GIF ):
2014-05-28 21:03:24 +00:00
2018-02-07 23:40:33 +00:00
do_ss = False
2014-06-25 20:37:06 +00:00
ss = 0
self.pos = 0
skip_frames = start_index
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
else:
2014-05-28 21:03:24 +00:00
2018-02-07 23:40:33 +00:00
if start_index == 0:
2018-02-14 21:47:18 +00:00
do_ss = False
2018-02-07 23:40:33 +00:00
else:
2018-02-14 21:47:18 +00:00
do_ss = True
2018-02-07 23:40:33 +00:00
2019-01-09 22:59:03 +00:00
ss = start_index / self.fps
2014-06-25 20:37:06 +00:00
self.pos = start_index
skip_frames = 0
2014-05-28 21:03:24 +00:00
2021-11-03 20:49:56 +00:00
do_fast_seek = True
2014-06-25 20:37:06 +00:00
( w, h ) = self._target_resolution
2014-05-28 21:03:24 +00:00
2018-02-07 23:40:33 +00:00
cmd = [ FFMPEG_PATH ]
2021-11-03 20:49:56 +00:00
if do_ss and do_fast_seek: # fast seek
cmd.extend( [ '-ss', "%.03f" % ss ] )
cmd.extend( [ '-i', self._path ] )
if do_ss and not do_fast_seek: # slow seek
2018-02-07 23:40:33 +00:00
cmd.extend( [ '-ss', "%.03f" % ss ] )
2021-11-03 20:49:56 +00:00
cmd.extend( [
2014-06-25 20:37:06 +00:00
'-loglevel', 'quiet',
'-f', 'image2pipe',
"-pix_fmt", self.pix_fmt,
"-s", str( w ) + 'x' + str( h ),
2017-05-31 21:50:53 +00:00
'-vsync', '0',
2019-08-07 22:59:53 +00:00
'-vcodec', 'rawvideo',
2021-11-03 20:49:56 +00:00
'-'
] )
2015-06-17 20:01:41 +00:00
2014-05-28 21:03:24 +00:00
2019-01-09 22:59:03 +00:00
sbp_kwargs = HydrusData.GetSubprocessKWArgs()
2020-06-17 21:31:54 +00:00
HydrusData.CheckProgramIsNotShuttingDown()
2019-05-15 20:35:00 +00:00
try:
2019-10-16 20:47:55 +00:00
self.process = subprocess.Popen( cmd, bufsize = self.bufsize, stdin = subprocess.PIPE, stdout = subprocess.PIPE, stderr = subprocess.PIPE, **sbp_kwargs )
2019-05-15 20:35:00 +00:00
except FileNotFoundError as e:
HydrusData.ShowText( 'Cannot render video--FFMPEG not found!' )
raise
2014-05-28 21:03:24 +00:00
2016-07-27 21:53:34 +00:00
if skip_frames > 0:
self.skip_frames( skip_frames )
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
def skip_frames( self, n ):
2014-05-28 21:03:24 +00:00
2018-11-14 23:10:55 +00:00
n = int( n )
2014-06-25 20:37:06 +00:00
( w, h ) = self._target_resolution
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
for i in range( n ):
2014-05-28 21:03:24 +00:00
2015-09-09 22:04:39 +00:00
if self.process is not None:
self.process.stdout.read( self.depth * w * h )
self.process.stdout.flush()
2014-06-25 20:37:06 +00:00
self.pos += 1
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
def read_frame( self ):
2016-07-20 19:57:10 +00:00
if self.pos == self._num_frames:
self.initialize()
2014-05-28 21:03:24 +00:00
2016-07-06 21:13:15 +00:00
if self.process is None:
result = self.lastread
2014-06-25 20:37:06 +00:00
else:
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
( w, h ) = self._target_resolution
nbytes = self.depth * w * h
2019-03-06 23:06:22 +00:00
s = self.process.stdout.read( nbytes )
2014-06-25 20:37:06 +00:00
if len(s) != nbytes:
2018-01-10 22:41:51 +00:00
if self.lastread is None:
2019-03-06 23:06:22 +00:00
if self.pos != 0:
# this renderer was asked to render starting from mid-vid and this was not possible due to broken key frame index whatever
# lets try and render from the vid start before we say the whole vid is broke
# I tried doing 'start from 0 and skip n frames', but this is super laggy so would need updates further up the pipe to display this to the user
# atm this error states does not communicate to the videocontainer that the current frame num has changed, so the frames are henceforth out of phase
2020-05-20 21:36:02 +00:00
#frames_to_jump = self.pos
2019-03-06 23:06:22 +00:00
self.set_position( 0 )
return self.read_frame()
2018-01-10 22:41:51 +00:00
raise Exception( 'Unable to render that video! Please send it to hydrus dev so he can look at it!' )
2014-06-25 20:37:06 +00:00
result = self.lastread
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
self.close()
2014-05-28 21:03:24 +00:00
else:
2014-06-25 20:37:06 +00:00
result = numpy.fromstring( s, dtype = 'uint8' ).reshape( ( h, w, len( s ) // ( w * h ) ) )
self.lastread = result
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
self.pos += 1
return result
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
def set_position( self, pos ):
2014-05-28 21:03:24 +00:00
2014-06-25 20:37:06 +00:00
rewind = pos < self.pos
jump_a_long_way_ahead = pos > self.pos + 60
2018-09-05 20:52:32 +00:00
if rewind or jump_a_long_way_ahead:
self.initialize( pos )
else:
self.skip_frames( pos - self.pos )
2014-05-28 21:03:24 +00:00
2016-12-07 22:12:52 +00:00
2019-03-06 23:06:22 +00:00
def Stop( self ):
self.close()