""" Tools for using ffmpeg """ import numpy import sys import os import subprocess import threading import signal from queue import PriorityQueue import logging from ..core import Core from .common import checkOutput, pipeWrapper log = logging.getLogger("AVP.Toolkit.Ffmpeg") class FfmpegVideo: """Opens an input pipe to ffmpeg and stores a buffer of raw video frames.""" # error from the thread used to fill the buffer threadError = None def __init__(self, **kwargs): mandatoryArgs = [ "inputPath", "filter_", "width", "height", "frameRate", # frames per second "chunkSize", # number of bytes in one frame "parent", # mainwindow object "component", # component object ] for arg in mandatoryArgs: setattr(self, arg, kwargs[arg]) self.frameNo = -1 self.currentFrame = "None" self.map_ = None if "loopVideo" in kwargs and kwargs["loopVideo"]: self.loopValue = "-1" else: self.loopValue = "0" if "filter_" in kwargs: if kwargs["filter_"][0] != "-filter_complex": kwargs["filter_"].insert(0, "-filter_complex") else: kwargs["filter_"] = None self.command = [ Core.FFMPEG_BIN, "-thread_queue_size", "512", "-r", str(self.frameRate), "-stream_loop", str(self.loopValue), "-i", self.inputPath, "-f", "image2pipe", "-pix_fmt", "rgba", ] if type(kwargs["filter_"]) is list: self.command.extend(kwargs["filter_"]) self.command.extend( [ "-codec:v", "rawvideo", "-", ] ) self.frameBuffer = PriorityQueue() self.frameBuffer.maxsize = self.frameRate self.finishedFrames = {} self.thread = threading.Thread( target=self.fillBuffer, name="FFmpeg Frame-Fetcher" ) self.thread.daemon = True self.thread.start() def frame(self, num): while True: if num in self.finishedFrames: image = self.finishedFrames.pop(num) return image i, image = self.frameBuffer.get() self.finishedFrames[i] = image self.frameBuffer.task_done() def fillBuffer(self): from ..libcomponent import ComponentError if Core.logEnabled: logFilename = os.path.join( Core.logDir, "render_%s.log" % str(self.component.compPos) ) log.debug("Creating ffmpeg process (log at %s)", logFilename) with open(logFilename, "w") as logf: logf.write(" ".join(self.command) + "\n\n") with open(logFilename, "a") as logf: self.pipe = openPipe( self.command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=logf, bufsize=10**8, ) else: self.pipe = openPipe( self.command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8, ) while True: if self.parent.canceled: break self.frameNo += 1 # If we run out of frames, use the last good frame and loop. try: if len(self.currentFrame) == 0: self.frameBuffer.put((self.frameNo - 1, self.lastFrame)) continue except AttributeError: FfmpegVideo.threadError = ComponentError( self.component, "video", "Video seemed playable but wasn't.", ) break try: self.currentFrame = self.pipe.stdout.read(self.chunkSize) except ValueError as e: if str(e) == "PyMemoryView_FromBuffer(): info->buf must not be NULL": log.debug( "Ignored 'info->buf must not be NULL' error from FFmpeg pipe" ) return else: FfmpegVideo.threadError = ComponentError(self.component, "video") if len(self.currentFrame) != 0: self.frameBuffer.put((self.frameNo, self.currentFrame)) self.lastFrame = self.currentFrame @pipeWrapper def openPipe(commandList, **kwargs): return subprocess.Popen(commandList, **kwargs) def closePipe(pipe): pipe.stdout.close() pipe.send_signal(signal.SIGTERM) def findFfmpeg(): if sys.platform == "win32": bin = "ffmpeg.exe" else: bin = "ffmpeg" if getattr(sys, "frozen", False): # The application is frozen bin = os.path.join(Core.wd, bin) with open(os.devnull, "w") as f: try: checkOutput([bin, "-version"], stderr=f) except (subprocess.CalledProcessError, FileNotFoundError): bin = "" return bin def createFfmpegCommand( inputFile, outputFile, components, duration=-1, logLevel="info" ): """ Constructs the major ffmpeg command used to export the video """ if duration == -1: duration = getAudioDuration(inputFile) safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters duration = "{0:.3f}".format(duration + 0.1) # used by input sources # Test if user has libfdk_aac encoders = checkOutput("%s -encoders -hide_banner" % Core.FFMPEG_BIN, shell=True) encoders = encoders.decode("utf-8") acodec = Core.settings.value("outputAudioCodec") options = Core.encoderOptions containerName = Core.settings.value("outputContainer") vcodec = Core.settings.value("outputVideoCodec") vbitrate = str(Core.settings.value("outputVideoBitrate")) + "k" acodec = Core.settings.value("outputAudioCodec") abitrate = str(Core.settings.value("outputAudioBitrate")) + "k" for cont in options["containers"]: if cont["name"] == containerName: container = cont["container"] break vencoders = options["video-codecs"][vcodec] aencoders = options["audio-codecs"][acodec] def error(): nonlocal encoders, encoder log.critical( "Selected encoder (%s) is not supported by Ffmpeg. The supported encoders are: %s", encoder, encoders, ) return [] for encoder in vencoders: if encoder in encoders: vencoder = encoder break else: return error() for encoder in aencoders: if encoder in encoders: aencoder = encoder break else: return error() ffmpegCommand = [ Core.FFMPEG_BIN, "-loglevel", logLevel, "-thread_queue_size", "512", "-y", # overwrite the output file if it already exists. # INPUT VIDEO "-f", "rawvideo", "-vcodec", "rawvideo", "-s", f'{Core.settings.value("outputWidth")}x{Core.settings.value("outputHeight")}', "-pix_fmt", "rgba", "-r", str(Core.settings.value("outputFrameRate")), "-t", duration, "-an", # the video input has no sound "-i", "-", # the video input comes from a pipe # INPUT SOUND "-t", duration, "-i", inputFile, ] extraAudio = [comp.audio for comp in components if "audio" in comp.properties()] segment = createAudioFilterCommand(extraAudio, safeDuration) ffmpegCommand.extend(segment) # Map audio from the filters or the single audio input, and map video from the pipe ffmpegCommand.extend( [ "-map", "0:v", "-map", "[a]" if segment else "1:a", ] ) ffmpegCommand.extend( [ # OUTPUT "-vcodec", vencoder, "-acodec", aencoder, "-b:v", vbitrate, "-b:a", abitrate, "-pix_fmt", Core.settings.value("outputVideoFormat"), "-preset", Core.settings.value("outputPreset"), "-f", container, ] ) if acodec == "aac": ffmpegCommand.append("-strict") ffmpegCommand.append("-2") ffmpegCommand.append(outputFile) return ffmpegCommand def createAudioFilterCommand(extraAudio, duration): """Add extra inputs and any needed filters to the main ffmpeg command.""" # NOTE: Global filters are currently hard-coded here for debugging use globalFilters = 0 # increase to add global filters if not extraAudio and not globalFilters: return [] ffmpegCommand = [] # Add -i options for extra input files extraFilters = {} for streamNo, params in enumerate(reversed(extraAudio)): extraInputFile, params = params ffmpegCommand.extend( [ "-t", duration, # Tell ffmpeg about shorter clips (seemingly not needed) # streamDuration = getAudioDuration(extraInputFile) # if streamDuration and streamDuration > float(safeDuration) # else "{0:.3f}".format(streamDuration), "-i", extraInputFile, ] ) # Construct dataset of extra filters we'll need to add later for ffmpegFilter in params: if streamNo + 2 not in extraFilters: extraFilters[streamNo + 2] = [] extraFilters[streamNo + 2].append((ffmpegFilter, params[ffmpegFilter])) # Start creating avfilters! Popen-style, so don't use semicolons; extraFilterCommand = [] if globalFilters <= 0: # Dictionary of last-used tmp labels for a given stream number tmpInputs = {streamNo: -1 for streamNo in extraFilters} else: # Insert blank entries for global filters into extraFilters # so the per-stream filters know what input to source later for streamNo in range(len(extraAudio), 0, -1): if streamNo + 1 not in extraFilters: extraFilters[streamNo + 1] = [] # Also filter the primary audio track extraFilters[1] = [] tmpInputs = {streamNo: globalFilters - 1 for streamNo in extraFilters} # Add the global filters! # NOTE: list length must = globalFilters, currently hardcoded if tmpInputs: extraFilterCommand.extend( [ "[%s:a] ashowinfo [%stmp0]" % (str(streamNo), str(streamNo)) for streamNo in tmpInputs ] ) # Now add the per-stream filters! for streamNo, paramList in extraFilters.items(): for param in paramList: source = ( "[%s:a]" % str(streamNo) if tmpInputs[streamNo] == -1 else "[%stmp%s]" % (str(streamNo), str(tmpInputs[streamNo])) ) tmpInputs[streamNo] = tmpInputs[streamNo] + 1 extraFilterCommand.append( "%s %s%s [%stmp%s]" % ( source, param[0], param[1], str(streamNo), str(tmpInputs[streamNo]), ) ) # Join all the filters together and combine into 1 stream extraFilterCommand = "; ".join(extraFilterCommand) + "; " if tmpInputs else "" ffmpegCommand.extend( [ "-filter_complex", extraFilterCommand + "%s amix=inputs=%s:duration=first [a]" % ( "".join( [ ( "[%stmp%s]" % (str(i), tmpInputs[i]) if i in extraFilters else "[%s:a]" % str(i) ) for i in range(1, len(extraAudio) + 2) ] ), str(len(extraAudio) + 1), ), ] ) return ffmpegCommand def testAudioStream(filename): """Test if an audio stream definitely exists""" audioTestCommand = [ Core.FFMPEG_BIN, "-i", filename, "-vn", "-f", "null", "-", ] try: checkOutput(audioTestCommand, stderr=subprocess.DEVNULL) except subprocess.CalledProcessError: return False else: return True def getAudioDuration(filename): """Try to get duration of audio file as float, or False if not possible""" command = [Core.FFMPEG_BIN, "-i", filename] try: fileInfo = checkOutput(command, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as ex: fileInfo = ex.output except (FileNotFoundError, PermissionError): # ffmpeg is possibly not installed return False try: info = fileInfo.decode("utf-8").split("\n") except UnicodeDecodeError as e: log.error("Unicode error:", str(e)) return False for line in info: if "Duration" in line: d = line.split(",")[0] d = d.split(" ")[3] d = d.split(":") duration = float(d[0]) * 3600 + float(d[1]) * 60 + float(d[2]) break else: # String not found in output return False return duration def readAudioFile(filename, videoWorker): """ Creates the completeAudioArray given to components and used to draw the classic visualizer. """ duration = getAudioDuration(filename) if not duration: log.error(f"Audio file {filename} doesn't exist or unreadable.") return command = [ Core.FFMPEG_BIN, "-i", filename, "-f", "s16le", "-acodec", "pcm_s16le", "-ar", "44100", # ouput will have 44100 Hz "-ac", "1", # mono (set to '2' for stereo) "-", ] in_pipe = openPipe( command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8, ) completeAudioArray = numpy.empty(0, dtype="int16") progress = 0 lastPercent = None while True: if Core.canceled: return # read 2 seconds of audio progress += 4 raw_audio = in_pipe.stdout.read(88200 * 4) if len(raw_audio) == 0: break audio_array = numpy.frombuffer(raw_audio, dtype="int16") completeAudioArray = numpy.append(completeAudioArray, audio_array) percent = int(100 * (progress / duration)) if percent >= 100: percent = 100 if lastPercent != percent: string = "Loading audio file: " + str(percent) + "%" videoWorker.progressBarSetText.emit(string) videoWorker.progressBarUpdate.emit(percent) lastPercent = percent in_pipe.kill() in_pipe.wait() # add 0s the end completeAudioArrayCopy = numpy.zeros(len(completeAudioArray) + 44100, dtype="int16") completeAudioArrayCopy[: len(completeAudioArray)] = completeAudioArray completeAudioArray = completeAudioArrayCopy return (completeAudioArray, duration) def exampleSound(style="white", extra="apulsator=offset_l=0.35:offset_r=0.67"): """Help generate an example sound for use in creating a preview""" if style == "white": src = "-2+random(0)" elif style == "freq": src = "sin(1000*t*PI*t)" elif style == "wave": src = "sin(random(0)*2*PI*t)*tan(random(0)*2*PI*t)" elif style == "stereo": src = "0.1*sin(2*PI*(360-2.5/2)*t) | 0.1*sin(2*PI*(360+2.5/2)*t)" return "aevalsrc='%s', %s%s" % (src, extra, ", " if extra else "") def checkFfmpegVersion(): try: with open(os.devnull, "w") as f: ffmpegVers = checkOutput([Core.FFMPEG_BIN, "-version"], stderr=f) ffmpegVers = str(ffmpegVers).split()[2].split(".", 1)[0] if ffmpegVers.startswith("n"): ffmpegVers = ffmpegVers[1:] versionNum = int(ffmpegVers) goodVersion = versionNum > 3 except Exception: versionNum = -1 goodVersion = False return goodVersion, versionNum