exif_change_date.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  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 sanitize_exif_dict(exif_dict):
  22. """Fixes type mismatches in raw EXIF tags that make piexif.dump choke."""
  23. if "Exif" in exif_dict:
  24. # Tag 41729 is SceneType. Must be bytes.
  25. if 41729 in exif_dict["Exif"] and isinstance(exif_dict["Exif"][41729], int):
  26. exif_dict["Exif"][41729] = bytes([exif_dict["Exif"][41729]])
  27. # Tag 41728 is FileSource. Must be bytes.
  28. if 41728 in exif_dict["Exif"] and isinstance(exif_dict["Exif"][41728], int):
  29. exif_dict["Exif"][41728] = bytes([exif_dict["Exif"][41728]])
  30. return exif_dict
  31. def update_file_timestamps(file_path, exif_date_str):
  32. """Updates EXIF metadata/native chunks based on format, and sets filesystem mtime/atime."""
  33. try:
  34. # Match the colon format returned by get_arbitrary_date
  35. dt = datetime.strptime(exif_date_str, "%Y:%m:%d %H:%M:%S")
  36. target_timestamp = dt.timestamp()
  37. except ValueError as e:
  38. return f"Failed to parse internal date string {exif_date_str}: {e}"
  39. file_suffix = file_path.suffix.lower()
  40. meta_updated = False
  41. # 1. Handle JPEG/WEBP via piexif
  42. if file_suffix in {'.jpg', '.jpeg', '.webp'}:
  43. try:
  44. try:
  45. exif_dict = piexif.load(str(file_path))
  46. exif_dict = sanitize_exif_dict(exif_dict)
  47. except Exception:
  48. exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "Interop": {}, "1st": {}, "thumbnail": None}
  49. date_bytes = exif_date_str.encode('utf-8')
  50. exif_dict["0th"][piexif.ImageIFD.DateTime] = date_bytes
  51. exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = date_bytes
  52. exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized] = date_bytes
  53. exif_bytes = piexif.dump(exif_dict)
  54. piexif.insert(exif_bytes, str(file_path))
  55. meta_updated = True
  56. except Exception as e:
  57. return f"Warning: Failed to update EXIF for JPEG/WEBP {file_path.name}: {e}"
  58. # 2. Handle PNG via Pillow
  59. elif file_suffix == '.png':
  60. try:
  61. with Image.open(file_path) as img:
  62. exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "Interop": {}, "1st": {}, "thumbnail": None}
  63. date_bytes = exif_date_str.encode('utf-8')
  64. exif_dict["0th"][piexif.ImageIFD.DateTime] = date_bytes
  65. exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = date_bytes
  66. exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized] = date_bytes
  67. exif_bytes = piexif.dump(exif_dict)
  68. img.save(file_path, exif=exif_bytes)
  69. meta_updated = True
  70. except Exception as e:
  71. return f"Warning: Failed to inject EXIF chunk into PNG {file_path.name}: {e}"
  72. # 3. Handle TIFF via structured TiffImagePlugin tags
  73. elif file_suffix in {'.tiff', '.tif'}:
  74. try:
  75. with Image.open(file_path) as img:
  76. tiff_info = TiffImagePlugin.ImageFileDirectoryV2()
  77. tiff_info[306] = exif_date_str
  78. tiff_info[36867] = exif_date_str
  79. tiff_info[36868] = exif_date_str
  80. img.save(file_path, tiffinfo=tiff_info)
  81. meta_updated = True
  82. except Exception as e:
  83. return f"Warning: Failed to update TIFF tags for {file_path.name}: {e}"
  84. # 4. Apply filesystem updates (mtime and atime) to everything
  85. try:
  86. os.utime(str(file_path), (target_timestamp, target_timestamp))
  87. status = "META + FS" if meta_updated else "FS Only"
  88. return f"Updated ({status}): {file_path.name}"
  89. except Exception as e:
  90. return f"Failed to update filesystem time for {file_path.name}: {e}"
  91. def main():
  92. try:
  93. current_dir = Path.cwd()
  94. except FileNotFoundError:
  95. pwd_env = os.environ.get('PWD')
  96. if pwd_env and os.path.exists(pwd_env):
  97. current_dir = Path(pwd_env)
  98. else:
  99. print("Error: The current directory does not exist. 'cd' into a valid directory and try again.", file=sys.stderr)
  100. sys.exit(1)
  101. valid_extensions = {
  102. '.jpg', '.jpeg', '.png', '.webp', '.tiff', '.tif',
  103. '.bmp', '.gif', '.heic', '.heif', '.cr2', '.nef', '.webm'
  104. }
  105. try:
  106. image_files = [
  107. f for f in current_dir.rglob('*')
  108. if f.is_file() and f.suffix.lower() in valid_extensions
  109. ]
  110. except FileNotFoundError:
  111. print(f"Error: Directory path '{current_dir}' is unreachable.", file=sys.stderr)
  112. sys.exit(1)
  113. if not image_files:
  114. print("No matching files found in this directory or subdirectories.")
  115. return
  116. print(f"Found {len(image_files)} files recursively under {current_dir}")
  117. target_date = get_arbitrary_date()
  118. print(f"\nProcessing files concurrently using target date: {target_date}...")
  119. max_workers = min(32, (os.cpu_count() or 1) * 5)
  120. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  121. future_to_img = {executor.submit(update_file_timestamps, img_path, target_date): img_path for img_path in image_files}
  122. for future in as_completed(future_to_img):
  123. result_message = future.result()
  124. if result_message:
  125. print(result_message)
  126. print("\nFinished processing all files.")
  127. if __name__ == "__main__":
  128. main()