Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use QgsBlockingProcess to run movie creation commands #8

Merged
merged 1 commit into from
Mar 28, 2022
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 114 additions & 35 deletions movie_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,21 @@

# This will make the QGIS use a world projection and then move the center
# of the CRS sequentially to create a spinning globe effect
import os
from enum import Enum
from typing import List, Optional

# This import is to enable SIP API V2
# noinspection PyUnresolvedReferences
import qgis # NOQA
from qgis.PyQt.QtCore import pyqtSignal
from qgis.core import QgsTask
from qgis.PyQt.QtCore import (
pyqtSignal,
QProcess
)
from qgis.core import (
QgsTask,
QgsBlockingProcess,
QgsFeedback
)

from .utilities import get_ui_class, which

Expand All @@ -34,15 +41,15 @@ class MovieCreationTask(QgsTask):
movie_created = pyqtSignal(str)

def __init__(
self,
output_file: str,
music_file: str,
output_format: MovieFormat,
work_directory: str,
frame_filename_prefix: str,
framerate: int,
self,
output_file: str,
music_file: str,
output_format: MovieFormat,
work_directory: str,
frame_filename_prefix: str,
framerate: int,
):
super().__init__("Exporting Movie", QgsTask.Flags())
super().__init__("Exporting Movie", QgsTask.Flag.CanCancel)

self.output_file = output_file
self.music_file = music_file
Expand All @@ -51,11 +58,60 @@ def __init__(
self.frame_filename_prefix = frame_filename_prefix
self.framerate = framerate

self.feedback: Optional[QgsFeedback] = None

def run_process(self, command: str, arguments: List[str]):

self.message.emit(f"Generating Movie: " + command + ' ' + ' '.join(arguments))

def on_stdout(ba):
val = ba.data().decode('UTF-8')

on_stdout.buffer += val
if on_stdout.buffer.endswith('\n') or on_stdout.buffer.endswith('\r'):
# flush buffer
self.message.emit(on_stdout.buffer.rstrip())
on_stdout.buffer = ''

on_stdout.progress = 0
on_stdout.buffer = ''

def on_stderr(ba):
val = ba.data().decode('UTF-8')
on_stderr.buffer += val

if on_stderr.buffer.endswith('\n') or on_stderr.buffer.endswith('\r'):
# flush buffer
self.message.emit(on_stderr.buffer.rstrip())
on_stderr.buffer = ''

on_stderr.buffer = ''

proc = QgsBlockingProcess(command, arguments)
proc.setStdOutHandler(on_stdout)
proc.setStdErrHandler(on_stderr)

res = proc.run(self.feedback)
if self.feedback.isCanceled() and res != 0:
self.message.emit('Process was canceled and did not complete')
elif not self.feedback.isCanceled() and proc.exitStatus() == QProcess.CrashExit:
self.message.emit('Process was unexpectedly terminated')
elif res == 0:
self.message.emit('Process completed successfully')
elif proc.processError() == QProcess.FailedToStart:
self.message.emit(
'Process {} failed to start. Either {} is missing, or you may have insufficient permissions to run the program.').format(
command, command)
else:
self.message.emit('Process returned error code {}'.format(res))

def run(self):
"""
Creates a movie in a background task
"""

self.feedback = QgsFeedback()

if self.format == MovieFormat.GIF:
self.message.emit("Generating GIF")

Expand All @@ -66,25 +122,36 @@ def run(self):
# the command line and check the path to convert (provided by
# ImageMagick) is correct...
# delay of 3.33 makes the output around 30fps
os.system(
f"""{convert} -delay {100/self.framerate} -loop 0 \
{self.work_directory}/{self.frame_filename_prefix}-*.png \
{self.output_file}"""
)

self.run_process(convert,
[
'-delay', str(100 / self.framerate),
'-loop' ,'0',
f'{self.work_directory}/{self.frame_filename_prefix}-*.png',
self.output_file
])

# Now do a second pass with image magick to resize and compress the
# gif as much as possible. The remap option basically takes the
# first image as a reference image for the colour palette Depending
# on you cartography you may also want to bump up the colors param
# to increase palette size and of course adjust the scale factor to
# the ultimate image size you want
os.system(
f"""
{convert} {self.output_file} -coalesce -scale 600x600 \
-fuzz 2% +dither \
-remap {self.output_file}[20] \
+dither -colors 14 -layers \
Optimize {self.work_directory}/animation_small.gif"""
)
self.run_process(convert,
[
self.output_file,
'-coalesce',
'-scale' ,'600x600',
'-fuzz', '2%', '+dither',
'-remap' ,f'{self.output_file}[20]',
'+dither',
'-colors', '14',
'-layers',
'Optimize',
f'{self.work_directory}/animation_small.gif'
]
)

self.message.emit(f"GIF written to {self.output_file}")
self.movie_created.emit(self.output_file)
else:
Expand All @@ -99,16 +166,22 @@ def run(self):
# -y to force overwrite exising file
self.message.emit(f"ffmpeg found: {ffmpeg}")

arguments = [
'-y',
f'-framerate', str(self.framerate),
'-pattern_type', 'glob',
'-i', f"{self.work_directory}/{self.frame_filename_prefix}-*.png"
]

if self.music_file:
mp3_flag = f"-i {self.music_file}"
else:
mp3_flag = ""
unix_command = f"""
{ffmpeg} -y -framerate {self.framerate} -pattern_type glob \
-i "{self.work_directory}/{self.frame_filename_prefix}-*.png" \
{mp3_flag} -vf \
"pad=ceil(iw/2)*2:ceil(ih/2)*2:color=white" \
-c:v libx264 -pix_fmt yuv420p {self.output_file}"""
arguments.append(f"-i {self.music_file}")

arguments.extend([
'-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2:color=white',
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
self.output_file
])

# windows_command = ("""
# %s -y -framerate %s -pattern_type sequence \
Expand All @@ -122,9 +195,15 @@ def run(self):
# self.frame_filename_prefix,
# self.output_file))

self.message.emit(f"Generating Movie:{unix_command}")
os.system(unix_command)
self.run_process(ffmpeg, arguments)
self.message.emit(f"MP4 written to {self.output_file}")
self.movie_created.emit(self.output_file)

self.feedback = None
return True

def cancel(self):
if self.feedback is not None:
self.feedback.cancel()

super().cancel()