aboutsummaryrefslogtreecommitdiff
path: root/src/video_thread.py
diff options
context:
space:
mode:
authorAeliton G. Silva2026-01-12 22:39:55 -0300
committerAeliton G. Silva2026-01-13 04:22:25 -0300
commitf975144f25d34f97329b2d4e52891061573cea08 (patch)
tree226fe223b31af6f217b1dd413629ab2cf26964d4 /src/video_thread.py
parentb8703752ffc7768b0275897b3c2a869ff41504e5 (diff)
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
Diffstat (limited to 'src/video_thread.py')
-rw-r--r--src/video_thread.py417
1 files changed, 0 insertions, 417 deletions
diff --git a/src/video_thread.py b/src/video_thread.py
deleted file mode 100644
index 5d72409..0000000
--- a/src/video_thread.py
+++ /dev/null
@@ -1,417 +0,0 @@
-"""
-Worker thread created to export a video. It has a slot to begin export using
-an input file, output path, and component list.
-
-Signals are emitted to update MainWindow's progress bar, detail text, and preview.
-A Command object takes the place of MainWindow while in commandline mode.
-
-Export can be cancelled with cancel()
-"""
-
-from PyQt6 import QtCore, QtGui
-from PyQt6.QtCore import pyqtSignal, pyqtSlot
-from PIL import Image
-from PIL.ImageQt import ImageQt
-import numpy
-import subprocess as sp
-import sys
-import os
-import time
-import signal
-import logging
-
-from .component import ComponentError
-from .toolkit.frame import Checkerboard
-from .toolkit.ffmpeg import (
- openPipe,
- readAudioFile,
- getAudioDuration,
- createFfmpegCommand,
-)
-
-
-log = logging.getLogger("AVP.VideoThread")
-
-
-class Worker(QtCore.QObject):
-
- imageCreated = pyqtSignal("QImage")
- videoCreated = pyqtSignal()
- progressBarUpdate = pyqtSignal(int)
- progressBarSetText = pyqtSignal(str)
- encoding = pyqtSignal(bool)
-
- def __init__(self, parent, inputFile, outputFile, components):
- super().__init__()
- self.core = parent.core
- self.settings = parent.settings
- self.modules = parent.core.modules
- parent.createVideo.connect(self.createVideo)
- self.previewEnabled = type(parent.core).previewEnabled
-
- self.components = components
- self.outputFile = outputFile
- self.inputFile = inputFile
-
- self.hertz = 44100
- self.sampleSize = 1470 # 44100 / 30 = 1470
- self.canceled = False
- self.error = False
-
- def createFfmpegCommand(self, duration):
- try:
- ffmpegCommand = createFfmpegCommand(
- self.inputFile, self.outputFile, self.components, duration
- )
- except sp.CalledProcessError as e:
- # FIXME video_thread should own this error signal, not components
- self.components[0]._error.emit(
- "Ffmpeg could not be found. Is it installed?", str(e)
- )
- self.error = True
- return
-
- if not ffmpegCommand:
- # FIXME video_thread should own this error signal, not components
- self.components[0]._error.emit(
- "The FFmpeg command could not be generated.", ""
- )
- log.critical(
- "Cancelling render process due to failure while generating the ffmpeg command."
- )
- self.failExport()
- return
- return ffmpegCommand
-
- def determineAudioLength(self):
- """
- Returns audio length which determines length of final video, or False if failure occurs
- """
- if any(
- [True if "pcm" in comp.properties() else False for comp in self.components]
- ):
- self.progressBarSetText.emit("Loading audio file...")
- audioFileTraits = readAudioFile(self.inputFile, self)
- if audioFileTraits is None:
- self.cancelExport()
- return False
- self.completeAudioArray, duration = audioFileTraits
- self.audioArrayLen = len(self.completeAudioArray)
- else:
- duration = getAudioDuration(self.inputFile)
- self.completeAudioArray = []
- self.audioArrayLen = int(
- ((duration * self.hertz) + self.hertz) - self.sampleSize
- )
- return duration
-
- def preFrameRender(self):
- """
- Initializes components that need to pre-compute stuff.
- Also prerenders "static" components like text and merges them if possible
- """
- self.staticComponents = {}
-
- # Call preFrameRender on each component
- canceledByComponent = False
- initText = ", ".join(
- [
- "%s) %s" % (num, str(component))
- for num, component in enumerate(reversed(self.components))
- ]
- )
- print("Loaded Components:", initText)
- log.info("Calling preFrameRender for %s", initText)
- for compNo, comp in enumerate(reversed(self.components)):
- try:
- comp.preFrameRender(
- audioFile=self.inputFile,
- completeAudioArray=self.completeAudioArray,
- audioArrayLen=self.audioArrayLen,
- sampleSize=self.sampleSize,
- progressBarUpdate=self.progressBarUpdate,
- progressBarSetText=self.progressBarSetText,
- )
- except ComponentError:
- log.warning(
- "#%s %s encountered an error in its preFrameRender method",
- compNo,
- comp,
- )
-
- compProps = comp.properties()
- if "error" in compProps or comp._lockedError is not None:
- self.cancel()
- self.canceled = True
- canceledByComponent = True
- compError = (
- comp.error() if type(comp.error()) is tuple else (comp.error(), "")
- )
- errMsg = (
- "Component #%s (%s) encountered an error!"
- % (str(compNo), comp.name)
- if comp.error() is None
- else "Export cancelled by component #%s (%s): %s"
- % (str(compNo), comp.name, compError[0])
- )
- log.error(errMsg)
- comp._error.emit(errMsg, compError[1])
- break
- if "static" in compProps:
- log.info("Saving static frame from #%s %s", compNo, comp)
- self.staticComponents[compNo] = comp.frameRender(0).copy()
-
- # Check if any errors occured
- log.debug("Checking if a component wishes to cancel the export...")
- if self.canceled:
- if canceledByComponent:
- log.error(
- "Export cancelled by component #%s (%s): %s",
- compNo,
- comp.name,
- (
- "No message."
- if comp.error() is None
- else (
- comp.error()
- if type(comp.error()) is str
- else comp.error()[0]
- )
- ),
- )
- self.cancelExport()
-
- # Merge static frames that can be merged to reduce workload
- def mergeConsecutiveStaticComponentFrames(self):
- log.info("Merging consecutive static component frames")
- for compNo in range(len(self.components)):
- if (
- compNo not in self.staticComponents
- or compNo + 1 not in self.staticComponents
- ):
- continue
- self.staticComponents[compNo + 1] = Image.alpha_composite(
- self.staticComponents.pop(compNo),
- self.staticComponents[compNo + 1],
- )
- self.staticComponents[compNo] = None
-
- mergeConsecutiveStaticComponentFrames(self)
-
- def frameRender(self, audioI):
- """
- Renders a frame composited together from the frames returned by each component
- audioI is a multiple of self.sampleSize, which can be divided to determine frameNo
- """
-
- def err():
- self.closePipe()
- self.cancelExport()
- self.error = True
- msg = "A call to renderFrame in the video thread failed critically."
- log.critical(msg)
- comp._error.emit(msg, str(e))
-
- bgI = int(audioI / self.sampleSize)
- frame = None
- for layerNo, comp in enumerate(reversed((self.components))):
- if self.canceled:
- break
- try:
- if layerNo in self.staticComponents:
- if self.staticComponents[layerNo] is None:
- # this layer was merged into a following layer
- continue
- # static component
- if frame is None: # bottom-most layer
- frame = self.staticComponents[layerNo]
- else:
- frame = Image.alpha_composite(
- frame, self.staticComponents[layerNo]
- )
-
- else:
- # animated component
- if frame is None: # bottom-most layer
- frame = comp.frameRender(bgI)
- else:
- frame = Image.alpha_composite(frame, comp.frameRender(bgI))
- except Exception as e:
- err()
- return frame
-
- def showPreview(self, frame):
- """
- Receives a final frame that will be piped to FFmpeg,
- adds it to the MainWindow for the live preview
- """
- # We must store a reference to this QImage
- # or else Qt will garbage-collect it on the C++ side
- self.latestPreview = ImageQt(frame)
- self.imageCreated.emit(QtGui.QImage(self.latestPreview))
-
- @pyqtSlot()
- def createVideo(self):
- """
- 1. Numpy is set to ignore division errors during this method
- 2. Determine length of final video
- 3. Call preFrameRender on each component
- 4. Create the main FFmpeg command
- 5. Open the out_pipe to FFmpeg process
- 6. Iterate over the audio data array and call frameRender on the components to get frames
- 7. Close the out_pipe
- 8. Call postFrameRender on each component
- """
- log.debug("Video worker received signal to createVideo")
- log.debug("Video thread id: {}".format(int(QtCore.QThread.currentThreadId())))
- numpy.seterr(divide="ignore")
- self.encoding.emit(True)
- self.extraAudio = []
- self.width = int(self.settings.value("outputWidth"))
- self.height = int(self.settings.value("outputHeight"))
-
- # Set core.Core.canceled to False and call .reset() on each component
- self.reset()
- # Initialize progress bar to 0
- progressBarValue = 0
- self.progressBarUpdate.emit(progressBarValue)
-
- # Determine longest length of audio which will be the final video's duration
- log.debug("Determining length of audio...")
- duration = self.determineAudioLength()
- if not duration:
- return
-
- # Call preFrameRender on each component to perform initialization
- self.progressBarUpdate.emit(0)
- self.progressBarSetText.emit("Starting components...")
- self.preFrameRender()
- if self.canceled:
- return
-
- # Create FFmpeg command
- ffmpegCommand = self.createFfmpegCommand(duration)
- if not ffmpegCommand:
- return
- cmd = " ".join(ffmpegCommand)
- print("###### FFMPEG COMMAND ######\n%s" % cmd)
- print("############################")
- log.info(cmd)
-
- # Open pipe to FFmpeg
- log.info("Opening pipe to FFmpeg")
- try:
- self.out_pipe = openPipe(
- ffmpegCommand,
- stdin=sp.PIPE,
- stdout=sys.stdout,
- stderr=sys.stdout,
- )
- except sp.CalledProcessError:
- log.critical("Out_Pipe to FFmpeg couldn't be created!", exc_info=True)
- raise
-
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
- # START CREATING THE VIDEO
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
- progressBarValue = 0
- self.progressBarUpdate.emit(progressBarValue)
- # Begin piping into ffmpeg!
- self.progressBarSetText.emit("Exporting video...")
- for audioI in range(0, self.audioArrayLen, self.sampleSize):
- if self.canceled:
- break
- # fetch the next frame & add to the FFmpeg pipe
- frame = self.frameRender(audioI)
-
- # Update live preview
- if self.previewEnabled:
- self.showPreview(frame)
-
- try:
- self.out_pipe.stdin.write(frame.tobytes())
- except Exception:
- break
-
- # increase progress bar value
- completion = (audioI / self.audioArrayLen) * 100
- if progressBarValue + 1 <= completion:
- progressBarValue = numpy.floor(completion).astype(int)
- self.progressBarUpdate.emit(progressBarValue)
- self.progressBarSetText.emit(
- "Exporting video: %s%%" % str(int(progressBarValue))
- )
-
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
- # Finished creating the video!
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
-
- numpy.seterr(all="print")
-
- self.closePipe()
-
- for comp in reversed(self.components):
- comp.postFrameRender()
-
- if self.canceled:
- print("Export Canceled")
- try:
- os.remove(self.outputFile)
- except Exception:
- pass
- self.progressBarUpdate.emit(0)
- self.progressBarSetText.emit("Export Canceled")
- else:
- if self.error:
- self.failExport()
- else:
- print("Export Complete")
- self.progressBarUpdate.emit(100)
- self.progressBarSetText.emit("Export Complete")
-
- self.error = False
- self.canceled = False
- self.encoding.emit(False)
- self.videoCreated.emit()
-
- def closePipe(self):
- try:
- self.out_pipe.stdin.close()
- except (BrokenPipeError, OSError):
- log.debug("Broken pipe to FFmpeg!")
- if self.out_pipe.stderr is not None:
- log.error(self.out_pipe.stderr.read())
- self.out_pipe.stderr.close()
- self.error = True
- self.out_pipe.wait()
-
- def cancelExport(self, message="Export Canceled"):
- self.progressBarUpdate.emit(0)
- self.progressBarSetText.emit(message)
- self.encoding.emit(False)
- self.videoCreated.emit()
-
- def failExport(self):
- self.cancelExport("Export Failed")
-
- def updateProgress(self, pStr, pVal):
- self.progressBarValue.emit(pVal)
- self.progressBarSetText.emit(pStr)
-
- def cancel(self):
- self.canceled = True
- self.core.cancel()
-
- for comp in self.components:
- comp.cancel()
-
- try:
- self.out_pipe.send_signal(signal.SIGTERM)
- except Exception:
- pass
-
- def reset(self):
- self.core.reset()
- self.canceled = False
- for comp in self.components:
- comp.reset()