starting work on Waveform component

split Video class out of Video component for reuse in Waveform
This commit is contained in:
tassaron 2017-07-29 13:08:28 -04:00
parent 6f8f178778
commit c1457b6dad
8 changed files with 606 additions and 159 deletions

2
.gitignore vendored
View File

@ -11,3 +11,5 @@ env/*
*.tar.*
*.exe
ffmpeg
*.bak
*~

View File

@ -197,7 +197,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
'''
Must call super() when subclassing
Triggered only before a video is exported (video_thread.py)
self.worker = the video thread worker
self.audioFile = filepath to the main input audio file
self.completeAudioArray = a list of audio samples
self.sampleSize = number of audio samples per video frame
self.progressBarUpdate = signal to set progress bar number
@ -436,7 +436,7 @@ class ComponentError(RuntimeError):
import sys
if sys.exc_info()[0] is not None:
string = (
"%s component's %s encountered %s %s." % (
"%s component's %s encountered %s %s: %s" % (
caller.__class__.name,
name,
'an' if any([
@ -444,12 +444,13 @@ class ComponentError(RuntimeError):
for vowel in ('A', 'I')
]) else 'a',
sys.exc_info()[0].__name__,
str(sys.exc_info()[1])
)
)
detail = formatTraceback(sys.exc_info()[2])
else:
string = name
detail = "Methods:\n%s" % (
detail = "Attributes:\n%s" % (
"\n".join(
[m for m in dir(caller) if not m.startswith('_')]
)

View File

@ -1,103 +1,13 @@
from PIL import Image, ImageDraw
from PIL import Image
from PyQt5 import QtGui, QtCore, QtWidgets
import os
import math
import subprocess
import signal
import threading
from queue import PriorityQueue
from component import Component, ComponentError
from toolkit.frame import BlankFrame
from toolkit.ffmpeg import testAudioStream
from toolkit import openPipe, checkOutput
class Video:
'''Opens a 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 = [
'ffmpeg', # path to ffmpeg, usually self.core.FFMPEG_BIN
'videoPath',
'width',
'height',
'scale', # percentage scale
'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'
if 'loopVideo' in kwargs and kwargs['loopVideo']:
self.loopValue = '-1'
else:
self.loopValue = '0'
self.command = [
self.ffmpeg,
'-thread_queue_size', '512',
'-r', str(self.frameRate),
'-stream_loop', self.loopValue,
'-i', self.videoPath,
'-f', 'image2pipe',
'-pix_fmt', 'rgba',
'-filter_complex', '[0:v] scale=%s:%s' % scale(
self.scale, self.width, self.height, str),
'-vcodec', 'rawvideo', '-',
]
self.frameBuffer = PriorityQueue()
self.frameBuffer.maxsize = self.frameRate
self.finishedFrames = {}
self.thread = threading.Thread(
target=self.fillBuffer,
name='Video 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 finalizeFrame(
self.component, image, self.width, self.height)
i, image = self.frameBuffer.get()
self.finishedFrames[i] = image
self.frameBuffer.task_done()
def fillBuffer(self):
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:
Video.threadError = ComponentError(self.component, 'video')
break
self.currentFrame = self.pipe.stdout.read(self.chunkSize)
if len(self.currentFrame) != 0:
self.frameBuffer.put((self.frameNo, self.currentFrame))
self.lastFrame = self.currentFrame
from toolkit.ffmpeg import testAudioStream, FfmpegVideo
from toolkit import openPipe, closePipe, checkOutput, scale
class Component(Component):
@ -182,22 +92,21 @@ class Component(Component):
def preFrameRender(self, **kwargs):
super().preFrameRender(**kwargs)
self.updateChunksize()
self.video = Video(
ffmpeg=self.core.FFMPEG_BIN, videoPath=self.videoPath,
self.video = FfmpegVideo(
inputPath=self.videoPath, filter_=self.makeFfmpegFilter(),
width=self.width, height=self.height, chunkSize=self.chunkSize,
frameRate=int(self.settings.value("outputFrameRate")),
parent=self.parent, loopVideo=self.loopVideo,
component=self, scale=self.scale
component=self
) if os.path.exists(self.videoPath) else None
def frameRender(self, frameNo):
if Video.threadError is not None:
raise Video.threadError
return self.video.frame(frameNo)
if FfmpegVideo.threadError is not None:
raise FfmpegVideo.threadError
return self.finalizeFrame(self.video.frame(frameNo))
def postFrameRender(self):
self.video.pipe.stdout.close()
self.video.pipe.send_signal(signal.SIGINT)
closePipe(self.video.pipe)
def pickVideo(self):
imgDir = self.settings.value("componentDir", os.path.expanduser("~"))
@ -220,23 +129,30 @@ class Component(Component):
'-i', self.videoPath,
'-f', 'image2pipe',
'-pix_fmt', 'rgba',
'-filter_complex', '[0:v] scale=%s:%s' % scale(
self.scale, width, height, str),
]
command.extend(self.makeFfmpegFilter())
command.extend([
'-vcodec', 'rawvideo', '-',
'-ss', '90',
'-vframes', '1',
]
'-frames:v', '1',
])
pipe = openPipe(
command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, bufsize=10**8
)
byteFrame = pipe.stdout.read(self.chunkSize)
pipe.stdout.close()
pipe.send_signal(signal.SIGINT)
closePipe(pipe)
frame = finalizeFrame(self, byteFrame, width, height)
frame = self.finalizeFrame(byteFrame)
return frame
def makeFfmpegFilter(self):
return [
'-filter_complex',
'[0:v] scale=%s:%s' % scale(
self.scale, self.width, self.height, str),
]
def updateChunksize(self):
if self.scale != 100 and not self.distort:
width, height = scale(self.scale, self.width, self.height, int)
@ -268,44 +184,32 @@ class Component(Component):
print('Load a video:\n path=/filepath/to/video.mp4')
print('Using audio:\n path=/filepath/to/video.mp4 audio')
def finalizeFrame(self, imageData):
try:
if self.distort:
image = Image.frombytes(
'RGBA',
(self.width, self.height),
imageData)
else:
image = Image.frombytes(
'RGBA',
scale(self.scale, self.width, self.height, int),
imageData)
def scale(scale, width, height, returntype=None):
width = (float(width) / 100.0) * float(scale)
height = (float(height) / 100.0) * float(scale)
if returntype == str:
return (str(math.ceil(width)), str(math.ceil(height)))
elif returntype == int:
return (math.ceil(width), math.ceil(height))
else:
return (width, height)
except ValueError:
print(
'### BAD VIDEO SELECTED ###\n'
'Video will not export with these settings'
)
self.badVideo = True
return BlankFrame(self.width, self.height)
def finalizeFrame(self, imageData, width, height):
try:
if self.distort:
image = Image.frombytes(
'RGBA',
(width, height),
imageData)
if self.scale != 100 \
or self.xPosition != 0 or self.yPosition != 0:
frame = BlankFrame(self.width, self.height)
frame.paste(image, box=(self.xPosition, self.yPosition))
else:
image = Image.frombytes(
'RGBA',
scale(self.scale, width, height, int),
imageData)
except ValueError:
print(
'### BAD VIDEO SELECTED ###\n'
'Video will not export with these settings'
)
self.badVideo = True
return BlankFrame(width, height)
if self.scale != 100 \
or self.xPosition != 0 or self.yPosition != 0:
frame = BlankFrame(width, height)
frame.paste(image, box=(self.xPosition, self.yPosition))
else:
frame = image
self.badVideo = False
return frame
frame = image
self.badVideo = False
return frame

139
src/components/waveform.py Normal file
View File

@ -0,0 +1,139 @@
from PIL import Image
from PyQt5 import QtGui, QtCore, QtWidgets
from PyQt5.QtGui import QColor
import os
import math
import subprocess
from component import Component, ComponentError
from toolkit.frame import BlankFrame
from toolkit import openPipe, checkOutput, rgbFromString
from toolkit.ffmpeg import FfmpegVideo
class Component(Component):
name = 'Waveform'
version = '1.0.0'
def widget(self, *args):
self.color = (255, 255, 255)
super().widget(*args)
self.page.lineEdit_color.setText('%s,%s,%s' % self.color)
btnStyle = "QPushButton { background-color : %s; outline: none; }" \
% QColor(*self.color1).name()
self.page.lineEdit_color.setStylesheet(btnStyle)
self.page.pushButton_color.clicked.connect(lambda: self.pickColor())
self.trackWidgets(
{
'mode': self.page.comboBox_mode,
'x': self.page.spinBox_x,
'y': self.page.spinBox_y,
'mirror': self.page.checkBox_mirror,
'scale': self.page.spinBox_scale,
}
)
def update(self):
self.color = rgbFromString(self.page.lineEdit_color.text())
btnStyle = "QPushButton { background-color : %s; outline: none; }" \
% QColor(*self.color).name()
self.page.pushButton_color.setStyleSheet(btnStyle)
super().update()
def previewRender(self):
self.updateChunksize()
frame = self.getPreviewFrame(self.width, self.height)
if not frame:
return BlankFrame(self.width, self.height)
else:
return frame
def preFrameRender(self, **kwargs):
super().preFrameRender(**kwargs)
self.updateChunksize()
self.video = FfmpegVideo(
inputPath=self.audioFile,
filter_=makeFfmpegFilter(),
width=self.width, height=self.height,
chunkSize=self.chunkSize,
frameRate=int(self.settings.value("outputFrameRate")),
parent=self.parent, component=self,
)
def frameRender(self, frameNo):
if FfmpegVideo.threadError is not None:
raise FfmpegVideo.threadError
return finalizeFrame(self.video.frame(frameNo))
def postFrameRender(self):
closePipe(self.video.pipe)
def getPreviewFrame(self, width, height):
inputFile = self.parent.window.lineEdit_audioFile.text()
if not inputFile or not os.path.exists(inputFile):
return
command = [
self.core.FFMPEG_BIN,
'-thread_queue_size', '512',
'-i', inputFile,
'-f', 'image2pipe',
'-pix_fmt', 'rgba',
]
command.extend(self.makeFfmpegFilter())
command.extend([
'-vcodec', 'rawvideo', '-',
'-ss', '90',
'-frames:v', '1',
])
pipe = openPipe(
command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, bufsize=10**8
)
byteFrame = pipe.stdout.read(self.chunkSize)
closePipe(pipe)
frame = finalizeFrame(self, byteFrame, width, height)
return frame
def makeFfmpegFilter(self):
w, h = scale(self.scale, self.width, self.height, str)
return [
'-filter_complex',
'[0:a] showwaves=s=%sx%s:mode=%s,format=rgba [v]' % (
w, h, self.mode,
),
'-map', '[v]',
'-map', '0:a',
]
def updateChunksize(self):
if self.scale != 100:
width, height = scale(self.scale, self.width, self.height, int)
else:
width, height = self.width, self.height
self.chunkSize = 4 * width * height
def scale(scale, width, height, returntype=None):
width = (float(width) / 100.0) * float(scale)
height = (float(height) / 100.0) * float(scale)
if returntype == str:
return (str(math.ceil(width)), str(math.ceil(height)))
elif returntype == int:
return (math.ceil(width), math.ceil(height))
else:
return (width, height)
def finalizeFrame(self, imageData, width, height):
# frombytes goes here
if self.scale != 100 \
or self.x != 0 or self.y != 0:
frame = BlankFrame(width, height)
frame.paste(image, box=(self.x, self.y))
else:
frame = image
return frame

283
src/components/waveform.ui Normal file
View File

@ -0,0 +1,283 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>586</width>
<height>197</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>197</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="leftMargin">
<number>4</number>
</property>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_8">
<item>
<widget class="QLabel" name="label_textColor">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>31</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Mode</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBox_mode">
<item>
<property name="text">
<string>Cline</string>
</property>
</item>
<item>
<property name="text">
<string>Line</string>
</property>
</item>
<item>
<property name="text">
<string>P2p</string>
</property>
</item>
<item>
<property name="text">
<string>Point</string>
</property>
</item>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_9">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>5</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="label_xTitleAlign">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>X</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="spinBox_x">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>16777215</height>
</size>
</property>
<property name="minimum">
<number>-10000</number>
</property>
<property name="maximum">
<number>10000</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_yTitleAlign">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Y</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="spinBox_y">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>16777215</height>
</size>
</property>
<property name="baseSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="minimum">
<number>-10000</number>
</property>
<property name="maximum">
<number>10000</number>
</property>
<property name="value">
<number>0</number>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_9">
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Wave Color</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="lineEdit_color"/>
</item>
<item>
<widget class="QPushButton" name="pushButton_color">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="default">
<bool>false</bool>
</property>
<property name="flat">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QCheckBox" name="checkBox_mirror">
<property name="text">
<string>Mirror</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Scale</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="spinBox_scale">
<property name="buttonSymbols">
<enum>QAbstractSpinBox::UpDownArrows</enum>
</property>
<property name="suffix">
<string>%</string>
</property>
<property name="minimum">
<number>10</number>
</property>
<property name="maximum">
<number>400</number>
</property>
<property name="value">
<number>100</number>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -6,9 +6,22 @@ import string
import os
import sys
import subprocess
import signal
import math
from collections import OrderedDict
def scale(scale, width, height, returntype=None):
width = (float(width) / 100.0) * float(scale)
height = (float(height) / 100.0) * float(scale)
if returntype == str:
return (str(math.ceil(width)), str(math.ceil(height)))
elif returntype == int:
return (math.ceil(width), math.ceil(height))
else:
return (width, height)
def badName(name):
'''Returns whether a name contains non-alphanumeric chars'''
return any([letter in string.punctuation for letter in name])
@ -34,29 +47,35 @@ def appendUppercase(lst):
lst.append(form.upper())
return lst
def hideCmdWin(func):
''' Stops CMD window from appearing on Windows.
Adapted from here: http://code.activestate.com/recipes/409002/
'''
def decorator(commandList, **kwargs):
def pipeWrapper(func):
'''A decorator to insert proper kwargs into Popen objects.'''
def pipeWrapper(commandList, **kwargs):
if sys.platform == 'win32':
# Stop CMD window from appearing on Windows
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
kwargs['startupinfo'] = startupinfo
if 'bufsize' not in kwargs:
kwargs['bufsize'] = 10**8
if 'stdin' not in kwargs:
kwargs['stdin'] = subprocess.DEVNULL
return func(commandList, **kwargs)
return decorator
return pipeWrapper
@hideCmdWin
@pipeWrapper
def checkOutput(commandList, **kwargs):
return subprocess.check_output(commandList, **kwargs)
@hideCmdWin
@pipeWrapper
def openPipe(commandList, **kwargs):
return subprocess.Popen(commandList, **kwargs)
def closePipe(pipe):
pipe.stdout.close()
pipe.send_signal(signal.SIGINT)
def disableWhenEncoding(func):
def decorator(self, *args, **kwargs):

View File

@ -5,11 +5,110 @@ import numpy
import sys
import os
import subprocess
import threading
from queue import PriorityQueue
import core
from toolkit.common import checkOutput, openPipe
class FfmpegVideo:
'''Opens a 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.Core.FFMPEG_BIN,
'-thread_queue_size', '512',
'-r', str(self.frameRate),
'-stream_loop', self.loopValue,
'-i', self.inputPath,
'-f', 'image2pipe',
'-pix_fmt', 'rgba',
]
if type(kwargs['filter_']) is list:
self.command.extend(
kwargs['filter_']
)
self.command.extend([
'-vcodec', '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):
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:
Video.threadError = ComponentError(self.component, 'video')
break
self.currentFrame = self.pipe.stdout.read(self.chunkSize)
if len(self.currentFrame) != 0:
self.frameBuffer.put((self.frameNo, self.currentFrame))
self.lastFrame = self.currentFrame
def findFfmpeg():
if getattr(sys, 'frozen', False):
# The application is frozen

View File

@ -153,7 +153,7 @@ class Worker(QtCore.QObject):
for compNo, comp in enumerate(reversed(self.components)):
try:
comp.preFrameRender(
worker=self,
audioFile=self.inputFile,
completeAudioArray=self.completeAudioArray,
sampleSize=self.sampleSize,
progressBarUpdate=self.progressBarUpdate,