stopi/main_c.py

710 lines
30 KiB
Python

#!/usr/bin/env python3
#
# main_c.py
#
# v0.1 - 2024 schnappy <contact@schnappy.xyz>
#
# The Tuffy font is public domain and was created by
# Thatcher Ulrich <tu@tulrich.com> http://tulrich.com
# Karoly Barta bartakarcsi@gmail.com
# Michael Evans http://www.evertype.com
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# A full copy of the GNU General Public License is available from
# https://www.gnu.org/licenses/gpl-3.0.en.html
#
import collections
import gettext
# DLSR support
import gphoto2 as gp
from itertools import count
import locale
import os
from PIL import Image, ImageTk, ImageFilter
import sys
import threading
import time
import tkinter as tk
from tkinter import filedialog, messagebox
import tomllib
# async FFMPEG exportation support
import asyncio
from ffmpeg.asyncio import FFmpeg
from send2trash import send2trash
# TODO : Todo List
#
# X wait event mode
# X remove frames
# X keep images in memory (odict > list ?)
# / resize images upon shooting/crop output video ?
# X project workflow
# X use a different folder for each project (A, B, C, etc...)
# X startup : check for existing folder with name A, B, C, etc.
# X startup : offer to continue previous project or new project
# X if continue, find last frame in folder X else, find next letter and create folder
# o notify export ending
# X colored onion skin frame (pillow filters)
# X startup frame
# o webcam support (pygame, win and linux only)
# X Import config values from config file
# X Translation
# o Better settings names
# o Allow opening and exporting without a camera connected
running_from_folder = os.path.realpath(__file__)
alphabet = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z']
# l10n
LOCALE = os.getenv('LANG', 'en_EN')
_ = gettext.translation('template', localedir='locales', languages=[LOCALE]).gettext
# Config
# defaults
project_settings_defaults = {
'file_extension':'JPG',
'trigger_mode': 'key',
'projects_folder': '',
# ~ 'project_letter': 'A'
'onion_skin_onstartup' : False,
'onionskin_alpha_default' : 0.4,
'onionskin_fx' : False,
'fullscreen_bool' : True,
'screen_w' : 640,
'screen_h' : 480,
'framerate' : 16,
'vflip' : False,
'hflip' : False,
'export_options' : 'scale=1920:-1,crop=1920:1080:0:102',
}
# Camera Settings (Nikon D40x)
# See `gphoto2 --list-config` to find your camera's values
camera_settings = camera_status = {}
# Load from file
config_locations = ["./", "~/.", "~/.config/"]
config_found_msg = _("No configuration file found, using defaults.")
project_settings = project_settings_defaults
for location in config_locations:
# Optional config files, ~ is expanded to $HOME on *nix, %USERPROFILE% on windows
if os.path.exists( os.path.expanduser(os.path.join(location, 'config.toml'))):
with open(os.path.expanduser(location + 'config.toml'), 'rb') as config_file:
project_settings = tomllib.load(config_file)
if 'DEFAULT' in project_settings:
project_settings = project_settings['DEFAULT']
if 'CAMERA' in project_settings:
camera_settings = project_settings['CAMERA']
if 'CHECK' in project_settings:
camera_settings = project_settings['CHECK']
config_found_msg = _("Found configuration file in {}").format(os.path.expanduser(location))
print(config_found_msg)
class KISStopmo(tk.Tk):
def __init__(self, *args, **kargs):
self.check_config()
# Default config
# Set script settings according to config file
self.onion_skin = project_settings['onion_skin_onstartup']
self.onionskin_alpha = project_settings['onionskin_alpha_default']
self.onionskin_fx = project_settings['onionskin_fx']
self.fullscreen_bool = project_settings['fullscreen_bool']
self.screen_w, self.screen_h = project_settings['screen_w'], project_settings['screen_h']
self.framerate = project_settings['framerate']
# ~ for setting in camera_settings:
# ~ print(setting)
# ~ self.photo = None
self.end_thread = False
# Window setup
self.screen_w, self.screen_h = root.winfo_screenwidth(), root.winfo_screenheight()
root.wm_attributes("-fullscreen", self.fullscreen_bool)
root.title('KISStopmotion')
root.iconbitmap('@' + os.path.join(os.getcwd(), 'kisstopmo.xbm'))
# Image container creation
self.label = tk.Label(root)
self.label.pack()
# Savepath setup
self.projects_folder = tk.filedialog.askdirectory()
self.savepath = self.get_session_folder()
if len(self.savepath):
self.project_letter = self.savepath.split(os.sep)[-1]
else:
self.project_letter = 'A'
# Export settings
# ~ self.input_filename = "{folder}{sep}{letter}.%04d.{ext}".format(folder=self.savepath, sep=os.sep, letter=project_settings['project_letter'], ext=project_settings["file_extension"])
self.input_filename = "{folder}{sep}{letter}.%04d.{ext}".format(folder=self.savepath, sep=os.sep, letter=self.project_letter, ext=project_settings["file_extension"])
self.input_options = {"f": "image2", "r": str(self.framerate)}
# ~ self.output_filename = "{folder}{sep}{filename}.mp4".format(folder=self.projects_folder, sep=os.sep, filename=self.savepath.split(os.sep)[-1])
self.output_filename = "{filename}.mp4".format(filename=self.project_letter)
self.output_options = self.parse_export_options(project_settings['export_options'], project_settings['vflip'], project_settings['hflip'] )
# Get frames list
self.img_list = {}
self.img_list = self.get_frames_list(self.savepath)
self.img_index = self.check_range(len(self.img_list)-1, False)
self.splash_text = _("No images yet! Start shooting...")
# Camera setup
self.camera = gp.check_result(gp.gp_camera_new())
try:
gp.check_result(gp.gp_camera_init(self.camera))
# get configuration tree
self.current_camera_config = gp.check_result(gp.gp_camera_get_config(self.camera))
self.apply_camera_settings(self.camera, self.current_camera_config)
if self.check_status(self.camera, self.current_camera_config) is False:
print(_("Warning: Some settings are not set to the recommended value!"))
except:
self.splash_text += _("\nCamera not found or busy.")
self.timeout = 3000 # milliseconds
self.splashscreen = self.generate_splashscreen()
image = self.update_image()
if image:
if self.onion_skin:
if self.img_index:
photo = self.apply_onionskin(image, self.onionskin_alpha, self.onionskin_fx)
else:
photo = ImageTk.PhotoImage(image)
self.label.configure(image=photo)
self.label.image = photo
if project_settings['trigger_mode'] == 'event':
root.after(1000, self.trigger_bg_loop)
# ~ root.after(1000, self.wait_for_capture)
# Key binding
root.bind("<Escape>", lambda event: root.attributes("-fullscreen", False))
root.bind("<f>", lambda event: root.attributes("-fullscreen", True))
root.bind("<n>", self.next_frame)
root.bind("<b>", self.previous_frame)
root.bind("<o>", self.toggle_onionskin)
root.bind("<p>", self.preview_animation)
root.bind("<e>", self.trigger_export_animation)
root.bind("<d>", self.remove_frame)
root.bind("<a>", self.print_imglist)
if project_settings['trigger_mode'] != 'event':
root.bind("<j>", self.capture_image)
def check_config(self):
global project_settings
for setting in project_settings_defaults:
if setting not in project_settings:
project_settings[setting] = project_settings_defaults[setting]
def parse_export_options(self, options:str, vflip:bool=False, hflip:bool=False):
options = {"vf" : options}
if vflip:
options['vf'] += ',vflip'
if hflip:
options['vf'] += ',hflip'
return options
def generate_splashscreen(self):
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO
splash = Image.new('RGB', (self.screen_w, self.screen_h), (200,200,200))
splash_draw = ImageDraw.Draw(splash)
if self.splash_text is not None:
font = ImageFont.truetype("Tuffy_Bold.ttf", 60)
font_len = font.getlength(self.splash_text)
splash_draw.text((self.screen_w/2 - font_len/2, self.screen_h/2 ), self.splash_text, fill=(255, 255, 255), font=font)
# Use in-memory
splash_bytes = BytesIO()
splash.save(splash_bytes, 'png')
return splash
def find_letter_after(self, letter:str):
if letter in alphabet and alphabet.index(letter) < len(alphabet) - 1:
return alphabet[alphabet.index(letter) + 1]
else:
return False
def get_projects_folder(self):
if len(self.projects_folder):
project_folder = self.projects_folder
else:
# Get user folder
project_folder = os.path.expanduser('~')
# If a project folder is defined in settings, use it
if project_settings['projects_folder'] != '':
subfolder = project_settings['projects_folder']
else:
# If it doesn't exist, use a default name
subfolder = 'Stopmotion Projects'
project_folder = os.path.join(project_folder, subfolder)
# Create folder if it doesn't exist
if os.path.exists(project_folder) == False:
os.mkdir(project_folder)
else:
if not os.path.isdir(project_folder):
# If file exists but is not a folder, can't create it, abort
return False
return project_folder
def get_session_folder(self):
project_folder = self.get_projects_folder()
if project_folder:
sessions_list = []
dir_list = os.listdir(project_folder)
# Filter folders with name only one char long
for dir in dir_list:
if len(dir) == 1 and dir in alphabet:
sessions_list.append(dir)
# If folders exist, find last folder in alphabetical order
if len(sessions_list):
sessions_list.sort()
last_letter = sessions_list[-1]
# By default, find next letter for a new session
next_letter = self.find_letter_after(last_letter)
if next_letter is False:
return False
# A previous session folder was found; ask the user if they wish to resume session
resume_session = tk.messagebox.askyesno(_("Resume session?"), _("A previous session was found in {}, resume shooting ?").format(os.path.join(project_folder, last_letter)))
if resume_session:
next_letter = last_letter
else:
next_letter = 'A'
if os.path.exists(os.path.join(project_folder, next_letter)) is False:
os.mkdir(os.path.join(project_folder, next_letter))
print(_("Using {} as session folder.").format(os.path.join(project_folder, next_letter)))
return os.path.join(project_folder, next_letter)
return False
def trigger_bg_loop(self):
self.event_thread = threading.Thread(target=self.wait_for_capture)
self.event_thread.start()
def wait_for_event(self):
# ~ while True:
event_type, event_data = self.camera.wait_for_event(self.timeout)
if event_type == gp.GP_EVENT_FILE_ADDED:
cam_file = self.camera.file_get(
event_data.folder, event_data.name, gp.GP_FILE_TYPE_NORMAL)
# ~ next_filename = prefix+next(filename)+ext
next_filename = self.return_next_frame_number(self.get_last_frame(self.savepath))
target_path = os.path.join(os.getcwd(), next_filename)
print(_("Image is being saved to {}").format(target_path))
cam_file.save(target_path)
# ~ return 0
def print_imglist(self, event):
print(self.img_list)
def get_frames_list(self, folder:str):
# Get JPG files list in current directory
# ~ existing_animation_files = []
# ~ if not len(self.img_list):
existing_animation_files = self.img_list
# ~ existing_animation_files =
file_list = os.listdir(folder)
# ~ print(file_list)
for file in file_list:
if (file.startswith(self.project_letter) and file.endswith(project_settings['file_extension'])):
if file not in existing_animation_files:
# ~ existing_animation_files.append(file)
existing_animation_files[file] = None
if len(existing_animation_files) == 0:
# If no images were found, return fake name set to -001 to init file count to 000
# ~ return ["{}.{:04d}.{}".format(project_settings['project_letter'], -1, project_settings['file_extension'])]
return {"{}.{:04d}.{}".format(self.project_letter, -1, project_settings['file_extension']):None}
# ~ else:
# Remove fake file name as soon as we have real pics
# ~ if 'A.-001.JPG' in existing_animation_files:
# ~ existing_animation_files.pop('A.-001.JPG')
# ~ existing_animation_files.sort()
existing_animation_files = collections.OrderedDict(sorted(existing_animation_files.items()))
return existing_animation_files
def clean_img_list(self, folder_path):
# Check file in dict exists, else remove it
file_list = os.listdir(folder_path)
# Iterate over copy of dict to avoid OOR error
img_list_copy = dict(self.img_list)
for file in img_list_copy:
if file not in file_list:
self.img_list.pop(file)
# TODO : check this works with new workflow
def batch_rename(self, folder:str):
# initialize counter to 0
frame_list = self.get_frames_list(folder)
counter = (".%04i." % x for x in count(0))
# ~ for i in range(len(frame_list)):
for i in frame_list.keys():
# ~ if os.path.exists(os.path.realpath(frame_list[i])):
if os.path.exists(os.path.realpath(i)):
# ~ os.rename(os.path.realpath(frame_list[i]), os.path.realpath("{}{}{}".format(project_settings['project_letter'], next(counter), project_settings['file_extension'])))
os.rename(os.path.realpath(i), os.path.realpath("{}{}{}".format(self.project_letter, next(counter), project_settings['file_extension'])))
else:
print(_("{} does not exist").format(str(i)))
return self.get_frames_list(folder)
def offset_dictvalues(self, from_index=0):
dict_copy = dict(self.img_list)
for i in range(from_index, len(dict_copy)):
if i < len(self.img_list)-1:
self.img_list[list(self.img_list.keys())[i]] = list(self.img_list.values())[i+1]
else:
self.img_list[list(self.img_list.keys())[i]] = None
# FIXME: Does this still work ?
def remove_frame(self, event):
if len(list(self.img_list.items())):
folder_path = os.path.realpath(self.savepath)
frame_name = list(self.img_list.items())[self.img_index][0]
frame_path = os.path.realpath(frame_name)
if not os.path.exists(frame_path):
return 0
# ~ print(self.img_list)
print(_("Removing {}").format(frame_path))
# trash file
send2trash(frame_path)
# remove entry from dict
self.img_list.pop(frame_name)
# offset cached images
self.offset_dictvalues(self.img_index)
# rename files and get new list
self.img_list = self.batch_rename(folder_path)
print(self.img_list)
self.clean_img_list(folder_path)
# ~ print(self.img_list)
# update index if possible
# ~ print(self.img_index)
self.img_index = self.check_range(self.img_index, False)
# ~ print(self.img_index)
# update display
# ~ print(self.img_index)
self.update_image(None, self.img_index)
# ~ def open_jpg(self, filepath:str, w:int, h:int):
def open_jpg(self, filetuple:tuple, w:int, h:int, vflip:bool=False, hflip:bool=False):
# If pic not cached
if filetuple[-1] is None:
try:
# ~ image = Image.open(filepath)
# ~ print( filetuple[0] + "is not in cache")
image = Image.open(os.path.join(self.savepath, filetuple[0]))
image = image.resize((w, h))
if vflip:
image = image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
if hflip:
image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
self.img_list[filetuple[0]] = image
except FileNotFoundError:
return False
else:
# ~ print( filetuple[0] + "is in cache")
image = filetuple[-1]
return image
# ~ def inc(self, x):
# ~ if x >= len(self.img_list)-1:
# ~ return 0
# ~ else:
# ~ return x + 1
def check_range(self, x, loop=True):
if x < 0:
if loop:
return len(self.img_list)-1
else:
return 0
elif x > len(self.img_list)-1:
if loop:
return 0
else:
return len(self.img_list)-1
else:
return x
def apply_onionskin(self, image, alpha, fx=False):
# ~ prev_image = Image.open(self.img_list[self.img_index-1])
# ~ prev_image = prev_image.resize((self.screen_w, self.screen_h))
prev_image = list(self.img_list.items())[self.img_index-1]
if prev_image[-1] is None:
prev_image = self.open_jpg(prev_image, project_settings['screen_w'], project_settings['screen_h'], project_settings['vflip'], project_settings['hflip'])
else:
prev_image = prev_image[-1]
if fx:
prev_image = prev_image.filter(ImageFilter.FIND_EDGES)
composite = Image.blend(prev_image, image, alpha)
photo = ImageTk.PhotoImage(composite)
return photo
def update_image(self, event=None, index=None):
if event is not None:
self.img_index = self.check_range(self.img_index+1)
if index is None:
# ~ index = self.img_index
index = self.check_range(self.img_index)
if len(list(self.img_list.items())):
# TODO: better approach
if list(self.img_list.items())[index][0] == "{}.{:04d}.{}".format(self.project_letter, -1, project_settings['file_extension']):
new_image = self.splashscreen
else:
new_image = self.open_jpg(list(self.img_list.items())[index], project_settings['screen_w'], project_settings['screen_h'], project_settings['vflip'], project_settings['hflip'])
else:
new_image = self.splashscreen
# ~ return False
# ~ print(new_image)
# ~ new_image = self.open_jpg(self.img_list[index], self.screen_w, self.screen_h)
if new_image:
photo = ImageTk.PhotoImage(new_image)
if self.onion_skin:
if index:
photo = self.apply_onionskin(new_image, project_settings['onionskin_alpha'], project_settings['onionskin_fx'])
# ~ print(photo)
self.label.configure(image=photo)
self.label.image = photo
return new_image
else:
return False
def next_frame(self, event):
self.img_index = self.check_range(self.img_index+1)
self.update_image(None, self.img_index)
def previous_frame(self, event):
self.img_index = self.check_range(self.img_index-1)
self.update_image(None, self.img_index)
def preview_animation(self, event):
# save OS state
if self.onion_skin:
self.onion_skin = False
onion_skin_was_on = True
# playback
for img in self.img_list:
# ~ self.update_image(None, self.img_list.index(img))
self.update_image(None, list(self.img_list.keys()).index(img))
root.update_idletasks()
time.sleep(1/self.framerate)
# ~ self.update_image(None, self.img_index)
self.display_last_frame()
# restore OS state
if 'onion_skin_was_on' in locals():
self.toggle_onionskin()
return 0
def toggle_onionskin(self, event=None):
self.onion_skin = not self.onion_skin
self.update_image()
def get_last_frame(self, folder:str):
# Refresh file list
existing_animation_files = self.get_frames_list(folder)
# Get last file
# Filename pattern is A.0001.JPG
# ~ return existing_animation_files[-1].split('.')
print(next(reversed(existing_animation_files.keys())))
return next(reversed(existing_animation_files.keys())).split('.')
def display_last_frame(self):
self.img_list = self.get_frames_list(self.savepath)
self.img_index = len(self.img_list)-1
self.update_image(None, self.img_index)
def return_next_frame_number(self, last_frame_name):
prefix, filecount, ext = last_frame_name
filename = '.{:04d}.'.format(int(filecount)+1)
# ~ filename = (".%04i." % x for x in count(int(filecount) + 1))
# ~ return prefix + next(filename) + ext
return prefix + filename + ext
def set_config_value(self, config, setting, new_value):
cur_setting = gp.check_result(gp.gp_widget_get_child_by_name(config, setting))
# find corresponding choice
cur_setting_choice = gp.check_result(gp.gp_widget_get_choice(cur_setting, new_value))
# set config value
gp.check_result(gp.gp_widget_set_value(cur_setting, cur_setting_choice))
def apply_camera_settings(self, camera, config):
# iterate over the settings dictionary
for setting in camera_settings:
# find the capture mode config item
# ~ cur_setting = gp.check_result(gp.gp_widget_get_child_by_name(config, setting))
# ~ # find corresponding choice
# ~ cur_setting_choice = gp.check_result(gp.gp_widget_get_choice(cur_setting, camera_settings[setting]))
# ~ # set config value
# ~ gp.check_result(gp.gp_widget_set_value(cur_setting, cur_setting_choice))
self.set_config_value(config, setting, camera_settings[setting])
# validate config
gp.check_result(gp.gp_camera_set_config(self.camera, config))
def check_status_value(self, config, value, optimal_value=None):
cur_check = gp.check_result(gp.gp_widget_get_child_by_name(config, value))
cur_check_value = gp.check_result(gp.gp_widget_get_value(cur_check))
if optimal_value is not None:
cur_check_choice = gp.check_result(gp.gp_widget_get_choice(cur_check, optimal_value[value]))
return [cur_check_value, cur_check_choice]
else:
return cur_check_value
def check_status(self, camera, config):
for value in camera_status:
# ~ cur_check = gp.check_result(gp.gp_widget_get_child_by_name(config, value))
# ~ cur_check_choice = gp.check_result(gp.gp_widget_get_choice(cur_check, camera_status[value]))
# ~ cur_check_value = gp.check_result(gp.gp_widget_get_value(cur_check))
cur_check_value, cur_check_choice = self.check_status_value(config, value, camera_status)
if cur_check_value == cur_check_choice:
return True
else:
# Some values are not optimal
return False
# ~ def find_shutterspeed(camera, config):
def find_shutterspeed(self, camera):
# get exposure using /main/status/lightmeter > should be 0
config = gp.check_result(gp.gp_camera_get_config(camera))
current_lightmeter_value = self.check_status_value(config, 'lightmeter')
current_shutterspeed_value = self.check_status_value(config, 'shutterspeed2')
# ~ previous_shutterspeed_value = -1
print(str(current_lightmeter_value) + " - " + str(current_shutterspeed_value))
speed = 30
if current_lightmeter_value < 0: # or current_lightmeter_value > 7:
# ~ while current_lightmeter_value < -7:
print(_("speed too high"))
while current_lightmeter_value < -7:
self.set_config_value(config, 'shutterspeed2', speed) # 0 to 46
gp.check_result(gp.gp_camera_set_config(camera, config))
time.sleep(1)
config = gp.check_result(gp.gp_camera_get_config(camera))
# ~ previous_shutterspeed_value = current_shutterspeed_value
current_lightmeter_value = self.check_status_value(config, 'lightmeter')
current_shutterspeed_value = self.check_status_value(config, 'shutterspeed2')
speed += 1
print(speed)
print(str(current_lightmeter_value) + " - " + str(current_shutterspeed_value))
if current_lightmeter_value > 0:
print(_("Speed too low."))
while current_lightmeter_value > 7:
self.set_config_value(config, 'shutterspeed2', speed) # 0 to 46
gp.check_result(gp.gp_camera_set_config(camera, config))
time.sleep(1)
config = gp.check_result(gp.gp_camera_get_config(self.camera))
# ~ previous_shutterspeed_value = current_shutterspeed_value
current_lightmeter_value = self.check_status_value(config, 'lightmeter')
current_shutterspeed_value = self.check_status_value(config, 'shutterspeed2')
speed -= 1
print(speed)
print(str(current_lightmeter_value) + " - " + str(current_shutterspeed_value))
return True
# ~ print(str(current_lightmeter_value) + " - " + str(current_shutterspeed_value))
def capture_image(self, event=None, event_data=None):
# get net file name based on prefix, file count and extension
next_filename = self.return_next_frame_number(self.get_last_frame(self.savepath))
# build full path to file
target_path = os.path.join(self.savepath, next_filename)
# ~ print(target_path)
# Get file from camera
if event_data is None:
# ~ print("j pressed")
new_frame_path = self.camera.capture(gp.GP_CAPTURE_IMAGE)
# ~ self.camera.trigger_capture()
new_frame = self.camera.file_get(
new_frame_path.folder, new_frame_path.name, gp.GP_FILE_TYPE_NORMAL)
print(_("Saving {}{}").format(new_frame_path.folder, new_frame_path.name))
else:
print(_("Getting file {}").format(event_data.name))
new_frame = self.camera.file_get(
event_data.folder, event_data.name, gp.GP_FILE_TYPE_NORMAL)
new_frame.save(target_path)
# ~ next_filename = prefix+next(filename)+ext
# ~ print(self.img_list)
if '{letter}.-001.JPG'.format(letter=self.project_letter) in self.img_list:
# ~ self.img_list.pop('A.-001.JPG')
self.img_list.pop('{letter}.-001.JPG'.format(letter=self.project_letter))
# Display new frame
self.display_last_frame()
def wait_for_capture(self):
if self.end_thread == True:
print(_("Ending thread"))
return
event_type, event_data = self.camera.wait_for_event(self.timeout)
if event_type == gp.GP_EVENT_FILE_ADDED:
# ~ print("file added")
self.capture_image(None, event_data)
# ~ root.after(self.timeout, self.wait_for_capture)
self.wait_for_capture()
async def export_animation(self):
ffmpeg = (
FFmpeg()
# overwrite file
.option("y")
.input(
self.input_filename,
self.input_options)
.output(
self.output_filename,
self.output_options
)
)
await ffmpeg.execute()
def trigger_export_animation(self, event):
output_folder = filedialog.askdirectory()
self.output_filename = "{folder}{sep}{filename}".format(folder=output_folder, sep=os.sep, filename=self.output_filename)
print(_("Exporting to {}").format(self.output_filename))
self.export_task = asyncio.run(self.export_animation())
# check with self.export_task.done() == True ? https://stackoverflow.com/questions/69350645/proper-way-to-retrieve-the-result-of-tasks-in-asyncio
def end_bg_loop(KISStopmo):
KISStopmo.camera.exit()
KISStopmo.end_thread = True
root = tk.Tk()
toot = KISStopmo(root)
root.protocol('WM_DELETE_WINDOW', end_bg_loop(toot))
root.mainloop()