exif_change_date.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. import os
  2. import sys
  3. from datetime import datetime
  4. from pathlib import Path
  5. from PIL import Image, TiffImagePlugin
  6. import piexif
  7. from concurrent.futures import ThreadPoolExecutor, as_completed
  8. def get_arbitrary_date():
  9. """Prompts the user for a date. Returns Unix epoch if blank, or the parsed target date."""
  10. while True:
  11. user_input = input("Enter the target date and time (YYYY-MM-DD HH:MM:SS) [Press Enter for 1970-01-01 00:00:00]: ").strip()
  12. if not user_input:
  13. return "1970:01:01 00:00:00"
  14. try:
  15. # Parse user's hyphenated input
  16. dt = datetime.strptime(user_input, "%Y-%m-%d %H:%M:%S")
  17. # Return colon-separated EXIF format
  18. return dt.strftime("%Y:%m:%d %H:%M:%S")
  19. except ValueError:
  20. print("Incorrect format. You must use YYYY-MM-DD HH:MM:SS or press Enter for 1970. Try again.", file=sys.stderr)
  21. def update_file_timestamps(file_path, exif_date_str):
  22. """Updates EXIF metadata/native chunks based on format, and sets filesystem mtime/atime."""
  23. try:
  24. # Match the colon format returned by get_arbitrary_date
  25. dt = datetime.strptime(exif_date_str, "%Y:%m:%d %H:%M:%S")
  26. target_timestamp = dt.timestamp()
  27. except ValueError as e:
  28. return f"Failed to parse internal date string {exif_date_str}: {e}"
  29. file_suffix = file_path.suffix.lower()
  30. meta_updated = False
  31. # 1. Handle JPEG/WEBP via piexif
  32. if file_suffix in {'.jpg', '.jpeg', '.webp'}:
  33. try:
  34. try:
  35. exif_dict = piexif.load(str(file_path))
  36. except Exception:
  37. exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "Interop": {}, "1st": {}, "thumbnail": None}
  38. date_bytes = exif_date_str.encode('utf-8')
  39. exif_dict["0th"][piexif.ImageIFD.DateTime] = date_bytes
  40. exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = date_bytes
  41. exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized] = date_bytes
  42. exif_bytes = piexif.dump(exif_dict)
  43. piexif.insert(exif_bytes, str(file_path))
  44. meta_updated = True
  45. except Exception as e:
  46. return f"Warning: Failed to update EXIF for JPEG/WEBP {file_path.name}: {e}"
  47. # 2. Handle PNG via Pillow
  48. elif file_suffix == '.png':
  49. try:
  50. with Image.open(file_path) as img:
  51. exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "Interop": {}, "1st": {}, "thumbnail": None}
  52. date_bytes = exif_date_str.encode('utf-8')
  53. exif_dict["0th"][piexif.ImageIFD.DateTime] = date_bytes
  54. exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = date_bytes
  55. exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized] = date_bytes
  56. exif_bytes = piexif.dump(exif_dict)
  57. img.save(file_path, exif=exif_bytes)
  58. meta_updated = True
  59. except Exception as e:
  60. return f"Warning: Failed to inject EXIF chunk into PNG {file_path.name}: {e}"
  61. # 3. Handle TIFF via structured TiffImagePlugin tags
  62. elif file_suffix in {'.tiff', '.tif'}:
  63. try:
  64. with Image.open(file_path) as img:
  65. tiff_info = TiffImagePlugin.ImageFileDirectoryV2()
  66. tiff_info[306] = exif_date_str
  67. tiff_info[36867] = exif_date_str
  68. tiff_info[36868] = exif_date_str
  69. img.save(file_path, tiffinfo=tiff_info)
  70. meta_updated = True
  71. except Exception as e:
  72. return f"Warning: Failed to update TIFF tags for {file_path.name}: {e}"
  73. # 4. Apply filesystem updates (mtime and atime) to everything
  74. try:
  75. os.utime(str(file_path), (target_timestamp, target_timestamp))
  76. status = "META + FS" if meta_updated else "FS Only"
  77. return f"Updated ({status}): {file_path.name}"
  78. except Exception as e:
  79. return f"Failed to update filesystem time for {file_path.name}: {e}"
  80. def main():
  81. try:
  82. current_dir = Path.cwd()
  83. except FileNotFoundError:
  84. pwd_env = os.environ.get('PWD')
  85. if pwd_env and os.path.exists(pwd_env):
  86. current_dir = Path(pwd_env)
  87. else:
  88. print("Error: The current directory does not exist. 'cd' into a valid directory and try again.", file=sys.stderr)
  89. sys.exit(1)
  90. valid_extensions = {
  91. '.jpg', '.jpeg', '.png', '.webp', '.tiff', '.tif',
  92. '.bmp', '.gif', '.heic', '.heif', '.cr2', '.nef'
  93. }
  94. try:
  95. image_files = [
  96. f for f in current_dir.rglob('*')
  97. if f.is_file() and f.suffix.lower() in valid_extensions
  98. ]
  99. except FileNotFoundError:
  100. print(f"Error: Directory path '{current_dir}' is unreachable.", file=sys.stderr)
  101. sys.exit(1)
  102. if not image_files:
  103. print("No matching images found in this directory or subdirectories.")
  104. return
  105. print(f"Found {len(image_files)} images recursively under {current_dir}")
  106. target_date = get_arbitrary_date()
  107. print(f"\nProcessing files concurrently using target date: {target_date}...")
  108. max_workers = min(32, (os.cpu_count() or 1) * 5)
  109. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  110. future_to_img = {executor.submit(update_file_timestamps, img_path, target_date): img_path for img_path in image_files}
  111. for future in as_completed(future_to_img):
  112. result_message = future.result()
  113. if result_message:
  114. print(result_message)
  115. print("\nFinished processing all files.")
  116. if __name__ == "__main__":
  117. main()