diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..9c4f771 --- /dev/null +++ b/config.toml @@ -0,0 +1,25 @@ +[DEFAULT] +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 = 1920 +screen_h = 1080 +framerate = 16 +vflip = true +hflip = false +export_options = 'scale=1920:-1,crop=1920:1080:0:102' +[CAMERA] +# Nikon D40x +capturemode = 3 # use IR remote +imagesize = 2 # use size S (1936x1296) +whitebalance = 1 # Natural light +capturetarget = 0 # Internal memory +nocfcardrelease = 0 # Allow capture without sd card +recordingmedia = 1 # Write to RAM +[CHECK] +acpower = 0 # we d'rather have this set to 0 which means we're running on AC \ No newline at end of file diff --git a/locales/en/LC_MESSAGES/template.mo b/locales/en/LC_MESSAGES/template.mo new file mode 100644 index 0000000..9f2e039 Binary files /dev/null and b/locales/en/LC_MESSAGES/template.mo differ diff --git a/locales/fr/LC_MESSAGES/template.mo b/locales/fr/LC_MESSAGES/template.mo new file mode 100644 index 0000000..6bf5985 Binary files /dev/null and b/locales/fr/LC_MESSAGES/template.mo differ diff --git a/main_c.py b/main_c.py index d708d14..a01c878 100644 --- a/main_c.py +++ b/main_c.py @@ -1,82 +1,110 @@ #!/usr/bin/env python3 # 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 - -from PIL import Image, ImageTk, ImageFilter -# DLSR support -import gphoto2 as gp +from tkinter import filedialog, messagebox +import tomllib # async FFMPEG exportation support import asyncio from ffmpeg.asyncio import FFmpeg - -from itertools import count from send2trash import send2trash -# TODO +# TODO : Todo List # # X wait event mode # X remove frames # X keep images in memory (odict > list ?) -# o resize images upon shooting/ crop output video ? -# o project workflow +# / 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. -# - startup : offer to continue previous project or new project -# - if continue, find last frame in folder X else, find next letter and create folder -# o notify export endingS +# 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) -# V startup frame -# o open existing project screen +# X startup frame +# o webcam support (pygame, win and linux only) +# X Import config values from config file +# X Translation +# o Better settings names 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'] -project_settings = { +# 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':'', + '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) +# TODO : Check resulting config has needed settings, fill missing ones + class KISStopmo(tk.Tk): def __init__(self, *args, **kargs): + self.check_config() + print(project_settings) # Default config - # TODO : Import value from config file - # Script settings - self.onion_skin = False - self.onionskin_alpha = 0.4 - self.onionskin_fx = False - self.fullscreen_bool = True - self.screen_w, self.screen_h = 640, 480 - self.framerate = 16 - self.photo = None - # ~ self.savepath = os.getcwd() + # 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 - - # ~ self.project_settings = {'file_extension' : 'JPG'} - # ~ self.project_letter = "A" - - # Camera Settings - self.camera_settings = {'capturemode': 3, - 'imagesize': 2, - 'whitebalance': 5, - 'capturetarget': 0, - 'nocfcardrelease': 0, - 'recordingmedia' : 1 - } - - self.camera_status = { 'acpower': 0 # we d'rather have this set to 0 which means we're running on AC - } - # Window setup self.screen_w, self.screen_h = root.winfo_screenwidth(), root.winfo_screenheight() root.wm_attributes("-fullscreen", self.fullscreen_bool) @@ -87,8 +115,7 @@ class KISStopmo(tk.Tk): self.label.pack() # Savepath setup - - self.projects_folder = filedialog.askdirectory() + 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] @@ -100,8 +127,7 @@ class KISStopmo(tk.Tk): 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 = {"vf":"scale=1920:-1,crop=1920:1080:0:102"} - + 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) @@ -115,9 +141,9 @@ class KISStopmo(tk.Tk): 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!") + print(_("Warning: Some settings are not set to the recommended value!")) except: - self.splash_text = 'Camera not found or busy.' + self.splash_text = _("Camera not found or busy.") self.timeout = 3000 # milliseconds @@ -151,15 +177,32 @@ class KISStopmo(tk.Tk): if project_settings['trigger_mode'] != 'event': root.bind("", 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 img = Image.new('RGB', (self.screen_w, self.screen_h), (128,128,128)) d = ImageDraw.Draw(img) if self.splash_text is not None: - fnt = ImageFont.truetype("FreeMono", 100) - d.text((900, 500 ), self.splash_text, fill=(255, 0, 0), font=fnt) + fnt = ImageFont.truetype("FreeMono", 50) + fnt_len = fnt.getlength(self.splash_text) + d.text((self.screen_w/2 - fnt_len/2, self.screen_h/2 ), self.splash_text, fill=(255, 0, 0), font=fnt) # Save to file # img.save('test.png') # Use in-memory @@ -212,13 +255,19 @@ class KISStopmo(tk.Tk): 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 @@ -234,9 +283,9 @@ class KISStopmo(tk.Tk): 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(os.getcwd())) + 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)) + print(_("Image is being saved to {}").format(target_path)) cam_file.save(target_path) # ~ return 0 @@ -252,6 +301,7 @@ class KISStopmo(tk.Tk): 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: @@ -291,7 +341,7 @@ class KISStopmo(tk.Tk): # ~ 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(str(i) + " does not exist") + print(_("{} does not exist").format(str(i))) return self.get_frames_list(folder) @@ -304,7 +354,7 @@ class KISStopmo(tk.Tk): self.img_list[list(self.img_list.keys())[i]] = None - # FIXME + # FIXME: Does this still work ? def remove_frame(self, event): if len(list(self.img_list.items())): @@ -316,7 +366,7 @@ class KISStopmo(tk.Tk): return 0 # ~ print(self.img_list) - print("removing {}".format(frame_path)) + print(_("Removing {}").format(frame_path)) # trash file send2trash(frame_path) # remove entry from dict @@ -337,15 +387,18 @@ class KISStopmo(tk.Tk): 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): + 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])) - # TODO : Keep aspect ratio 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 @@ -381,7 +434,7 @@ class KISStopmo(tk.Tk): # ~ 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, self.screen_w, self.screen_h) + 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: @@ -402,7 +455,7 @@ class KISStopmo(tk.Tk): 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], self.screen_w, self.screen_h) + 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 @@ -412,7 +465,7 @@ class KISStopmo(tk.Tk): photo = ImageTk.PhotoImage(new_image) if self.onion_skin: if index: - photo = self.apply_onionskin(new_image, self.onionskin_alpha, self.onionskin_fx) + 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 @@ -489,14 +542,14 @@ class KISStopmo(tk.Tk): def apply_camera_settings(self, camera, config): # iterate over the settings dictionary - for setting in self.camera_settings: + 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, self.camera_settings[setting]) + self.set_config_value(config, setting, camera_settings[setting]) # validate config gp.check_result(gp.gp_camera_set_config(self.camera, config)) @@ -512,11 +565,11 @@ class KISStopmo(tk.Tk): def check_status(self, camera, config): - for value in self.camera_status: + 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, self.camera_status) + cur_check_value, cur_check_choice = self.check_status_value(config, value, camera_status) if cur_check_value == cur_check_choice: return True else: @@ -535,7 +588,7 @@ class KISStopmo(tk.Tk): speed = 30 if current_lightmeter_value < 0: # or current_lightmeter_value > 7: # ~ while current_lightmeter_value < -7: - print("speed too high") + 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)) @@ -548,7 +601,7 @@ class KISStopmo(tk.Tk): print(speed) print(str(current_lightmeter_value) + " - " + str(current_shutterspeed_value)) if current_lightmeter_value > 0: - print("speed too low") + 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)) @@ -576,9 +629,9 @@ class KISStopmo(tk.Tk): # ~ 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 " + new_frame_path.folder + new_frame_path.name ) + print(_("Saving {}{}").format(new_frame_path.folder, new_frame_path.name)) else: - print("getting file {}".format(event_data.name)) + 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) @@ -593,9 +646,8 @@ class KISStopmo(tk.Tk): def wait_for_capture(self): if self.end_thread == True: - print("ending thread") + print(_("Ending thread")) return - # ~ print("waiting for capture") event_type, event_data = self.camera.wait_for_event(self.timeout) if event_type == gp.GP_EVENT_FILE_ADDED: # ~ print("file added") @@ -623,16 +675,17 @@ class KISStopmo(tk.Tk): def trigger_export_animation(self, event): output_folder = filedialog.askdirectory() output_folder = "{folder}{sep}{filename}".format(folder=output_folder, sep=os.sep, filename=self.output_filename) - print("exporting to " + output_folder) + print(_("Exporting to {}").format(output_folder)) 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.protocol('WM_DELETE_WINDOW', end_bg_loop(toot)) root.mainloop() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a56785f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +gphoto2 +pillow +python-ffmpeg +Send2Trash