From f975144f25d34f97329b2d4e52891061573cea08 Mon Sep 17 00:00:00 2001 From: Aeliton G. Silva Date: Mon, 12 Jan 2026 22:39:55 -0300 Subject: Use pyproject.toml + uv_build This replaces setup.py by a modern pyproject.toml using uv_build backend. Dependencies are being also managed by uv, so to install dependencies and run the project one can execute: ``` uv sync uv run pytest # optional python -m avp ``` To build the both source and binary (wheel) distribution package run: ``` uv build ``` Uv can be installed with `pip install uv`. The directory structure has been changed to reflect best practices. - src/* -> src/avp/ - src/tests -> ../tests --- src/avp/components/__init__.py | 1 + src/avp/components/__template__.ui | 119 +++++ src/avp/components/color.py | 176 +++++++ src/avp/components/color.ui | 666 ++++++++++++++++++++++++++ src/avp/components/image.py | 129 +++++ src/avp/components/image.ui | 388 +++++++++++++++ src/avp/components/life.py | 520 ++++++++++++++++++++ src/avp/components/life.ui | 405 ++++++++++++++++ src/avp/components/original.py | 243 ++++++++++ src/avp/components/original.ui | 243 ++++++++++ src/avp/components/sound.py | 77 +++ src/avp/components/sound.ui | 172 +++++++ src/avp/components/spectrum.py | 368 +++++++++++++++ src/avp/components/spectrum.ui | 946 +++++++++++++++++++++++++++++++++++++ src/avp/components/text.py | 218 +++++++++ src/avp/components/text.ui | 671 ++++++++++++++++++++++++++ src/avp/components/video.py | 254 ++++++++++ src/avp/components/video.ui | 328 +++++++++++++ src/avp/components/waveform.py | 230 +++++++++ src/avp/components/waveform.ui | 383 +++++++++++++++ 20 files changed, 6537 insertions(+) create mode 100644 src/avp/components/__init__.py create mode 100644 src/avp/components/__template__.ui create mode 100644 src/avp/components/color.py create mode 100644 src/avp/components/color.ui create mode 100644 src/avp/components/image.py create mode 100644 src/avp/components/image.ui create mode 100644 src/avp/components/life.py create mode 100644 src/avp/components/life.ui create mode 100644 src/avp/components/original.py create mode 100644 src/avp/components/original.ui create mode 100644 src/avp/components/sound.py create mode 100644 src/avp/components/sound.ui create mode 100644 src/avp/components/spectrum.py create mode 100644 src/avp/components/spectrum.ui create mode 100644 src/avp/components/text.py create mode 100644 src/avp/components/text.ui create mode 100644 src/avp/components/video.py create mode 100644 src/avp/components/video.ui create mode 100644 src/avp/components/waveform.py create mode 100644 src/avp/components/waveform.ui (limited to 'src/avp/components') diff --git a/src/avp/components/__init__.py b/src/avp/components/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/avp/components/__init__.py @@ -0,0 +1 @@ + diff --git a/src/avp/components/__template__.ui b/src/avp/components/__template__.ui new file mode 100644 index 0000000..301a2b7 --- /dev/null +++ b/src/avp/components/__template__.ui @@ -0,0 +1,119 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + Form + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/avp/components/color.py b/src/avp/components/color.py new file mode 100644 index 0000000..1f32c23 --- /dev/null +++ b/src/avp/components/color.py @@ -0,0 +1,176 @@ +from PyQt6 import QtGui +import logging + +from ..component import Component +from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor + + +log = logging.getLogger("AVP.Components.Color") + + +class Component(Component): + name = "Color" + version = "1.0.0" + + def widget(self, *args): + self.x = 0 + self.y = 0 + super().widget(*args) + + # disable color #2 until non-default 'fill' option gets changed + self.page.lineEdit_color2.setDisabled(True) + self.page.pushButton_color2.setDisabled(True) + self.page.spinBox_width.setValue(int(self.settings.value("outputWidth"))) + self.page.spinBox_height.setValue(int(self.settings.value("outputHeight"))) + + self.fillLabels = [ + "Solid", + "Linear Gradient", + "Radial Gradient", + ] + for label in self.fillLabels: + self.page.comboBox_fill.addItem(label) + self.page.comboBox_fill.setCurrentIndex(0) + + self.trackWidgets( + { + "x": self.page.spinBox_x, + "y": self.page.spinBox_y, + "sizeWidth": self.page.spinBox_width, + "sizeHeight": self.page.spinBox_height, + "trans": self.page.checkBox_trans, + "spread": self.page.comboBox_spread, + "stretch": self.page.checkBox_stretch, + "RG_start": self.page.spinBox_radialGradient_start, + "LG_start": self.page.spinBox_linearGradient_start, + "RG_end": self.page.spinBox_radialGradient_end, + "LG_end": self.page.spinBox_linearGradient_end, + "RG_centre": self.page.spinBox_radialGradient_spread, + "fillType": self.page.comboBox_fill, + "color1": self.page.lineEdit_color1, + "color2": self.page.lineEdit_color2, + }, + presetNames={ + "sizeWidth": "width", + "sizeHeight": "height", + }, + colorWidgets={ + "color1": self.page.pushButton_color1, + "color2": self.page.pushButton_color2, + }, + relativeWidgets=[ + "x", + "y", + "sizeWidth", + "sizeHeight", + "LG_start", + "LG_end", + "RG_start", + "RG_end", + "RG_centre", + ], + ) + + def update(self): + fillType = self.page.comboBox_fill.currentIndex() + if fillType == 0: + self.page.lineEdit_color2.setEnabled(False) + self.page.pushButton_color2.setEnabled(False) + self.page.checkBox_trans.setEnabled(False) + self.page.checkBox_stretch.setEnabled(False) + self.page.comboBox_spread.setEnabled(False) + else: + self.page.lineEdit_color2.setEnabled(True) + self.page.pushButton_color2.setEnabled(True) + self.page.checkBox_trans.setEnabled(True) + self.page.checkBox_stretch.setEnabled(True) + self.page.comboBox_spread.setEnabled(True) + if self.page.checkBox_trans.isChecked(): + self.page.lineEdit_color2.setEnabled(False) + self.page.pushButton_color2.setEnabled(False) + self.page.fillWidget.setCurrentIndex(fillType) + + def previewRender(self): + return self.drawFrame(self.width, self.height) + + def properties(self): + return ["static"] + + def frameRender(self, frameNo): + log.debug("Color component is drawing frame #%s", frameNo) + return self.drawFrame(self.width, self.height) + + def drawFrame(self, width, height): + r, g, b = self.color1 + shapeSize = (self.sizeWidth, self.sizeHeight) + # in default state, skip all this logic and return a plain fill + if ( + self.fillType == 0 + and shapeSize == (width, height) + and self.x == 0 + and self.y == 0 + ): + return FloodFrame(width, height, (r, g, b, 255)) + + # Return a solid image at x, y + if self.fillType == 0: + frame = BlankFrame(width, height) + image = FloodFrame(self.sizeWidth, self.sizeHeight, (r, g, b, 255)) + frame.paste(image, box=(self.x, self.y)) + return frame + + # Now fills that require using Qt... + elif self.fillType > 0: + image = FramePainter(width, height) + + if self.stretch: + w = width + h = height + else: + w = self.sizeWidth + h = self.sizeWidth + + if self.fillType == 1: # Linear Gradient + brush = QtGui.QLinearGradient( + float(self.LG_start), + float(self.LG_start), + float(self.LG_end + width / 3), + float(self.LG_end), + ) + + elif self.fillType == 2: # Radial Gradient + brush = QtGui.QRadialGradient( + float(self.RG_start), + float(self.RG_end), + float(w), + float(h), + float(self.RG_centre), + ) + spread = QtGui.QGradient.Spread.PadSpread + if self.spread == 1: + spread = QtGui.QGradient.Spread.ReflectSpread + elif self.spread == 2: + spread = QtGui.QGradient.Spread.RepeatSpread + brush.setSpread(spread) + brush.setColorAt(0.0, PaintColor(*self.color1)) + if self.trans: + brush.setColorAt(1.0, PaintColor(0, 0, 0, 0)) + elif self.fillType == 1 and self.stretch: + brush.setColorAt(0.2, PaintColor(*self.color2)) + else: + brush.setColorAt(1.0, PaintColor(*self.color2)) + image.setBrush(brush) + image.drawRect(self.x, self.y, self.sizeWidth, self.sizeHeight) + + return image.finalize() + + def commandHelp(self): + print("Specify a color:\n color=255,255,255") + + def command(self, arg): + if "=" in arg: + key, arg = arg.split("=", 1) + if key == "color": + self.page.lineEdit_color1.setText(arg) + return + super().command(arg) diff --git a/src/avp/components/color.ui b/src/avp/components/color.ui new file mode 100644 index 0000000..c1713fb --- /dev/null +++ b/src/avp/components/color.ui @@ -0,0 +1,666 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + Form + + + + + + 4 + + + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Color #1 + + + + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + + 0 + 0 + + + + + 1 + 0 + + + + 0,0,0 + + + 12 + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Color #2 + + + + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + + 0 + 0 + + + + + 1 + 0 + + + + 133,133,133 + + + 12 + + + + + + + + + 0 + + + + + + 0 + 0 + + + + Width + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + 0 + + + 19200 + + + 0 + + + + + + + + 0 + 0 + + + + Height + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + 10800 + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + -10000 + + + 10000 + + + 0 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + -10000 + + + 10000 + + + + + + + + + 0 + + + + + + 0 + 0 + + + + Fill + + + + + + + + 0 + 0 + + + + -1 + + + QComboBox::AdjustToContentsOnFirstShow + + + + + + + + 0 + 0 + + + + Transparent + + + + + + + + 0 + 0 + + + + Stretch + + + + + + + + Pad + + + + + Reflect + + + + + Repeat + + + + + + + + Qt::Horizontal + + + QSizePolicy::Minimum + + + + 40 + 20 + + + + + + + + + + + + + 0 + 0 + + + + 0 + + + 2 + + + + + + + -1 + 0 + 561 + 31 + + + + + + + + 0 + 0 + + + + Start + + + + + + + -10000 + + + 10000 + + + 10 + + + + + + + + 0 + 0 + + + + End + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + -10000 + + + 10000 + + + 10 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + -1 + -1 + 561 + 31 + + + + + + + + 0 + 0 + + + + Start + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + -10000 + + + 10000 + + + 10 + + + + + + + + 0 + 0 + + + + End + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + -10000 + + + 10000 + + + 10 + + + + + + + + 0 + 0 + + + + Centre + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QAbstractSpinBox::PlusMinus + + + -10000 + + + 10000 + + + 3 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + diff --git a/src/avp/components/image.py b/src/avp/components/image.py new file mode 100644 index 0000000..2393611 --- /dev/null +++ b/src/avp/components/image.py @@ -0,0 +1,129 @@ +from PIL import Image, ImageDraw, ImageEnhance +from PyQt6 import QtGui, QtCore, QtWidgets +import os + +from ..component import Component +from ..toolkit.frame import BlankFrame + + +class Component(Component): + name = "Image" + version = "1.0.1" + + def widget(self, *args): + super().widget(*args) + self.page.pushButton_image.clicked.connect(self.pickImage) + self.trackWidgets( + { + "imagePath": self.page.lineEdit_image, + "scale": self.page.spinBox_scale, + "stretchScale": self.page.spinBox_scale_stretch, + "rotate": self.page.spinBox_rotate, + "color": self.page.spinBox_color, + "xPosition": self.page.spinBox_x, + "yPosition": self.page.spinBox_y, + "stretched": self.page.checkBox_stretch, + "mirror": self.page.checkBox_mirror, + }, + presetNames={ + "imagePath": "image", + "xPosition": "x", + "yPosition": "y", + }, + relativeWidgets=["xPosition", "yPosition", "scale"], + ) + + def previewRender(self): + return self.drawFrame(self.width, self.height) + + def properties(self): + props = ["static"] + if not os.path.exists(self.imagePath): + props.append("error") + return props + + def error(self): + if not self.imagePath: + return "There is no image selected." + if not os.path.exists(self.imagePath): + return "The image selected does not exist!" + + def frameRender(self, frameNo): + return self.drawFrame(self.width, self.height) + + def drawFrame(self, width, height): + frame = BlankFrame(width, height) + if self.imagePath and os.path.exists(self.imagePath): + scale = self.scale if not self.stretched else self.stretchScale + image = Image.open(self.imagePath) + + # Modify image's appearance + if self.color != 100: + image = ImageEnhance.Color(image).enhance(float(self.color / 100)) + if self.mirror: + image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + if self.stretched and image.size != (width, height): + image = image.resize((width, height), Image.Resampling.LANCZOS) + if scale != 100: + newHeight = int((image.height / 100) * scale) + newWidth = int((image.width / 100) * scale) + image = image.resize((newWidth, newHeight), Image.Resampling.LANCZOS) + + # Paste image at correct position + frame.paste(image, box=(self.xPosition, self.yPosition)) + if self.rotate != 0: + frame = frame.rotate(self.rotate) + + return frame + + def pickImage(self): + imgDir = self.settings.value("componentDir", os.path.expanduser("~")) + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.page, + "Choose Image", + imgDir, + "Image Files (%s)" % " ".join(self.core.imageFormats), + ) + if filename: + self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False + self.page.lineEdit_image.setText(filename) + self.mergeUndo = True + + def command(self, arg): + if "=" in arg: + key, arg = arg.split("=", 1) + if key == "path" and os.path.exists(arg): + try: + Image.open(arg) + self.page.lineEdit_image.setText(arg) + self.page.checkBox_stretch.setChecked(True) + return + except OSError as e: + print("Not a supported image format") + quit(1) + super().command(arg) + + def commandHelp(self): + print("Load an image:\n path=/filepath/to/image.png") + + def savePreset(self): + # Maintain the illusion that the scale spinbox is one widget + scaleBox = self.page.spinBox_scale + stretchScaleBox = self.page.spinBox_scale_stretch + if self.page.checkBox_stretch.isChecked(): + scaleBox.setValue(stretchScaleBox.value()) + else: + stretchScaleBox.setValue(scaleBox.value()) + return super().savePreset() + + def update(self): + # Maintain the illusion that the scale spinbox is one widget + scaleBox = self.page.spinBox_scale + stretchScaleBox = self.page.spinBox_scale_stretch + if self.page.checkBox_stretch.isChecked(): + scaleBox.setVisible(False) + stretchScaleBox.setVisible(True) + else: + scaleBox.setVisible(True) + stretchScaleBox.setVisible(False) diff --git a/src/avp/components/image.ui b/src/avp/components/image.ui new file mode 100644 index 0000000..2dad127 --- /dev/null +++ b/src/avp/components/image.ui @@ -0,0 +1,388 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + Form + + + + + + 4 + + + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Image + + + + + + + + 1 + 0 + + + + + + + + + 0 + 0 + + + + + 1 + 0 + + + + + 32 + 32 + + + + ... + + + + 32 + 32 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + -10000 + + + 10000 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + -1000 + + + 1000 + + + 0 + + + + + + + + + + + Stretch + + + false + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + Mirror + + + + + + + Rotate + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QAbstractSpinBox::UpDownArrows + + + ° + + + 0 + + + 359 + + + 0 + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 10 + 20 + + + + + + + + + 0 + 0 + + + + Scale + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QAbstractSpinBox::UpDownArrows + + + % + + + 10 + + + 400 + + + 100 + + + + + + + % + + + 10 + + + 400 + + + 100 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + Color + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QAbstractSpinBox::UpDownArrows + + + % + + + 0 + + + 999 + + + 1 + + + 100 + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/avp/components/life.py b/src/avp/components/life.py new file mode 100644 index 0000000..5b719d1 --- /dev/null +++ b/src/avp/components/life.py @@ -0,0 +1,520 @@ +from PyQt6 import QtGui, QtCore, QtWidgets +from PyQt6.QtGui import QUndoCommand +from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter +import os +import math +import logging + + +from ..component import Component +from ..toolkit.frame import BlankFrame, scale + + +log = logging.getLogger("AVP.Component.Life") + + +class Component(Component): + name = "Conway's Game of Life" + version = "1.0.0" + + def widget(self, *args): + super().widget(*args) + self.scale = 32 + self.updateGridSize() + # The initial grid: a "Queen Bee Shuttle" + # https://conwaylife.com/wiki/Queen_bee_shuttle + self.startingGrid = set( + [ + (3, 7), + (3, 8), + (4, 7), + (4, 8), + (8, 7), + (9, 6), + (9, 8), + (10, 5), + (10, 9), + (11, 6), + (11, 7), + (11, 8), + (12, 4), + (12, 5), + (12, 9), + (12, 10), + (23, 6), + (23, 7), + (24, 6), + (24, 7), + ] + ) + + # Amount of 'bleed' (off-canvas coordinates) on each side of the grid + self.bleedSize = 40 + + self.page.pushButton_pickImage.clicked.connect(self.pickImage) + self.trackWidgets( + { + "tickRate": self.page.spinBox_tickRate, + "scale": self.page.spinBox_scale, + "color": self.page.lineEdit_color, + "shapeType": self.page.comboBox_shapeType, + "shadow": self.page.checkBox_shadow, + "customImg": self.page.checkBox_customImg, + "showGrid": self.page.checkBox_showGrid, + "image": self.page.lineEdit_image, + }, + colorWidgets={ + "color": self.page.pushButton_color, + }, + ) + self.shiftButtons = ( + self.page.toolButton_up, + self.page.toolButton_down, + self.page.toolButton_left, + self.page.toolButton_right, + ) + + def shiftFunc(i): + def shift(): + self.shiftGrid(i) + + return shift + + shiftFuncs = [shiftFunc(i) for i in range(len(self.shiftButtons))] + for i, widget in enumerate(self.shiftButtons): + widget.clicked.connect(shiftFuncs[i]) + self.page.spinBox_scale.setValue(self.scale) + self.page.spinBox_scale.valueChanged.connect(self.updateGridSize) + + def pickImage(self): + imgDir = self.settings.value("componentDir", os.path.expanduser("~")) + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.page, + "Choose Image", + imgDir, + "Image Files (%s)" % " ".join(self.core.imageFormats), + ) + if filename: + self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False + self.page.lineEdit_image.setText(filename) + self.mergeUndo = True + + def shiftGrid(self, d): + action = ShiftGrid(self, d) + self.parent.undoStack.push(action) + + def update(self): + self.updateGridSize() + if self.page.checkBox_customImg.isChecked(): + self.page.label_color.setVisible(False) + self.page.lineEdit_color.setVisible(False) + self.page.pushButton_color.setVisible(False) + self.page.label_shape.setVisible(False) + self.page.comboBox_shapeType.setVisible(False) + self.page.label_image.setVisible(True) + self.page.lineEdit_image.setVisible(True) + self.page.pushButton_pickImage.setVisible(True) + else: + self.page.label_color.setVisible(True) + self.page.lineEdit_color.setVisible(True) + self.page.pushButton_color.setVisible(True) + self.page.label_shape.setVisible(True) + self.page.comboBox_shapeType.setVisible(True) + self.page.label_image.setVisible(False) + self.page.lineEdit_image.setVisible(False) + self.page.pushButton_pickImage.setVisible(False) + enabled = len(self.startingGrid) > 0 + for widget in self.shiftButtons: + widget.setEnabled(enabled) + + def previewClickEvent(self, pos, size, button): + pos = ( + math.ceil((pos[0] / size[0]) * self.gridWidth) - 1, + math.ceil((pos[1] / size[1]) * self.gridHeight) - 1, + ) + action = ClickGrid(self, pos, button) + self.parent.undoStack.push(action) + + def updateGridSize(self): + w, h = self.core.resolutions[-1].split("x") + self.gridWidth = int(int(w) / self.scale) + self.gridHeight = int(int(h) / self.scale) + self.pxWidth = math.ceil(self.width / self.gridWidth) + self.pxHeight = math.ceil(self.height / self.gridHeight) + + def previewRender(self): + return self.drawGrid(self.startingGrid) + + def preFrameRender(self, *args, **kwargs): + super().preFrameRender(*args, **kwargs) + self.tickGrids = {0: self.startingGrid} + + def properties(self): + if self.customImg and (not self.image or not os.path.exists(self.image)): + return ["error"] + return [] + + def error(self): + return "No image selected to represent life." + + def frameRender(self, frameNo): + tick = math.floor(frameNo / self.tickRate) + + # Compute grid evolution on this frame if it hasn't been computed yet + if tick not in self.tickGrids: + self.tickGrids[tick] = self.gridForTick(tick) + grid = self.tickGrids[tick] + + # Delete old evolution data which we shouldn't need anymore + if tick - 60 in self.tickGrids: + del self.tickGrids[tick - 60] + return self.drawGrid(grid) + + def drawGrid(self, grid): + frame = BlankFrame(self.width, self.height) + + def drawCustomImg(): + try: + img = Image.open(self.image) + except Exception: + return + img = img.resize((self.pxWidth, self.pxHeight), Image.Resampling.LANCZOS) + frame.paste(img, box=(drawPtX, drawPtY)) + + def drawShape(): + drawer = ImageDraw.Draw(frame) + rect = ( + (drawPtX, drawPtY), + (drawPtX + self.pxWidth, drawPtY + self.pxHeight), + ) + shape = self.page.comboBox_shapeType.currentText().lower() + + # Rectangle + if shape == "rectangle": + drawer.rectangle(rect, fill=self.color) + + # Elliptical + elif shape == "elliptical": + drawer.ellipse(rect, fill=self.color) + + tenthX, tenthY = scale(10, self.pxWidth, self.pxHeight, int) + smallerShape = ( + ( + drawPtX + tenthX + int(tenthX / 4), + drawPtY + tenthY + int(tenthY / 2), + ), + ( + drawPtX + self.pxWidth - tenthX - int(tenthX / 4), + drawPtY + self.pxHeight - (tenthY + int(tenthY / 2)), + ), + ) + outlineShape = ( + (drawPtX + int(tenthX / 4), drawPtY + int(tenthY / 2)), + ( + drawPtX + self.pxWidth - int(tenthX / 4), + drawPtY + self.pxHeight - int(tenthY / 2), + ), + ) + # Circle + if shape == "circle": + drawer.ellipse(outlineShape, fill=self.color) + drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) + + # Lilypad + elif shape == "lilypad": + drawer.pieslice(smallerShape, 290, 250, fill=self.color) + + # Pie + elif shape == "pie": + drawer.pieslice(outlineShape, 35, 320, fill=self.color) + + hX, hY = scale(50, self.pxWidth, self.pxHeight, int) # halfline + tX, tY = scale(33, self.pxWidth, self.pxHeight, int) # thirdline + qX, qY = scale(20, self.pxWidth, self.pxHeight, int) # quarterline + + # Path + if shape == "path": + drawer.ellipse(rect, fill=self.color) + rects = { + direction: False + for direction in ( + "up", + "down", + "left", + "right", + ) + } + for cell in self.nearbyCoords(x, y): + if cell not in grid: + continue + if cell[0] == x: + if cell[1] < y: + rects["up"] = True + if cell[1] > y: + rects["down"] = True + if cell[1] == y: + if cell[0] < x: + rects["left"] = True + if cell[0] > x: + rects["right"] = True + + for direction, rect in rects.items(): + if rect: + if direction == "up": + sect = ( + (drawPtX, drawPtY), + (drawPtX + self.pxWidth, drawPtY + hY), + ) + elif direction == "down": + sect = ( + (drawPtX, drawPtY + hY), + ( + drawPtX + self.pxWidth, + drawPtY + self.pxHeight, + ), + ) + elif direction == "left": + sect = ( + (drawPtX, drawPtY), + (drawPtX + hX, drawPtY + self.pxHeight), + ) + elif direction == "right": + sect = ( + (drawPtX + hX, drawPtY), + ( + drawPtX + self.pxWidth, + drawPtY + self.pxHeight, + ), + ) + drawer.rectangle(sect, fill=self.color) + + # Duck + elif shape == "duck": + duckHead = ( + (drawPtX + qX, drawPtY + qY), + (drawPtX + int(qX * 3), drawPtY + int(tY * 2)), + ) + duckBeak = ( + (drawPtX + hX, drawPtY + qY), + (drawPtX + self.pxWidth + qX, drawPtY + int(qY * 3)), + ) + duckWing = ((drawPtX, drawPtY + hY), rect[1]) + duckBody = ( + (drawPtX + int(qX / 4), drawPtY + int(qY * 3)), + (drawPtX + int(tX * 2), drawPtY + self.pxHeight), + ) + drawer.ellipse(duckBody, fill=self.color) + drawer.ellipse(duckHead, fill=self.color) + drawer.pieslice(duckWing, 130, 200, fill=self.color) + drawer.pieslice(duckBeak, 145, 200, fill=self.color) + + # Peace + elif shape == "peace": + line = ( + ( + drawPtX + hX - int(tenthX / 2), + drawPtY + int(tenthY / 2), + ), + ( + drawPtX + hX + int(tenthX / 2), + drawPtY + self.pxHeight - int(tenthY / 2), + ), + ) + drawer.ellipse(outlineShape, fill=self.color) + drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) + drawer.rectangle(line, fill=self.color) + + def slantLine(difference): + return ( + (drawPtX + difference), + (drawPtY + self.pxHeight - qY), + ), ( + (drawPtX + hX), + (drawPtY + hY), + ) + + drawer.line(slantLine(qX), fill=self.color, width=tenthX) + drawer.line(slantLine(self.pxWidth - qX), fill=self.color, width=tenthX) + + for x, y in grid: + drawPtX = x * self.pxWidth + if drawPtX > self.width: + continue + drawPtY = y * self.pxHeight + if drawPtY > self.height: + continue + + if self.customImg: + drawCustomImg() + else: + drawShape() + + if self.shadow: + shadImg = ImageEnhance.Contrast(frame).enhance(0.0) + shadImg = shadImg.filter(ImageFilter.GaussianBlur(5.00)) + shadImg = ImageChops.offset(shadImg, -2, 2) + shadImg.paste(frame, box=(0, 0), mask=frame) + frame = shadImg + if self.showGrid: + drawer = ImageDraw.Draw(frame) + w, h = scale(0.05, self.width, self.height, int) + for x in range(self.pxWidth, self.width, self.pxWidth): + drawer.rectangle( + ((x, 0), (x + w, self.height)), + fill=self.color, + ) + for y in range(self.pxHeight, self.height, self.pxHeight): + drawer.rectangle( + ((0, y), (self.width, y + h)), + fill=self.color, + ) + + return frame + + def gridForTick(self, tick): + """ + Given a tick number over 0, returns a new grid (a set of tuples). + This must compute the previous ticks' grids if not already computed + """ + if tick - 1 not in self.tickGrids: + self.tickGrids[tick - 1] = self.gridForTick(tick - 1) + + lastGrid = self.tickGrids[tick - 1] + + def neighbours(x, y): + return {cell for cell in self.nearbyCoords(x, y) if cell in lastGrid} + + newGrid = set() + # Copy cells from the previous grid if they have 2 or 3 neighbouring cells + # and if they are within the grid or its bleed area (off-canvas area) + for x, y in lastGrid: + if ( + -self.bleedSize > x > self.gridWidth + self.bleedSize + or -self.bleedSize > y > self.gridHeight + self.bleedSize + ): + continue + surrounding = len(neighbours(x, y)) + if surrounding == 2 or surrounding == 3: + newGrid.add((x, y)) + + # Find positions around living cells which must be checked for reproduction + potentialNewCells = { + coordTup + for origin in lastGrid + for coordTup in list(self.nearbyCoords(*origin)) + } + # Check for reproduction + for x, y in potentialNewCells: + if (x, y) in newGrid: + # Ignore non-empty cell + continue + surrounding = len(neighbours(x, y)) + if surrounding == 3: + newGrid.add((x, y)) + + return newGrid + + def savePreset(self): + pr = super().savePreset() + pr["GRID"] = sorted(self.startingGrid) + return pr + + def loadPreset(self, pr, *args): + self.startingGrid = set(pr["GRID"]) + if self.startingGrid: + for widget in self.shiftButtons: + widget.setEnabled(True) + super().loadPreset(pr, *args) + + def nearbyCoords(self, x, y): + yield x + 1, y + 1 + yield x + 1, y - 1 + yield x - 1, y + 1 + yield x - 1, y - 1 + yield x, y + 1 + yield x, y - 1 + yield x + 1, y + yield x - 1, y + + +class ClickGrid(QUndoCommand): + def __init__(self, comp, pos, button): + super().__init__("click %s component #%s" % (comp.name, comp.compPos)) + self.comp = comp + self.pos = [pos] + if button == QtCore.Qt.MouseButton.RightButton: + self.button = 2 + else: + self.button = 1 + + def id(self): + return self.button + + def mergeWith(self, other): + self.pos.extend(other.pos) + return True + + def add(self): + for pos in self.pos[:]: + self.comp.startingGrid.add(pos) + self.comp.update(auto=True) + + def remove(self): + for pos in self.pos[:]: + self.comp.startingGrid.discard(pos) + self.comp.update(auto=True) + + def redo(self): + if self.button == 1: # Left-click + self.add() + elif self.button == 2: # Right-click + self.remove() + + def undo(self): + if self.button == 1: # Left-click + self.remove() + elif self.button == 2: # Right-click + self.add() + + +class ShiftGrid(QUndoCommand): + def __init__(self, comp, direction): + super().__init__("change %s component #%s" % (comp.name, comp.compPos)) + self.comp = comp + self.direction = direction + self.distance = 1 + + def id(self): + return self.direction + + def mergeWith(self, other): + self.distance += other.distance + return True + + def newGrid(self, Xchange, Ychange): + return {(x + Xchange, y + Ychange) for x, y in self.comp.startingGrid} + + def redo(self): + if self.direction == 0: + newGrid = self.newGrid(0, -self.distance) + elif self.direction == 1: + newGrid = self.newGrid(0, self.distance) + elif self.direction == 2: + newGrid = self.newGrid(-self.distance, 0) + elif self.direction == 3: + newGrid = self.newGrid(self.distance, 0) + self.comp.startingGrid = newGrid + self.comp._sendUpdateSignal() + + def undo(self): + if self.direction == 0: + newGrid = self.newGrid(0, self.distance) + elif self.direction == 1: + newGrid = self.newGrid(0, -self.distance) + elif self.direction == 2: + newGrid = self.newGrid(self.distance, 0) + elif self.direction == 3: + newGrid = self.newGrid(-self.distance, 0) + self.comp.startingGrid = newGrid + self.comp._sendUpdateSignal() diff --git a/src/avp/components/life.ui b/src/avp/components/life.ui new file mode 100644 index 0000000..30cf9d0 --- /dev/null +++ b/src/avp/components/life.ui @@ -0,0 +1,405 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + Form + + + + + + + + + + + + Simulation Speed + + + + + + + frames per tick + + + 1 + + + 30 + + + 5 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 16777215 + + + + 255,255,255 + + + + + + + + + + + Grid Scale + + + + + + + 22 + + + 128 + + + 32 + + + + + + + Custom Image + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Image + + + + + + + + + + + 0 + 0 + + + + + 32 + 32 + + + + ... + + + + + + + Color + + + + + + + + 0 + 16777215 + + + + 0,0,0 + + + + + + + + 0 + 0 + + + + + 32 + 32 + + + + + + + false + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Shape + + + + + + + + Path + + + + + Rectangle + + + + + Elliptical + + + + + Circle + + + + + Lilypad + + + + + Pie + + + + + Duck + + + + + Peace + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Shadow + + + + + + + Show Grid + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Up + + + Qt::UpArrow + + + + + + + Down + + + Qt::DownArrow + + + + + + + Left + + + Qt::LeftArrow + + + + + + + Right + + + Qt::RightArrow + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'Ubuntu'; font-size:11pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-weight:600;">Click the preview window to place a cell. Right-click to remove.</span></p> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- A cell with less than 2 neighbours will die from underpopulation</p> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- A cell with more than 3 neighbours will die from overpopulation.</p> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- An empty space surrounded by 3 live cells will cause reproduction.</p></body></html> + + + 80 + + + Qt::NoTextInteraction + + + false + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/avp/components/original.py b/src/avp/components/original.py new file mode 100644 index 0000000..fad797b --- /dev/null +++ b/src/avp/components/original.py @@ -0,0 +1,243 @@ +import numpy +from PIL import Image, ImageDraw +from copy import copy + +from ..component import Component +from ..toolkit.frame import BlankFrame + + +class Component(Component): + name = "Classic Visualizer" + version = "1.0.0" + + def names(*args): + return ["Original Audio Visualization"] + + def properties(self): + return ["pcm"] + + def widget(self, *args): + self.scale = 20 + self.y = 0 + super().widget(*args) + + self.page.comboBox_visLayout.addItem("Classic") + self.page.comboBox_visLayout.addItem("Split") + self.page.comboBox_visLayout.addItem("Bottom") + self.page.comboBox_visLayout.addItem("Top") + self.page.comboBox_visLayout.setCurrentIndex(0) + + self.page.lineEdit_visColor.setText("255,255,255") + + self.trackWidgets( + { + "visColor": self.page.lineEdit_visColor, + "layout": self.page.comboBox_visLayout, + "scale": self.page.spinBox_scale, + "y": self.page.spinBox_y, + "smooth": self.page.spinBox_smooth, + }, + colorWidgets={ + "visColor": self.page.pushButton_visColor, + }, + relativeWidgets=[ + "y", + ], + ) + + def previewRender(self): + spectrum = numpy.fromfunction( + lambda x: float(self.scale) / 2500 * (x - 128) ** 2, + (255,), + dtype="int16", + ) + return self.drawBars( + self.width, self.height, spectrum, self.visColor, self.layout + ) + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + self.smoothConstantDown = 0.08 + 0 if not self.smooth else self.smooth / 15 + self.smoothConstantUp = 0.8 - 0 if not self.smooth else self.smooth / 15 + self.lastSpectrum = None + self.spectrumArray = {} + + for i in range(0, len(self.completeAudioArray), self.sampleSize): + if self.canceled: + break + self.lastSpectrum = self.transformData( + i, + self.completeAudioArray, + self.sampleSize, + self.smoothConstantDown, + self.smoothConstantUp, + self.lastSpectrum, + ) + self.spectrumArray[i] = copy(self.lastSpectrum) + + progress = int(100 * (i / len(self.completeAudioArray))) + if progress >= 100: + progress = 100 + pStr = "Analyzing audio: " + str(progress) + "%" + self.progressBarSetText.emit(pStr) + self.progressBarUpdate.emit(int(progress)) + + def frameRender(self, frameNo): + arrayNo = frameNo * self.sampleSize + return self.drawBars( + self.width, + self.height, + self.spectrumArray[arrayNo], + self.visColor, + self.layout, + ) + + def transformData( + self, + i, + completeAudioArray, + sampleSize, + smoothConstantDown, + smoothConstantUp, + lastSpectrum, + ): + if len(completeAudioArray) < (i + sampleSize): + sampleSize = len(completeAudioArray) - i + + window = numpy.hanning(sampleSize) + data = completeAudioArray[i : i + sampleSize][::1] * window + paddedSampleSize = 2048 + paddedData = numpy.pad(data, (0, paddedSampleSize - sampleSize), "constant") + spectrum = numpy.fft.fft(paddedData) + sample_rate = 44100 + frequencies = numpy.fft.fftfreq(len(spectrum), 1.0 / sample_rate) + + y = abs(spectrum[0 : int(paddedSampleSize / 2) - 1]) + + # filter the noise away + # y[y<80] = 0 + + y = self.scale * numpy.log10(y) + y[numpy.isinf(y)] = 0 + + if lastSpectrum is not None: + lastSpectrum[y < lastSpectrum] = y[ + y < lastSpectrum + ] * smoothConstantDown + lastSpectrum[y < lastSpectrum] * ( + 1 - smoothConstantDown + ) + + lastSpectrum[y >= lastSpectrum] = y[ + y >= lastSpectrum + ] * smoothConstantUp + lastSpectrum[y >= lastSpectrum] * ( + 1 - smoothConstantUp + ) + else: + lastSpectrum = y + + x = frequencies[0 : int(paddedSampleSize / 2) - 1] + + return lastSpectrum + + def drawBars(self, width, height, spectrum, color, layout): + vH = height - height / 8 + bF = width / 64 + bH = bF / 2 + bQ = bF / 4 + imTop = BlankFrame(width, height) + draw = ImageDraw.Draw(imTop) + r, g, b = color + color2 = (r, g, b, 125) + + bP = height / 1200 + + for j in range(0, 63): + x0 = bH + j * bF + y0 = vH + bQ + y1 = vH + bQ - spectrum[j * 4] * bP - bH + x1 = bH + j * bF + bF + draw.rectangle( + ( + x0, + y0 if y0 < y1 else y1, + x1 if x1 > x0 else x0, + y1 if y0 < y1 else y0, + ), + fill=color2, + ) + + x0 = bH + bQ + j * bF + y0 = vH + x1 = bH + bQ + j * bF + bH + y1 = vH - spectrum[j * 4] * bP + draw.rectangle( + ( + x0, + y0 if y0 < y1 else y1, + x1 if x1 > x0 else x0, + y1 if y0 < y1 else y0, + ), + fill=color, + ) + + imBottom = imTop.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + + im = BlankFrame(width, height) + + if layout == 0: # Classic + y = self.y - int(height / 100 * 43) + im.paste(imTop, (0, y), mask=imTop) + y = self.y + int(height / 100 * 43) + im.paste(imBottom, (0, y), mask=imBottom) + + if layout == 1: # Split + y = self.y + int(height / 100 * 10) + im.paste(imTop, (0, y), mask=imTop) + y = self.y - int(height / 100 * 10) + im.paste(imBottom, (0, y), mask=imBottom) + + if layout == 2: # Bottom + y = self.y + int(height / 100 * 10) + im.paste(imTop, (0, y), mask=imTop) + + if layout == 3: # Top + y = self.y - int(height / 100 * 10) + im.paste(imBottom, (0, y), mask=imBottom) + + return im + + def command(self, arg): + if "=" in arg: + key, arg = arg.split("=", 1) + try: + if key == "color": + self.page.lineEdit_visColor.setText(arg) + return + elif key == "layout": + if arg == "classic": + self.page.comboBox_visLayout.setCurrentIndex(0) + elif arg == "split": + self.page.comboBox_visLayout.setCurrentIndex(1) + elif arg == "bottom": + self.page.comboBox_visLayout.setCurrentIndex(2) + elif arg == "top": + self.page.comboBox_visLayout.setCurrentIndex(3) + return + elif key == "scale": + arg = int(arg) + self.page.spinBox_scale.setValue(arg) + return + elif key == "y": + arg = int(arg) + self.page.spinBox_y.setValue(arg) + return + except ValueError: + print("You must enter a number.") + quit(1) + super().command(arg) + + def commandHelp(self): + print("Give a layout name:\n layout=[classic/split/bottom/top]") + print("Specify a color:\n color=255,255,255") + print("Visualizer scale (20 is default):\n scale=number") + print("Y position:\n y=number") diff --git a/src/avp/components/original.ui b/src/avp/components/original.ui new file mode 100644 index 0000000..c7b7e22 --- /dev/null +++ b/src/avp/components/original.ui @@ -0,0 +1,243 @@ + + + Form + + + + 0 + 0 + 586 + 178 + + + + + 180 + 0 + + + + Form + + + + + + 4 + + + + + + 0 + 0 + + + + Layout + + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + Color + + + + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + Y + + + + + + + QAbstractSpinBox::UpDownArrows + + + -5000 + + + 5000 + + + 10 + + + 0 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + 4 + + + + + Scale + + + + + + + QAbstractSpinBox::PlusMinus + + + 1 + + + 20 + + + + + + + Qt::Horizontal + + + QSizePolicy::Expanding + + + + 40 + 20 + + + + + + + + + + QLayout::SetDefaultConstraint + + + 4 + + + + + Sensitivity + + + + + + + 5 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/avp/components/sound.py b/src/avp/components/sound.py new file mode 100644 index 0000000..2df8e38 --- /dev/null +++ b/src/avp/components/sound.py @@ -0,0 +1,77 @@ +from PyQt6 import QtGui, QtCore, QtWidgets +import os + +from ..component import Component +from ..toolkit.frame import BlankFrame + + +class Component(Component): + name = "Sound" + version = "1.0.0" + + def widget(self, *args): + super().widget(*args) + self.page.pushButton_sound.clicked.connect(self.pickSound) + self.trackWidgets( + { + "sound": self.page.lineEdit_sound, + "chorus": self.page.checkBox_chorus, + "delay": self.page.spinBox_delay, + "volume": self.page.spinBox_volume, + }, + commandArgs={ + "sound": None, + }, + ) + + def properties(self): + props = ["static", "audio"] + if not os.path.exists(self.sound): + props.append("error") + return props + + def error(self): + if not self.sound: + return "No audio file selected." + if not os.path.exists(self.sound): + return "The audio file selected no longer exists!" + + def audio(self): + params = {} + if self.delay != 0.0: + params["adelay"] = "=%s" % str(int(self.delay * 1000.00)) + if self.chorus: + params["chorus"] = "=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3" + if self.volume != 1.0: + params["volume"] = "=%s:replaygain_noclip=0" % str(self.volume) + + return (self.sound, params) + + def pickSound(self): + sndDir = self.settings.value("componentDir", os.path.expanduser("~")) + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.page, + "Choose Sound", + sndDir, + "Audio Files (%s)" % " ".join(self.core.audioFormats), + ) + if filename: + self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False + self.page.lineEdit_sound.setText(filename) + self.mergeUndo = True + + def commandHelp(self): + print("Path to audio file:\n path=/filepath/to/sound.ogg") + + def command(self, arg): + if "=" in arg: + key, arg = arg.split("=", 1) + if key == "path": + if "*%s" % os.path.splitext(arg)[1] not in self.core.audioFormats: + print("Not a supported audio format") + quit(1) + self.page.lineEdit_sound.setText(arg) + return + + super().command(arg) diff --git a/src/avp/components/sound.ui b/src/avp/components/sound.ui new file mode 100644 index 0000000..4c11332 --- /dev/null +++ b/src/avp/components/sound.ui @@ -0,0 +1,172 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + Form + + + + + + 4 + + + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Audio File + + + + + + + + 1 + 0 + + + + + + + + + 0 + 0 + + + + + 1 + 0 + + + + + 32 + 32 + + + + ... + + + + 32 + 32 + + + + + + + + + + + + + + Volume + + + + + + + x + + + 10.000000000000000 + + + 0.100000000000000 + + + 1.000000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Delay + + + + + + + s + + + 9999999.990000000223517 + + + 0.500000000000000 + + + + + + + Chorus + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/avp/components/spectrum.py b/src/avp/components/spectrum.py new file mode 100644 index 0000000..062ebc7 --- /dev/null +++ b/src/avp/components/spectrum.py @@ -0,0 +1,368 @@ +from PIL import Image +from PyQt6 import QtGui, QtCore, QtWidgets +import os +import math +import subprocess +import time +import logging + +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit import checkOutput, connectWidget +from ..toolkit.ffmpeg import ( + openPipe, + closePipe, + getAudioDuration, + FfmpegVideo, + exampleSound, +) + + +log = logging.getLogger("AVP.Components.Spectrum") + + +class Component(Component): + name = "Spectrum" + version = "1.0.1" + + def widget(self, *args): + self.previewFrame = None + super().widget(*args) + self._image = BlankFrame(self.width, self.height) + self.chunkSize = 4 * self.width * self.height + self.changedOptions = True + self.previewSize = (214, 120) + self.previewPipe = None + + if hasattr(self.parent, "lineEdit_audioFile"): + # update preview when audio file changes (if genericPreview is off) + self.parent.lineEdit_audioFile.textChanged.connect(self.update) + + self.trackWidgets( + { + "filterType": self.page.comboBox_filterType, + "window": self.page.comboBox_window, + "mode": self.page.comboBox_mode, + "amplitude": self.page.comboBox_amplitude0, + "amplitude1": self.page.comboBox_amplitude1, + "amplitude2": self.page.comboBox_amplitude2, + "display": self.page.comboBox_display, + "zoom": self.page.spinBox_zoom, + "tc": self.page.spinBox_tc, + "x": self.page.spinBox_x, + "y": self.page.spinBox_y, + "mirror": self.page.checkBox_mirror, + "draw": self.page.checkBox_draw, + "scale": self.page.spinBox_scale, + "color": self.page.comboBox_color, + "compress": self.page.checkBox_compress, + "mono": self.page.checkBox_mono, + "hue": self.page.spinBox_hue, + }, + relativeWidgets=[ + "x", + "y", + ], + ) + for widget in self._trackedWidgets.values(): + connectWidget(widget, lambda: self.changed()) + + def changed(self): + self.changedOptions = True + + def update(self): + filterType = self.page.comboBox_filterType.currentIndex() + self.page.stackedWidget.setCurrentIndex(filterType) + if filterType == 3: + self.page.spinBox_hue.setEnabled(False) + else: + self.page.spinBox_hue.setEnabled(True) + if filterType == 2 or filterType == 4: + self.page.checkBox_mono.setEnabled(False) + else: + self.page.checkBox_mono.setEnabled(True) + + def previewRender(self): + changedSize = self.updateChunksize() + if ( + not changedSize + and not self.changedOptions + and self.previewFrame is not None + ): + log.debug("Spectrum #%s is reusing old preview frame" % self.compPos) + return self.previewFrame + + frame = self.getPreviewFrame() + self.changedOptions = False + if not frame: + log.warning("Spectrum #%s failed to create a preview frame" % self.compPos) + self.previewFrame = None + return BlankFrame(self.width, self.height) + else: + self.previewFrame = frame + return frame + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + if self.previewPipe is not None: + self.previewPipe.wait() + self.updateChunksize() + w, h = scale(self.scale, self.width, self.height, str) + self.video = FfmpegVideo( + inputPath=self.audioFile, + filter_=self.makeFfmpegFilter(), + width=w, + height=h, + 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 self.finalizeFrame(self.video.frame(frameNo)) + + def postFrameRender(self): + closePipe(self.video.pipe) + + def getPreviewFrame(self): + genericPreview = self.settings.value("pref_genericPreview") + startPt = 0 + if not genericPreview: + inputFile = self.parent.lineEdit_audioFile.text() + if not inputFile or not os.path.exists(inputFile): + return + duration = getAudioDuration(inputFile) + if not duration: + return + startPt = duration / 3 + + command = [ + self.core.FFMPEG_BIN, + "-thread_queue_size", + "512", + "-r", + str(self.settings.value("outputFrameRate")), + "-ss", + "{0:.3f}".format(startPt), + "-i", + self.core.junkStream if genericPreview else inputFile, + "-f", + "image2pipe", + "-pix_fmt", + "rgba", + ] + command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt)) + command.extend( + [ + "-an", + "-s:v", + "%sx%s" % scale(self.scale, self.width, self.height, str), + "-codec:v", + "rawvideo", + "-", + "-frames:v", + "1", + ] + ) + + if self.core.logEnabled: + logFilename = os.path.join( + self.core.logDir, "preview_%s.log" % str(self.compPos) + ) + log.debug("Creating FFmpeg process (log at %s)" % logFilename) + with open(logFilename, "w") as logf: + logf.write(" ".join(command) + "\n\n") + with open(logFilename, "a") as logf: + self.previewPipe = openPipe( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=logf, + bufsize=10**8, + ) + else: + self.previewPipe = openPipe( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=10**8, + ) + byteFrame = self.previewPipe.stdout.read(self.chunkSize) + closePipe(self.previewPipe) + + frame = self.finalizeFrame(byteFrame) + return frame + + def makeFfmpegFilter(self, preview=False, startPt=0): + """Makes final FFmpeg filter command""" + + def getFilterComplexCommand(): + """Inner function that creates the final, complex part of the filter command""" + nonlocal self + genericPreview = self.settings.value("pref_genericPreview") + + def getFilterComplexCommandForType(): + """Determine portion of filter command that changes depending on selected type""" + nonlocal self + if preview: + w, h = self.previewSize + else: + w, h = (self.width, self.height) + color = self.page.comboBox_color.currentText().lower() + + if self.filterType == 0: # Spectrum + if self.amplitude == 0: + amplitude = "sqrt" + elif self.amplitude == 1: + amplitude = "cbrt" + elif self.amplitude == 2: + amplitude = "4thrt" + elif self.amplitude == 3: + amplitude = "5thrt" + elif self.amplitude == 4: + amplitude = "lin" + elif self.amplitude == 5: + amplitude = "log" + filter_ = ( + f"showspectrum=s={w}x{h}:" + "slide=scroll:" + f"win_func={self.page.comboBox_window.currentText()}:" + f"color={color}:" + f"scale={amplitude}," + "colorkey=color=black:" + "similarity=0.1:blend=0.5" + ) + elif self.filterType == 1: # Histogram + if self.amplitude1 == 0: + amplitude = "log" + elif self.amplitude1 == 1: + amplitude = "lin" + if self.display == 0: + display = "log" + elif self.display == 1: + display = "sqrt" + elif self.display == 2: + display = "cbrt" + elif self.display == 3: + display = "lin" + elif self.display == 4: + display = "rlog" + filter_ = ( + f'ahistogram=r={str(self.settings.value("outputFrameRate"))}:' + f"s={w}x{h}:" + "dmode=separate:" + f"ascale={amplitude}:" + f"scale={display}" + ) + elif self.filterType == 2: # Vector Scope + if self.amplitude2 == 0: + amplitude = "log" + elif self.amplitude2 == 1: + amplitude = "sqrt" + elif self.amplitude2 == 2: + amplitude = "cbrt" + elif self.amplitude2 == 3: + amplitude = "lin" + m = self.page.comboBox_mode.currentText() + filter_ = ( + f"avectorscope=s={w}x{h}:" + f'draw={"line" if self.draw else "dot"}:' + f"m={m}:" + f"scale={amplitude}:" + f"zoom={str(self.zoom)}" + ) + elif self.filterType == 3: # Musical Scale + filter_ = ( + f'showcqt=r={str(self.settings.value("outputFrameRate"))}:' + f"s={w}x{h}:" + "count=30:" + "text=0:" + f"tc={str(self.tc)}," + "colorkey=color=black:" + "similarity=0.1:blend=0.5" + ) + elif self.filterType == 4: # Phase + filter_ = ( + f'aphasemeter=r={str(self.settings.value("outputFrameRate"))}:' + f"s={w}x{h}:" + "video=1 [atrash][vtmp1]; " + "[atrash] anullsink; " + "[vtmp1] colorkey=color=black:" + "similarity=0.1:blend=0.5, " + "crop=in_w/8:in_h:(in_w/8)*7:0 " + ) + return filter_ + + if self.filterType < 2: + exampleSnd = exampleSound("freq") + elif self.filterType == 2 or self.filterType == 4: + exampleSnd = exampleSound("stereo") + elif self.filterType == 3: + exampleSnd = exampleSound("white") + compression = "compand=gain=4," if self.compress else "" + aformat = ( + "aformat=channel_layouts=mono," + if self.mono and self.filterType not in (2, 4) + else "" + ) + filter_ = getFilterComplexCommandForType() + hflip = "hflip, " if self.mirror else "" + trim = ( + "trim=start=%s:end=%s, " + % ( + "{0:.3f}".format(startPt + 12), + "{0:.3f}".format(startPt + 12.5), + ) + if preview + else "" + ) + scale_ = "scale=%sx%s" % scale(self.scale, self.width, self.height, str) + hue = ( + ", hue=h=%s:s=10" % str(self.hue) + if self.hue > 0 and self.filterType != 3 + else "" + ) + convolution = ( + ", convolution=-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2" + if self.filterType == 3 + else "" + ) + + return ( + f"{exampleSnd if preview and genericPreview else '[0:a] '}" + f"{compression}{aformat}{filter_} [v1]; " + f"[v1] {hflip}{trim}{scale_}{hue}{convolution} [v]" + ) + + return [ + "-filter_complex", + getFilterComplexCommand(), + "-map", + "[v]", + ] + + def updateChunksize(self): + width, height = scale(self.scale, self.width, self.height, int) + oldChunkSize = int(self.chunkSize) + self.chunkSize = 4 * width * height + changed = self.chunkSize != oldChunkSize + return changed + + def finalizeFrame(self, imageData): + try: + image = Image.frombytes( + "RGBA", + scale(self.scale, self.width, self.height, int), + imageData, + ) + self._image = image + except ValueError: + image = self._image + + frame = BlankFrame(self.width, self.height) + frame.paste(image, box=(self.x, self.y)) + return frame diff --git a/src/avp/components/spectrum.ui b/src/avp/components/spectrum.ui new file mode 100644 index 0000000..c6a8a15 --- /dev/null +++ b/src/avp/components/spectrum.ui @@ -0,0 +1,946 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + + 0 + 0 + + + + + 0 + 197 + + + + Form + + + + + + 4 + + + + + + + + + + + 0 + 0 + + + + Type + + + + + + + + Spectrum + + + + + Histogram + + + + + Vector Scope + + + + + Musical Scale + + + + + Phase + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + -10000 + + + 10000 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + -10000 + + + 10000 + + + 0 + + + + + + + + + + + Compress + + + + + + + Mono + + + + + + + Mirror + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Hue + + + 4 + + + + + + + ° + + + 359 + + + + + + + + 0 + 0 + + + + Scale + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QAbstractSpinBox::UpDownArrows + + + % + + + 10 + + + 400 + + + 100 + + + + + + + + + + 0 + 0 + + + + false + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + + + + 0 + 0 + 561 + 66 + + + + + QLayout::SetMaximumSize + + + 0 + + + + + QLayout::SetDefaultConstraint + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Window + + + 4 + + + + + + + + hann + + + + + gauss + + + + + tukey + + + + + dolph + + + + + cauchy + + + + + parzen + + + + + poisson + + + + + rect + + + + + bartlett + + + + + hanning + + + + + hamming + + + + + blackman + + + + + welch + + + + + flattop + + + + + bharris + + + + + bnuttall + + + + + lanczos + + + + + + + + + 0 + 0 + + + + Amplitude + + + 4 + + + + + + + + Square root + + + + + Cubic root + + + + + 4thrt + + + + + 5thrt + + + + + Linear + + + + + Logarithmic + + + + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 10 + 20 + + + + + + + + + + + + + 0 + 0 + + + + Color + + + 4 + + + + + + + + Channel + + + + + Intensity + + + + + Rainbow + + + + + Moreland + + + + + Nebulae + + + + + Fire + + + + + Fiery + + + + + Fruit + + + + + Cool + + + + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 10 + 20 + + + + + + + + + + + + + + -1 + -1 + 561 + 31 + + + + + + + + + + 0 + 0 + + + + Display Scale + + + 4 + + + + + + + + Logarithmic + + + + + Square root + + + + + Cubic root + + + + + Linear + + + + + Reverse Log + + + + + + + + + 0 + 0 + + + + Amplitude + + + 4 + + + + + + + + Logarithmic + + + + + Linear + + + + + + + + Qt::Horizontal + + + QSizePolicy::Minimum + + + + 40 + 20 + + + + + + + + + + + + + + -1 + -1 + 585 + 64 + + + + + + + + + Mode + + + + + + + + lissajous + + + + + lissajous_xy + + + + + polar + + + + + + + + + 0 + 0 + + + + Amplitude + + + 4 + + + + + + + + Linear + + + + + Square root + + + + + Cubic root + + + + + Logarithmic + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + 0 + 0 + + + + Zoom + + + 4 + + + + + + + 1 + + + 10 + + + + + + + Line + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + 0 + 0 + 561 + 31 + + + + + + + + + + 0 + 0 + + + + Timeclamp + + + 4 + + + + + + + s + + + 3 + + + 0.002000000000000 + + + 1.000000000000000 + + + 0.010000000000000 + + + 0.017000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + 0 + 0 + 551 + 31 + + + + + + + + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 10 + + + + + + + + + diff --git a/src/avp/components/text.py b/src/avp/components/text.py new file mode 100644 index 0000000..40c981a --- /dev/null +++ b/src/avp/components/text.py @@ -0,0 +1,218 @@ +from PIL import ImageEnhance, ImageFilter, ImageChops +from PyQt6.QtGui import QColor, QFont +from PyQt6 import QtGui, QtCore, QtWidgets +import os +import logging + +from ..component import Component +from ..toolkit.frame import FramePainter, PaintColor + +log = logging.getLogger("AVP.Components.Text") + + +class Component(Component): + name = "Title Text" + version = "1.0.1" + + def widget(self, *args): + super().widget(*args) + self.title = "Text" + self.alignment = 1 + self.titleFont = QFont() + self.fontSize = self.height / 13.5 + + self.page.comboBox_textAlign.addItem("Left") + self.page.comboBox_textAlign.addItem("Middle") + self.page.comboBox_textAlign.addItem("Right") + self.page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) + self.page.spinBox_fontSize.setValue(int(self.fontSize)) + self.page.lineEdit_title.setText(self.title) + self.page.pushButton_center.clicked.connect(self.centerXY) + + self.page.fontComboBox_titleFont.currentFontChanged.connect( + self._sendUpdateSignal + ) + # The QFontComboBox must be connected directly to the Qt Signal + # which triggers the preview to update. + # This unfortunately makes changing the font into a non-undoable action. + # Must be something broken in the conversion to a ComponentAction + + self.trackWidgets( + { + "textColor": self.page.lineEdit_textColor, + "title": self.page.lineEdit_title, + "alignment": self.page.comboBox_textAlign, + "fontSize": self.page.spinBox_fontSize, + "xPosition": self.page.spinBox_xTextAlign, + "yPosition": self.page.spinBox_yTextAlign, + "fontStyle": self.page.comboBox_fontStyle, + "stroke": self.page.spinBox_stroke, + "strokeColor": self.page.lineEdit_strokeColor, + "shadow": self.page.checkBox_shadow, + "shadX": self.page.spinBox_shadX, + "shadY": self.page.spinBox_shadY, + "shadBlur": self.page.spinBox_shadBlur, + }, + colorWidgets={ + "textColor": self.page.pushButton_textColor, + "strokeColor": self.page.pushButton_strokeColor, + }, + relativeWidgets=[ + "xPosition", + "yPosition", + "fontSize", + "stroke", + "shadX", + "shadY", + "shadBlur", + ], + ) + self.centerXY() + + def update(self): + self.titleFont = self.page.fontComboBox_titleFont.currentFont() + if self.page.checkBox_shadow.isChecked(): + self.page.label_shadX.setHidden(False) + self.page.spinBox_shadX.setHidden(False) + self.page.spinBox_shadY.setHidden(False) + self.page.label_shadBlur.setHidden(False) + self.page.spinBox_shadBlur.setHidden(False) + else: + self.page.label_shadX.setHidden(True) + self.page.spinBox_shadX.setHidden(True) + self.page.spinBox_shadY.setHidden(True) + self.page.label_shadBlur.setHidden(True) + self.page.spinBox_shadBlur.setHidden(True) + + def centerXY(self): + self.setRelativeWidget("xPosition", 0.5) + self.setRelativeWidget("yPosition", 0.521) + + def getXY(self): + """Returns true x, y after considering alignment settings""" + fm = QtGui.QFontMetrics(self.titleFont) + text_width = fm.boundingRect(self.title).width() + x = self.pixelValForAttr("xPosition") + + if self.alignment == 1: # Middle + offset = int(text_width / 2) + elif self.alignment == 2: # Right + offset = text_width + else: + raise ValueError(f"Alignment value {self.alignment} unknown") + + x -= offset + + return x, self.yPosition + + def loadPreset(self, pr, *args): + super().loadPreset(pr, *args) + + font = QFont() + font.fromString(pr["titleFont"]) + self.page.fontComboBox_titleFont.setCurrentFont(font) + + def savePreset(self): + saveValueStore = super().savePreset() + saveValueStore["titleFont"] = self.titleFont.toString() + return saveValueStore + + def previewRender(self): + return self.addText(self.width, self.height) + + def properties(self): + props = ["static"] + if not self.title: + props.append("error") + return props + + def error(self): + return "No text provided." + + def frameRender(self, frameNo): + return self.addText(self.width, self.height) + + def addText(self, width, height): + font = self.titleFont + font.setPixelSize(self.fontSize) + font.setStyle(QFont.Style.StyleNormal) + font.setWeight(QFont.Weight.Normal) + font.setCapitalization(QFont.Capitalization.MixedCase) + if self.fontStyle == 1: + font.setWeight(QFont.Weight.DemiBold) + if self.fontStyle == 2: + font.setWeight(QFont.Weight.Bold) + elif self.fontStyle == 3: + font.setStyle(QFont.Style.StyleItalic) + elif self.fontStyle == 4: + font.setWeight(QFont.Weight.Bold) + font.setStyle(QFont.Style.StyleItalic) + elif self.fontStyle == 5: + font.setStyle(QFont.Style.StyleOblique) + elif self.fontStyle == 6: + font.setCapitalization(QFont.Capitalization.SmallCaps) + + image = FramePainter(width, height) + x, y = self.getXY() + log.debug("Text position translates to %s, %s", x, y) + if self.stroke > 0: + outliner = QtGui.QPainterPathStroker() + outliner.setWidth(self.stroke) + path = QtGui.QPainterPath() + if self.fontStyle == 6: + # PathStroker ignores smallcaps so we need this weird hack + path.addText(x, y, font, self.title[0]) + fm = QtGui.QFontMetrics(font) + newX = x + fm.boundingRect(self.title[0]).width() + strokeFont = self.page.fontComboBox_titleFont.currentFont() + strokeFont.setCapitalization(QFont.Capitalization.SmallCaps) + strokeFont.setPixelSize(int((self.fontSize / 7) * 5)) + strokeFont.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 139) + path.addText(newX, y, strokeFont, self.title[1:]) + else: + path.addText(x, y, font, self.title) + path = outliner.createStroke(path) + image.setPen(QtCore.Qt.PenStyle.NoPen) + image.setBrush(PaintColor(*self.strokeColor)) + image.drawPath(path) + + image.setFont(font) + image.setPen(self.textColor) + image.drawText(x, y, self.title) + + # turn QImage into Pillow frame + frame = image.finalize() + if self.shadow: + shadImg = ImageEnhance.Contrast(frame).enhance(0.0) + shadImg = shadImg.filter(ImageFilter.GaussianBlur(self.shadBlur)) + shadImg = ImageChops.offset(shadImg, self.shadX, self.shadY) + shadImg.paste(frame, box=(0, 0), mask=frame) + frame = shadImg + + return frame + + def commandHelp(self): + print("Enter a string to use as centred white text:") + print(' "title=User Error"') + print("Specify a text color:\n color=255,255,255") + print("Set custom x, y position:\n x=500 y=500") + + def command(self, arg): + if "=" in arg: + key, arg = arg.split("=", 1) + if key == "color": + self.page.lineEdit_textColor.setText(arg) + return + elif key == "size": + self.page.spinBox_fontSize.setValue(int(arg)) + return + elif key == "x": + self.page.spinBox_xTextAlign.setValue(int(arg)) + return + elif key == "y": + self.page.spinBox_yTextAlign.setValue(int(arg)) + return + elif key == "title": + self.page.lineEdit_title.setText(arg) + return + super().command(arg) diff --git a/src/avp/components/text.ui b/src/avp/components/text.ui new file mode 100644 index 0000000..b62e0ed --- /dev/null +++ b/src/avp/components/text.ui @@ -0,0 +1,671 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + Form + + + + + + 6 + + + QLayout::SetDefaultConstraint + + + 4 + + + + + + + Title + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + Testing New GUI + + + + + + + + 0 + 0 + + + + Font + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + + + + + + 0 + + + + + + 0 + 0 + + + + Text Layout + + + + + + + + 0 + 0 + + + + + 100 + 16777215 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + Center Text + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 50 + 16777215 + + + + + 0 + 0 + + + + 0 + + + 999999999 + + + 0 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 50 + 16777215 + + + + 999999999 + + + + + + + + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + Text Color + + + + + + + + 0 + 0 + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + Font Size + + + + + + + + 0 + 0 + + + + + + + + + + 1 + + + 500 + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + Font Style + + + + + + + + Normal + + + + + Semi-Bold + + + + + Bold + + + + + Italic + + + + + Bold Italic + + + + + Faux Italic + + + + + Small Caps + + + + + + + + + + + + + 0 + 0 + + + + + 0 + 16777215 + + + + Qt::NoFocus + + + 255,255,255 + + + + + + + + 0 + 0 + + + + Stroke + + + + + + + + 0 + 0 + + + + px + + + + + + + + 0 + 0 + + + + Stroke Color + + + + + + + + 0 + 0 + + + + + 0 + 16777215 + + + + Qt::NoFocus + + + 0,0,0 + + + + + + + + 0 + 0 + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + 0 + 0 + + + + Shadow + + + + + + + + 0 + 0 + + + + Shadow Offset + + + + + + + + 0 + 0 + + + + -1000 + + + 1000 + + + -4 + + + + + + + + 0 + 0 + + + + -1000 + + + 1000 + + + 8 + + + + + + + + 0 + 0 + + + + Shadow Blur + + + + + + + + 0 + 0 + + + + 99.000000000000000 + + + 0.100000000000000 + + + 5.000000000000000 + + + + + + + Qt::Horizontal + + + QSizePolicy::Minimum + + + + 40 + 20 + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/avp/components/video.py b/src/avp/components/video.py new file mode 100644 index 0000000..65a05af --- /dev/null +++ b/src/avp/components/video.py @@ -0,0 +1,254 @@ +from PIL import Image +from PyQt6 import QtGui, QtCore, QtWidgets +import os +import math +import subprocess +import logging + +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo +from ..toolkit import checkOutput + + +log = logging.getLogger("AVP.Components.Video") + + +class Component(Component): + name = "Video" + version = "1.0.0" + + def widget(self, *args): + self.videoPath = "" + self.badAudio = False + self.x = 0 + self.y = 0 + self.loopVideo = False + super().widget(*args) + self._image = BlankFrame(self.width, self.height) + self.page.pushButton_video.clicked.connect(self.pickVideo) + self.trackWidgets( + { + "videoPath": self.page.lineEdit_video, + "loopVideo": self.page.checkBox_loop, + "useAudio": self.page.checkBox_useAudio, + "distort": self.page.checkBox_distort, + "scale": self.page.spinBox_scale, + "volume": self.page.spinBox_volume, + "xPosition": self.page.spinBox_x, + "yPosition": self.page.spinBox_y, + }, + presetNames={ + "videoPath": "video", + "loopVideo": "loop", + "xPosition": "x", + "yPosition": "y", + }, + relativeWidgets=[ + "xPosition", + "yPosition", + ], + ) + + def update(self): + if self.page.checkBox_useAudio.isChecked(): + self.page.label_volume.setEnabled(True) + self.page.spinBox_volume.setEnabled(True) + else: + self.page.label_volume.setEnabled(False) + self.page.spinBox_volume.setEnabled(False) + + 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 properties(self): + props = [] + outputFile = None + if hasattr(self.parent, "lineEdit_outputFile"): + # check only happens in GUI mode + outputFile = self.parent.lineEdit_outputFile.text() + + if not self.videoPath: + self.lockError("There is no video selected.") + elif not os.path.exists(self.videoPath): + self.lockError("The video selected does not exist!") + elif outputFile and os.path.realpath(self.videoPath) == os.path.realpath( + outputFile + ): + self.lockError("Input and output paths match.") + + if self.useAudio: + props.append("audio") + if not testAudioStream(self.videoPath) and self.error() is None: + self.lockError("Could not identify an audio stream in this video.") + + return props + + def audio(self): + params = {} + if self.volume != 1.0: + params["volume"] = "=%s:replaygain_noclip=0" % str(self.volume) + return (self.videoPath, params) + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + self.updateChunksize() + 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, + ) + if os.path.exists(self.videoPath) + else None + ) + + def frameRender(self, frameNo): + if FfmpegVideo.threadError is not None: + raise FfmpegVideo.threadError + return self.finalizeFrame(self.video.frame(frameNo)) + + def postFrameRender(self): + closePipe(self.video.pipe) + + def pickVideo(self): + imgDir = self.settings.value("componentDir", os.path.expanduser("~")) + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.page, + "Choose Video", + imgDir, + "Video Files (%s)" % " ".join(self.core.videoFormats), + ) + if filename: + self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False + self.page.lineEdit_video.setText(filename) + self.mergeUndo = True + + def getPreviewFrame(self, width, height): + if not self.videoPath or not os.path.exists(self.videoPath): + return + + command = [ + self.core.FFMPEG_BIN, + "-thread_queue_size", + "512", + "-i", + self.videoPath, + "-f", + "image2pipe", + "-pix_fmt", + "rgba", + ] + command.extend(self.makeFfmpegFilter()) + command.extend( + [ + "-codec:v", + "rawvideo", + "-", + "-ss", + "90", + "-frames:v", + "1", + ] + ) + + if self.core.logEnabled: + logFilename = os.path.join( + self.core.logDir, "preview_%s.log" % str(self.compPos) + ) + log.debug("Creating ffmpeg process (log at %s)" % logFilename) + with open(logFilename, "w") as logf: + logf.write(" ".join(command) + "\n\n") + with open(logFilename, "a") as logf: + pipe = openPipe( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=logf, + bufsize=10**8, + ) + else: + pipe = openPipe( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=10**8, + ) + + byteFrame = pipe.stdout.read(self.chunkSize) + closePipe(pipe) + + 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) + else: + width, height = self.width, self.height + self.chunkSize = 4 * width * height + + def command(self, arg): + if "=" in arg: + key, arg = arg.split("=", 1) + if key == "path" and os.path.exists(arg): + if "*%s" % os.path.splitext(arg)[1] in self.core.videoFormats: + self.page.lineEdit_video.setText(arg) + self.page.spinBox_scale.setValue(100) + self.page.checkBox_loop.setChecked(True) + return + else: + print("Not a supported video format") + quit(1) + elif arg == "audio": + if not self.page.lineEdit_video.text(): + print("'audio' option must follow a video selection") + quit(1) + self.page.checkBox_useAudio.setChecked(True) + return + super().command(arg) + + def commandHelp(self): + 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, + ) + self._image = image + except ValueError: + # use last good frame + image = self._image + + 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: + frame = image + return frame diff --git a/src/avp/components/video.ui b/src/avp/components/video.ui new file mode 100644 index 0000000..08d15d3 --- /dev/null +++ b/src/avp/components/video.ui @@ -0,0 +1,328 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + + 0 + 0 + + + + + 0 + 197 + + + + Form + + + + + + 4 + + + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Video + + + + + + + + 1 + 0 + + + + + + + + + 0 + 0 + + + + + 1 + 0 + + + + + 32 + 32 + + + + ... + + + + 32 + 32 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + -10000 + + + 10000 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + -10000 + + + 10000 + + + 0 + + + + + + + + + + + + + Loop + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Distort by scale + + + + + + + Scale + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QAbstractSpinBox::UpDownArrows + + + % + + + 10 + + + 400 + + + 100 + + + + + + + + + + + Use Audio + + + + + + + Volume + + + + + + + + 0 + 0 + + + + x + + + 0.000000000000000 + + + 10.000000000000000 + + + 0.100000000000000 + + + 1.000000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/avp/components/waveform.py b/src/avp/components/waveform.py new file mode 100644 index 0000000..7dc0b99 --- /dev/null +++ b/src/avp/components/waveform.py @@ -0,0 +1,230 @@ +from PIL import Image +from PyQt6 import QtGui, QtCore, QtWidgets +from PyQt6.QtGui import QColor +import os +import math +import subprocess +import logging + +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit import checkOutput +from ..toolkit.ffmpeg import ( + openPipe, + closePipe, + getAudioDuration, + FfmpegVideo, + exampleSound, +) + + +log = logging.getLogger("AVP.Components.Waveform") + + +class Component(Component): + name = "Waveform" + version = "1.0.0" + + def widget(self, *args): + super().widget(*args) + self._image = BlankFrame(self.width, self.height) + + self.page.lineEdit_color.setText("255,255,255") + + if hasattr(self.parent, "lineEdit_audioFile"): + self.parent.lineEdit_audioFile.textChanged.connect(self.update) + + self.trackWidgets( + { + "color": self.page.lineEdit_color, + "mode": self.page.comboBox_mode, + "amplitude": self.page.comboBox_amplitude, + "x": self.page.spinBox_x, + "y": self.page.spinBox_y, + "mirror": self.page.checkBox_mirror, + "scale": self.page.spinBox_scale, + "opacity": self.page.spinBox_opacity, + "compress": self.page.checkBox_compress, + "mono": self.page.checkBox_mono, + }, + colorWidgets={ + "color": self.page.pushButton_color, + }, + relativeWidgets=[ + "x", + "y", + ], + ) + + 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() + w, h = scale(self.scale, self.width, self.height, str) + self.video = FfmpegVideo( + inputPath=self.audioFile, + filter_=self.makeFfmpegFilter(), + width=w, + height=h, + chunkSize=self.chunkSize, + frameRate=int(self.settings.value("outputFrameRate")), + parent=self.parent, + component=self, + debug=True, + ) + + def frameRender(self, frameNo): + if FfmpegVideo.threadError is not None: + raise FfmpegVideo.threadError + return self.finalizeFrame(self.video.frame(frameNo)) + + def postFrameRender(self): + closePipe(self.video.pipe) + + def getPreviewFrame(self, width, height): + genericPreview = self.settings.value("pref_genericPreview") + startPt = 0 + if not genericPreview: + inputFile = self.parent.lineEdit_audioFile.text() + if not inputFile or not os.path.exists(inputFile): + return + duration = getAudioDuration(inputFile) + if not duration: + return + startPt = duration / 3 + if startPt + 3 > duration: + startPt += startPt - 3 + + command = [ + self.core.FFMPEG_BIN, + "-thread_queue_size", + "512", + "-r", + str(self.settings.value("outputFrameRate")), + "-ss", + "{0:.3f}".format(startPt), + "-i", + self.core.junkStream if genericPreview else inputFile, + "-f", + "image2pipe", + "-pix_fmt", + "rgba", + ] + command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt)) + command.extend( + [ + "-an", + "-s:v", + "%sx%s" % scale(self.scale, self.width, self.height, str), + "-codec:v", + "rawvideo", + "-", + "-frames:v", + "1", + ] + ) + if self.core.logEnabled: + logFilename = os.path.join( + self.core.logDir, "preview_%s.log" % str(self.compPos) + ) + log.debug("Creating ffmpeg log at %s", logFilename) + with open(logFilename, "w") as logf: + logf.write(" ".join(command) + "\n\n") + with open(logFilename, "a") as logf: + pipe = openPipe( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=logf, + bufsize=10**8, + ) + else: + pipe = openPipe( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=10**8, + ) + byteFrame = pipe.stdout.read(self.chunkSize) + closePipe(pipe) + + frame = self.finalizeFrame(byteFrame) + return frame + + def makeFfmpegFilter(self, preview=False, startPt=0): + w, h = scale(self.scale, self.width, self.height, str) + if self.amplitude == 0: + amplitude = "lin" + elif self.amplitude == 1: + amplitude = "log" + elif self.amplitude == 2: + amplitude = "sqrt" + elif self.amplitude == 3: + amplitude = "cbrt" + hexcolor = QColor(*self.color).name() + opacity = "{0:.1f}".format(self.opacity / 100) + genericPreview = self.settings.value("pref_genericPreview") + if self.mode < 3: + filter_ = ( + "showwaves=" + f'r={str(self.settings.value("outputFrameRate"))}:' + f's={self.settings.value("outputWidth")}x{self.settings.value("outputHeight")}:' + f'mode={self.page.comboBox_mode.currentText().lower() if self.mode != 3 else "p2p"}:' + f"colors={hexcolor}@{opacity}:scale={amplitude}" + ) + elif self.mode > 2: + filter_ = ( + f'showfreqs=s={str(self.settings.value("outputWidth"))}x{str(self.settings.value("outputHeight"))}:' + f'mode={"line" if self.mode == 4 else "bar"}:' + f"colors={hexcolor}@{opacity}" + f":ascale={amplitude}:fscale={'log' if self.mono else 'lin'}" + ) + + baselineHeight = int(self.height * (4 / 1080)) + return [ + "-filter_complex", + f"{exampleSound('wave', extra='') if preview and genericPreview else '[0:a] '}" + f"{'compand=gain=4,' if self.compress else ''}" + f"{'aformat=channel_layouts=mono,' if self.mono and self.mode < 3 else ''}" + f"{filter_}" + f"{', drawbox=x=(iw-w)/2:y=(ih-h)/2:w=iw:h=%s:color=%s@%s' % (baselineHeight, hexcolor, opacity) if self.mode < 2 else ''}" + f"{', hflip' if self.mirror else''}" + " [v1]; " + "[v1] scale=%s:%s%s [v]" + % ( + w, + h, + ", trim=duration=%s" % "{0:.3f}".format(startPt + 3) if preview else "", + ), + "-map", + "[v]", + ] + + def updateChunksize(self): + width, height = scale(self.scale, self.width, self.height, int) + self.chunkSize = 4 * width * height + + def finalizeFrame(self, imageData): + try: + image = Image.frombytes( + "RGBA", + scale(self.scale, self.width, self.height, int), + imageData, + ) + self._image = image + except ValueError: + image = self._image + if self.scale != 100 or self.x != 0 or self.y != 0: + frame = BlankFrame(self.width, self.height) + frame.paste(image, box=(self.x, self.y)) + else: + frame = image + return frame diff --git a/src/avp/components/waveform.ui b/src/avp/components/waveform.ui new file mode 100644 index 0000000..5473f33 --- /dev/null +++ b/src/avp/components/waveform.ui @@ -0,0 +1,383 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + + 0 + 0 + + + + + 0 + 197 + + + + Form + + + + + + 4 + + + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Mode + + + + + + + + Cline + + + + + Line + + + + + Point + + + + + Frequency Bar + + + + + Frequency Line + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + -10000 + + + 10000 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + -10000 + + + 10000 + + + 0 + + + + + + + + + + + + + Color + + + + + + + Qt::ImhNone + + + + + + + + 0 + 0 + + + + + 32 + 32 + + + + + + + false + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Opacity + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QAbstractSpinBox::UpDownArrows + + + % + + + 0 + + + 100 + + + 100 + + + + + + + Scale + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QAbstractSpinBox::UpDownArrows + + + % + + + 10 + + + 400 + + + 100 + + + + + + + + + + + Compress + + + + + + + Mono + + + + + + + Mirror + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Amplitude + + + + + + + + Linear + + + + + Logarithmic + + + + + Square root + + + + + Cubic root + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + -- cgit v1.2.3