Initial commit: script de gestion des métadonnées EXIF pour photos argentiques
Script Python qui lit un fichier JSON généré par Exif Notes et écrit les métadonnées EXIF dans les fichiers images (TIFF, JPEG) ou crée des fichiers XMP sidecar pour les formats non supportés. Fonctionnalités : - Validation des fichiers JSON Exif Notes - Mapping complet JSON → tags EXIF standard - Écriture EXIF via exiftool avec fallback XMP - Support des formats de vitesse (1/125, 2", B/Bulb) - Support des dates ISO avec ou sans secondes - CLI avec options --dry-run, --verbose, --force-xmp Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
320
json_to_metadata/exif_writer.py
Normal file
320
json_to_metadata/exif_writer.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Module d'écriture des métadonnées EXIF via exiftool.
|
||||
|
||||
Ce module gère l'écriture des tags EXIF dans les fichiers images
|
||||
en utilisant l'outil en ligne de commande exiftool.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Extensions supportant l'EXIF intégré
|
||||
EMBEDDED_EXIF_EXTENSIONS = {'.tif', '.tiff', '.jpg', '.jpeg'}
|
||||
|
||||
# Extensions avec support partiel (tentative d'intégration)
|
||||
PARTIAL_SUPPORT_EXTENSIONS = {'.avif', '.heic', '.heif', '.webp'}
|
||||
|
||||
# Extensions nécessitant obligatoirement un fichier XMP sidecar
|
||||
XMP_ONLY_EXTENSIONS = {'.dng', '.cr2', '.cr3', '.nef', '.arw', '.orf', '.rw2', '.pef', '.raw'}
|
||||
|
||||
|
||||
class ExiftoolError(Exception):
|
||||
"""Exception levée lors d'une erreur exiftool."""
|
||||
pass
|
||||
|
||||
|
||||
class ExiftoolNotFoundError(ExiftoolError):
|
||||
"""Exception levée quand exiftool n'est pas installé."""
|
||||
pass
|
||||
|
||||
|
||||
def check_exiftool_available() -> bool:
|
||||
"""
|
||||
Vérifie que exiftool est installé et accessible.
|
||||
|
||||
Returns:
|
||||
True si exiftool est disponible.
|
||||
|
||||
Raises:
|
||||
ExiftoolNotFoundError: Si exiftool n'est pas trouvé.
|
||||
"""
|
||||
if shutil.which('exiftool') is None:
|
||||
raise ExiftoolNotFoundError(
|
||||
"exiftool n'est pas installé ou n'est pas dans le PATH.\n"
|
||||
"Installation :\n"
|
||||
" - Arch Linux : sudo pacman -S perl-image-exiftool\n"
|
||||
" - Ubuntu/Debian : sudo apt install libimage-exiftool-perl\n"
|
||||
" - macOS : brew install exiftool\n"
|
||||
" - Windows : https://exiftool.org/"
|
||||
)
|
||||
|
||||
# Vérifier que ça fonctionne
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['exiftool', '-ver'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
version = result.stdout.strip()
|
||||
logger.debug(f"exiftool version {version} détecté")
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ExiftoolError("exiftool ne répond pas (timeout)")
|
||||
except Exception as e:
|
||||
raise ExiftoolError(f"Erreur lors de la vérification de exiftool : {e}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def supports_embedded_exif(filepath: Path) -> bool:
|
||||
"""
|
||||
Détermine si le format du fichier supporte les métadonnées EXIF intégrées.
|
||||
|
||||
Args:
|
||||
filepath: Chemin vers le fichier image.
|
||||
|
||||
Returns:
|
||||
True si le format supporte l'EXIF intégré, False sinon.
|
||||
"""
|
||||
ext = filepath.suffix.lower()
|
||||
return ext in EMBEDDED_EXIF_EXTENSIONS
|
||||
|
||||
|
||||
def supports_partial_exif(filepath: Path) -> bool:
|
||||
"""
|
||||
Détermine si le format a un support partiel des métadonnées.
|
||||
|
||||
Ces formats peuvent accepter les métadonnées mais avec des limitations.
|
||||
|
||||
Args:
|
||||
filepath: Chemin vers le fichier image.
|
||||
|
||||
Returns:
|
||||
True si le format a un support partiel.
|
||||
"""
|
||||
ext = filepath.suffix.lower()
|
||||
return ext in PARTIAL_SUPPORT_EXTENSIONS
|
||||
|
||||
|
||||
def write_exif_to_file(filepath: Path, tags: dict, dry_run: bool = False) -> bool:
|
||||
"""
|
||||
Écrit les tags EXIF dans un fichier image via exiftool.
|
||||
|
||||
Args:
|
||||
filepath: Chemin vers le fichier image.
|
||||
tags: Dictionnaire de tags EXIF à écrire.
|
||||
dry_run: Si True, affiche les commandes sans les exécuter.
|
||||
|
||||
Returns:
|
||||
True si l'écriture a réussi.
|
||||
|
||||
Raises:
|
||||
ExiftoolError: Si l'écriture échoue.
|
||||
FileNotFoundError: Si le fichier n'existe pas.
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(f"Fichier image introuvable : {filepath}")
|
||||
|
||||
if not tags:
|
||||
logger.warning(f"Aucun tag à écrire pour {filepath}")
|
||||
return True
|
||||
|
||||
# Construire les arguments exiftool
|
||||
args = _build_exiftool_args(tags)
|
||||
|
||||
# Ajouter les options de base
|
||||
cmd = [
|
||||
'exiftool',
|
||||
'-overwrite_original', # Ne pas créer de backup
|
||||
'-charset', 'utf8', # Support UTF-8
|
||||
]
|
||||
cmd.extend(args)
|
||||
cmd.append(str(filepath))
|
||||
|
||||
logger.debug(f"Commande exiftool : {' '.join(cmd)}")
|
||||
|
||||
if dry_run:
|
||||
logger.info(f"[DRY-RUN] exiftool sur {filepath.name} avec {len(tags)} tags")
|
||||
return True
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||
raise ExiftoolError(f"Échec de l'écriture EXIF : {error_msg}")
|
||||
|
||||
# Vérifier le message de succès
|
||||
if '1 image files updated' in result.stdout:
|
||||
logger.info(f"Métadonnées écrites avec succès dans {filepath.name}")
|
||||
return True
|
||||
elif 'Warning' in result.stderr:
|
||||
logger.warning(f"Avertissements lors de l'écriture : {result.stderr}")
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"Sortie exiftool : {result.stdout}")
|
||||
return True
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ExiftoolError(f"Timeout lors de l'écriture dans {filepath}")
|
||||
except Exception as e:
|
||||
raise ExiftoolError(f"Erreur lors de l'écriture EXIF : {e}")
|
||||
|
||||
|
||||
def write_exif_with_fallback(
|
||||
filepath: Path,
|
||||
tags: dict,
|
||||
xmp_writer_func: callable,
|
||||
dry_run: bool = False
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Tente d'écrire les métadonnées EXIF avec fallback sur XMP sidecar.
|
||||
|
||||
Pour les formats avec support partiel (AVIF, HEIC, WebP), tente
|
||||
d'abord l'écriture directe, puis crée un XMP sidecar en cas d'échec.
|
||||
|
||||
Args:
|
||||
filepath: Chemin vers le fichier image.
|
||||
tags: Dictionnaire de tags EXIF.
|
||||
xmp_writer_func: Fonction pour écrire le fichier XMP sidecar.
|
||||
dry_run: Mode simulation.
|
||||
|
||||
Returns:
|
||||
Tuple (succès, méthode utilisée) où méthode est 'exif' ou 'xmp'.
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
|
||||
# Formats supportant pleinement l'EXIF intégré
|
||||
if supports_embedded_exif(filepath):
|
||||
success = write_exif_to_file(filepath, tags, dry_run)
|
||||
return (success, 'exif')
|
||||
|
||||
# Formats avec support partiel : tentative puis fallback
|
||||
if supports_partial_exif(filepath):
|
||||
try:
|
||||
success = write_exif_to_file(filepath, tags, dry_run)
|
||||
if success:
|
||||
return (True, 'exif')
|
||||
except ExiftoolError as e:
|
||||
logger.warning(
|
||||
f"Échec de l'écriture EXIF pour {filepath.name} : {e}. "
|
||||
f"Création d'un fichier XMP sidecar."
|
||||
)
|
||||
|
||||
# Fallback : création du fichier XMP sidecar
|
||||
try:
|
||||
xmp_path = xmp_writer_func(filepath, tags, dry_run)
|
||||
if xmp_path:
|
||||
return (True, 'xmp')
|
||||
except Exception as e:
|
||||
logger.error(f"Échec de la création du XMP sidecar : {e}")
|
||||
return (False, 'none')
|
||||
|
||||
return (False, 'none')
|
||||
|
||||
|
||||
def _build_exiftool_args(tags: dict) -> list[str]:
|
||||
"""
|
||||
Construit les arguments de ligne de commande pour exiftool.
|
||||
|
||||
Args:
|
||||
tags: Dictionnaire de tags EXIF.
|
||||
|
||||
Returns:
|
||||
Liste d'arguments pour exiftool.
|
||||
"""
|
||||
args = []
|
||||
|
||||
for tag, value in tags.items():
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
# Formatage de la valeur selon le type
|
||||
formatted_value = _format_tag_value(tag, value)
|
||||
|
||||
# Construction de l'argument
|
||||
args.append(f'-{tag}={formatted_value}')
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def _format_tag_value(tag: str, value: Any) -> str:
|
||||
"""
|
||||
Formate une valeur de tag pour exiftool.
|
||||
|
||||
Args:
|
||||
tag: Nom du tag.
|
||||
value: Valeur à formater.
|
||||
|
||||
Returns:
|
||||
Valeur formatée en chaîne.
|
||||
"""
|
||||
# Gestion des types spéciaux
|
||||
if isinstance(value, bool):
|
||||
return '1' if value else '0'
|
||||
|
||||
if isinstance(value, float):
|
||||
# Garder une précision raisonnable
|
||||
if value == int(value):
|
||||
return str(int(value))
|
||||
return f'{value:.6g}'
|
||||
|
||||
if isinstance(value, (list, tuple)):
|
||||
return ', '.join(str(v) for v in value)
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
def read_exif_from_file(filepath: Path) -> dict:
|
||||
"""
|
||||
Lit les métadonnées EXIF d'un fichier image.
|
||||
|
||||
Utile pour vérifier les métadonnées après écriture.
|
||||
|
||||
Args:
|
||||
filepath: Chemin vers le fichier image.
|
||||
|
||||
Returns:
|
||||
Dictionnaire des tags EXIF.
|
||||
|
||||
Raises:
|
||||
ExiftoolError: Si la lecture échoue.
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(f"Fichier image introuvable : {filepath}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['exiftool', '-json', '-charset', 'utf8', str(filepath)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise ExiftoolError(f"Échec de la lecture EXIF : {result.stderr}")
|
||||
|
||||
import json
|
||||
data = json.loads(result.stdout)
|
||||
return data[0] if data else {}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ExiftoolError(f"Timeout lors de la lecture de {filepath}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise ExiftoolError(f"Erreur de parsing JSON : {e}")
|
||||
Reference in New Issue
Block a user