751 lines
32 KiB
Python
751 lines
32 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 io import BytesIO
|
|
from itertools import count
|
|
import locale
|
|
import os
|
|
from PIL import Image, ImageTk, ImageFilter, ImageDraw, ImageOps, ImageFont
|
|
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 ?)
|
|
# X 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
|
|
# X colored onion skin frame (pillow filters)
|
|
# X startup frame
|
|
# X Import config values from config file
|
|
# X Translation
|
|
# X Allow opening and exporting without a camera connected
|
|
# o Better settings names
|
|
# o webcam support (pygame, win and linux only)
|
|
# o picam support (picamera2)
|
|
# o notify export ending
|
|
|
|
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 = {
|
|
# DSLR = 0, picam = 1, webcam = 2
|
|
'camera_type': 0,
|
|
'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 'CHECK' in project_settings:
|
|
camera_status = project_settings['CHECK']
|
|
if 'CAMERA' in project_settings:
|
|
camera_settings = project_settings['CAMERA']
|
|
if 'DEFAULT' in project_settings:
|
|
project_settings = project_settings['DEFAULT']
|
|
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()
|
|
if project_settings['camera_type'] != 0:
|
|
from picamera2 import Picamera2
|
|
# Default config
|
|
# Set script settings according to config file
|
|
self.onion_skin = project_settings['onion_skin_onstartup']
|
|
self.onionskin_alpha_default = 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
|
|
if project_settings['camera_type'] != 0:
|
|
try:
|
|
self.camera = Picamera2()
|
|
self.picam_conf_full = self.camera.create_still_configuration(main={"size":(1920,1080)}, lores={"size":(800,600)})
|
|
self.camera.configure(self.picam_conf_full)
|
|
# Autofocus, get lens position and switch to manual mode
|
|
# Set Af mode to Manual (1). Default is Continuous (2), Auto is 1
|
|
# TODO: lock exposure, wb
|
|
self.camera.set_controls({'AfMode':1})
|
|
self.camera.start(show_preview=False)
|
|
self.camera.autofocus_cycle()
|
|
self.camera_lenspos = self.camera.capture_metadata()['LensPosition']
|
|
self.camera.set_controls({'AfMode':0, 'AwbEnable': False, 'AeEnable': False})
|
|
self.camera.stop()
|
|
except:
|
|
self.camera = False
|
|
self.splash_text += _("\nCamera not found or busy.")
|
|
else:
|
|
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.camera = False
|
|
|
|
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_default, 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("<B>", self.toggle_onionskin)
|
|
root.bind("<N>", 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):
|
|
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.split('\n')[0])
|
|
splash_draw.multiline_text((self.screen_w/2 - font_len/2, self.screen_h/2 ), self.splash_text, fill=(255, 255, 255), font=font, align='center', spacing=20)
|
|
# 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)
|
|
|
|
|
|
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)):
|
|
# ~ print(frame_list)
|
|
for i in frame_list.keys():
|
|
# ~ if os.path.exists(os.path.realpath(frame_list[i])):
|
|
if os.path.exists(os.path.join(folder, 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.join(folder, i), os.path.join(folder, "{}{}{}".format(self.project_letter, next(counter), project_settings['file_extension'])))
|
|
# ~ print(os.path.join(folder, "{}{}{}".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
|
|
|
|
|
|
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)
|
|
frame_path = os.path.join(folder_path, 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),reducing_gap=1.5)
|
|
image = ImageOps.fit(image, (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):
|
|
# TODO : check event mode stille works
|
|
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 name == X.-001.JPG, we don't have any frame yet, so display splashscreen
|
|
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
|
|
if new_image:
|
|
photo = ImageTk.PhotoImage(new_image)
|
|
if self.onion_skin:
|
|
if index:
|
|
photo = self.apply_onionskin(new_image, project_settings['onionskin_alpha_default'], 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)
|
|
if project_settings['camera_type'] != 0:
|
|
self.camera.start(show_preview=False)
|
|
self.camera.set_controls({"LensPosition": self.camera_lenspos})
|
|
self.camera.capture_file(target_path, 'main', format='jpeg')
|
|
self.camera.stop()
|
|
else:
|
|
# Get file from DSLR 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.export_filename,
|
|
self.output_options
|
|
)
|
|
)
|
|
|
|
await ffmpeg.execute()
|
|
|
|
def trigger_export_animation(self, event):
|
|
# ~ output_folder = filedialog.askdirectory()
|
|
# TODO : Check what happens when closing the window without entering a name
|
|
self.export_filename = ()
|
|
self.export_filename = filedialog.asksaveasfilename(defaultextension='.mp4', filetypes=((_("Mp4 files"), '*.mp4'),))
|
|
if not self.export_filename:
|
|
# ~ self.export_filename = "{folder}{sep}{filename}".format(folder=os.getcwd(), sep=os.sep, filename=self.output_filename)
|
|
return False
|
|
print(_("Exporting to {}").format(self.export_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):
|
|
if KISStopmo.camera is not False:
|
|
if project_settings['camera_type'] != 0:
|
|
KISStopmo.camera.stop()
|
|
else:
|
|
KISStopmo.camera.exit()
|
|
KISStopmo.end_thread = True
|
|
|
|
root = tk.Tk()
|
|
toot = KISStopmo(root)
|
|
root.protocol('WM_DELETE_WINDOW', end_bg_loop(toot))
|
|
root.mainloop()
|
|
|
|
|