#!/usr/bin/env python3 import argparse import os import re import subprocess import sys from pathlib import Path MAX_MENU_OPTIONS = 3 DEFAULT_FILE_PERMISSIONS = 0o755 def read_commands(file_path: str) -> list[str]: """Read ExifTool commands from a file, stripping whitespace and filtering empty lines.""" try: with open(file_path, "r") as f: commands = [line.strip() for line in f if line.strip()] if not commands: print( f"\033[91mError:\033[0m Input file '{file_path}' contains no valid command lines." ) sys.exit(1) return commands except FileNotFoundError: print(f"\033[91mError:\033[0m Input file '{file_path}' does not exist.") sys.exit(1) except PermissionError: print(f"\033[91mError:\033[0m Permission denied reading file '{file_path}'.") sys.exit(1) except OSError as e: print(f"\033[91mError:\033[0m reading file '{file_path}': {e}") sys.exit(1) def extract_frame_count(last_command: str) -> int: """Extract the frame count from the filename pattern in the last command.""" match = re.search(r"\*_(\d+)\.\w+$", last_command) if match: return int(match.group(1)) else: print( f"\033[91mError:\033[0m Could not determine frame count from the last command line:" ) print(f" Expected pattern like '*_12.jpg' at the end.") print(f" Last command line processed: {last_command}") sys.exit(1) def get_film_info() -> str: """Prompt user for film size and stock, returning hierarchical subject string.""" # Define common film sizes film_sizes = ["35mm", "120", "4x5", "5x7", "8x10"] print("\n--- Film Stock Information ---") # Prompt for film size selection print("Select film size:") for i, size in enumerate(film_sizes, 1): print(f" {i}. {size}") print(f" {len(film_sizes) + 1}. Enter custom size") while True: try: choice_input = input(f"Enter your choice (1-{len(film_sizes) + 1}): ") if not choice_input: print("Invalid input. Please enter a number.") continue choice = choice_input.strip() choice_num = int(choice) if 1 <= choice_num <= len(film_sizes): film_size = film_sizes[choice_num - 1] break elif choice_num == len(film_sizes) + 1: film_size_input = input("Enter custom film size: ") if not film_size_input: print("Film size cannot be empty.") continue film_size = film_size_input.strip() if film_size: break else: print("Film size cannot be empty.") else: print(f"Invalid choice. Please enter a number between 1 and {len(film_sizes) + 1}.") except ValueError: print("Invalid input. Please enter a number.") # Prompt for film stock while True: film_stock_input = input("Enter film stock (e.g., Portra 400, Tri-X 400): ") if not film_stock_input: print("Film stock cannot be empty.") continue film_stock = film_stock_input.strip() if film_stock: break else: print("Film stock cannot be empty.") # Build hierarchical subject string hierarchical_subject = f"Film|{film_size}|{film_stock}" print(f"\nFilm info set to: {hierarchical_subject}") return hierarchical_subject def natural_sort_key(s: str) -> list[str | int]: """Return a list of strings and integers for natural sorting.""" return [ int(text) if text.isdigit() else text.lower() for text in re.split("([0-9]+)", s) ] def find_image_files(image_path: str, file_extension: str) -> list[str]: """Find and sort image files in the specified path naturally.""" try: p = Path(image_path) if not p.is_dir(): print( f"\033[91mError:\033[0m Image path '{image_path}' is not a valid directory." ) sys.exit(1) files = [f.name for f in p.glob(f"*{file_extension}") if f.is_file()] sorted_files = sorted(files, key=natural_sort_key) return sorted_files except PermissionError: print(f"\033[91mError:\033[0m Permission denied accessing '{image_path}'.") sys.exit(1) except OSError as e: print(f"\033[91mError:\033[0m accessing image path '{image_path}': {e}") sys.exit(1) def prompt_filename(found_files: list[str], prompt_label: str, max_options: int, file_extension: str) -> int: """Prompt user to select a filename from the found files list.""" print(f"Please choose the {prompt_label} image file:") menu_count = min(max_options, len(found_files)) for i in range(menu_count): print(f" {i+1}. {found_files[i]}") if len(found_files) > max_options: print(f" ... or type a filename from the directory") while True: choice = input(f"Enter menu number (1-{menu_count}) or type filename directly: ").strip() if not choice: print("Invalid input. Please enter a number or filename.") continue # Try menu number (only if within valid menu range) if choice.isdigit(): choice_num = int(choice) if 1 <= choice_num <= menu_count: return choice_num - 1 # Try as filename (with and without extension) if choice in found_files: return found_files.index(choice) # Try adding extension if not present if not choice.endswith(file_extension): with_ext = choice + file_extension if with_ext in found_files: return found_files.index(with_ext) print(f"Filename '{choice}' not found. Use menu number or type filename.") def select_image_files(found_files: list[str], frame_count: int, file_extension: str) -> list[str]: """Handle user interaction to select start and end image files, supporting bidirectional walking. Parameters ---------- found_files : list of str List of filenames found in the specified path, naturally sorted. frame_count : int The expected number of frames or files required for processing. Returns ------- selected_files : list of str List of selected image filenames in the order they'll be processed. Notes ----- This function prompts for start and end files to define a range. If end comes before start in the sorted list, the range walks backward. If range has more or fewer files than commands, warns and uses available files in order. """ num_found = len(found_files) # Handle equal case first - no range selection needed if num_found == frame_count: print(f"\nFound {num_found} images, matching the {frame_count} commands.") return found_files.copy() # Show count info and prompt for start file print(f"\nFound {num_found} images, but expected {frame_count} files.") start_idx = prompt_filename(found_files, "starting", MAX_MENU_OPTIONS, file_extension) start_filename = found_files[start_idx] print(f"Start file: {start_filename}") # Prompt for end file end_idx = prompt_filename(found_files, "ending", MAX_MENU_OPTIONS, file_extension) end_filename = found_files[end_idx] print(f"End file: {end_filename}") # Determine direction and build range is_reverse = end_idx < start_idx if is_reverse: # Walk backward: from start_idx down to end_idx (inclusive) # Slice end_idx:start_idx+1 gives files from end up to (and including) start, then reverse selected_files = found_files[end_idx:start_idx + 1][::-1] else: # Walk forward: start to end (inclusive) selected_files = found_files[start_idx:end_idx + 1] range_count = len(selected_files) # Summarize selection direction_str = "reverse" if is_reverse else "forward" print( f"\nSelected range: {selected_files[0]} ... {selected_files[-1]} " f"({range_count} files, {direction_str})" ) # Warn about count mismatch if range_count < frame_count: print( f"\033[93mWarning:\033[0m Range has {range_count} files but {frame_count} commands. " f"Only the first {range_count} commands will be used." ) elif range_count > frame_count: print( f"\033[93mWarning:\033[0m Range has {range_count} files but only {frame_count} commands. " f"Files beyond position {frame_count} will be ignored." ) # Confirm proceed_input = input("Do you want to proceed with this selection? (y/n): ") if not proceed_input: print("Invalid input. Please enter 'y' or 'n'.") return [] proceed = proceed_input.strip().lower() if proceed != "y": print("Selection cancelled.") return [] return selected_files def generate_final_commands(commands: list[str], selected_files: list[str], image_path: str, film_info: str) -> list[str]: """Generate the final list of commands targeting specific files.""" final_commands = [] num_to_process = min(len(commands), len(selected_files)) for i in range(num_to_process): cmd_template = commands[i] target_file = selected_files[i] full_target_path = Path(image_path) / target_file quoted_target_path = f'"{str(full_target_path)}"' modified_cmd = re.sub( r"\s+\S*\*_\d+\.\w+$", f" {quoted_target_path}", cmd_template ) if modified_cmd == cmd_template: print( f"\033[93mWarning:\033[0m Could not replace filename pattern in command: {cmd_template}" ) print(" Attempting to append filename.") cmd_parts = cmd_template.split() if len(cmd_parts) > 1: modified_cmd = ( cmd_parts[0] + " " + " ".join(cmd_parts[1:-1]) + f" {quoted_target_path}" ) else: modified_cmd = cmd_template + f" {quoted_target_path}" # Add film info to the command final_cmd = f"{modified_cmd.strip()} -XMP-lr:HierarchicalSubject+=\"{film_info}\"\n" final_commands.append(final_cmd) return final_commands def generate_shell_script(commands: list[str], output_path: str, input_filename: str, target_dir: str) -> str: """Generate a shell script with the modified commands.""" try: output_file = Path(target_dir) / output_path with open(output_file, "w") as f: f.write("#!/bin/bash\n\n") f.write(f"# Generated ExifTool commands from: {input_filename}\n") f.write(f"# Target directory: {os.path.abspath(target_dir)}\n\n") for cmd in commands: f.write(cmd) os.chmod(output_file, DEFAULT_FILE_PERMISSIONS) return str(output_file) except PermissionError: print(f"\033[91mError:\033[0m Permission denied writing shell script '{output_file}'.") sys.exit(1) except OSError as e: print(f"\033[91mError:\033[0m writing shell script '{output_file}': {e}") sys.exit(1) def main(): """Main function to process ExifTool commands, select target images interactively, and generate a shell script. This function reads the input file containing ExifTool commands, interacts with the user to configure settings such as the image path and file extension, finds and selects the relevant image files based on the expected frame count, generates the final list of modified commands targeting specific files, previews these commands for confirmation, and finally generates a shell script with these commands. The generated shell script is made executable and placed in the target directory. """ parser = argparse.ArgumentParser( description="Process ExifTool commands, select target images interactively, and generate a shell script.", epilog="Example: ./exiftool-prep.py commands.txt", ) parser.add_argument( "input_file", help="Path to the ExifTool commands file (e.g., Fuji_400H_ExifToolCmds.txt)", ) args = parser.parse_args() # Read the commands (now cleaned) and extract frame count commands = read_commands(args.input_file) frame_count = extract_frame_count(commands[-1]) # Will use the last non-empty line # --- Get user input for modifications --- print("\n--- Image File Configuration ---") current_dir = os.getcwd() image_path = "" # Initialize image_path while True: image_path_input = input( f"Enter the path to the directory containing image files (press Enter to use current: {current_dir}): " ).strip() # Assign chosen/default path to image_path image_path = image_path_input if image_path_input else current_dir if Path(image_path).is_dir(): break else: print( f"\033[91mError:\033[0m '{image_path}' is not a valid directory. Please try again." ) file_extension = input( "Enter the image file extension (e.g., .jpg, .tif - default: .jpg): " ).strip() if not file_extension: file_extension = ".jpg" elif not file_extension.startswith("."): file_extension = f".{file_extension}" # Get film stock information film_info = get_film_info() # --- Find and Select Image Files --- print(f"\nScanning '{image_path}' for '{file_extension}' files...") found_files = find_image_files(image_path, file_extension) if not found_files: print( f"\033[91mError:\033[0m No files matching '{file_extension}' found in '{image_path}'." ) sys.exit(1) selected_files = select_image_files(found_files, frame_count, file_extension) if not selected_files: # Should only happen if user explicitly cancels print("No image files selected. Exiting.") sys.exit(0) if len(selected_files) < len(commands): print( f"\033[93mWarning:\033[0m Only {len(selected_files)} images were selected. The first {len(selected_files)} commands will be used." ) # --- Generate Final Commands --- # Pass the confirmed image_path and film info to generate_final_commands final_commands = generate_final_commands(commands, selected_files, image_path, film_info) # --- Preview and Confirm Script Generation --- print("\n--- Preview of Final Commands (first 3) ---") for cmd in final_commands[:3]: print(cmd.strip()) if len(final_commands) > 3: print(f"... and {len(final_commands) - 3} more commands") confirm_input = input("\nGenerate the shell script with these commands? (y/n): ") if not confirm_input: print("Invalid input. Please enter 'y' or 'n'.") sys.exit(0) confirm = confirm_input.strip().lower() if confirm != "y": print("Script generation cancelled.") sys.exit(0) # --- Generate Shell Script --- output_file = f"run_exiftool_{Path(args.input_file).stem}.sh" # Pass necessary info to generate_shell_script and get the full path script_path = generate_shell_script( final_commands, output_path=output_file, input_filename=Path(args.input_file).name, target_dir=image_path, ) print(f"\nShell script generated: \033[1m{script_path}\033[0m") print("The script is executable and ready to run.") print(f"Make sure to review the script before execution.") run_script_input = input("\nDo you want to run the shell script now? (y/n): ") if not run_script_input: print("Invalid input. Please enter 'y' or 'n'.") sys.exit(0) run_script = run_script_input.strip().lower() if run_script == "y": print(f"\nRunning {script_path}...") result = subprocess.run([script_path]) print(f"\nExifTool completed with exit code: {result.returncode}") else: print("Shell script saved. You can run it manually later.") if __name__ == "__main__": main()