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:
2026-02-10 16:22:12 +01:00
commit 25bd8bc961
14 changed files with 2715 additions and 0 deletions

323
json_to_metadata/cli.py Normal file
View File

@@ -0,0 +1,323 @@
"""
Interface en ligne de commande pour json-to-metadata.
Ce module fournit le point d'entrée CLI pour le script de gestion
des métadonnées EXIF.
"""
import argparse
import logging
import sys
from pathlib import Path
from . import __version__
from .exif_writer import (
ExiftoolError,
ExiftoolNotFoundError,
check_exiftool_available,
write_exif_with_fallback,
)
from .json_parser import ValidationError, load_json, validate_roll
from .metadata_mapper import map_frame_to_exif
from .xmp_writer import write_xmp_sidecar
# Configuration du logging
logger = logging.getLogger('json_to_metadata')
def setup_logging(verbose: bool = False) -> None:
"""
Configure le système de logging.
Args:
verbose: Si True, active le niveau DEBUG.
"""
level = logging.DEBUG if verbose else logging.INFO
format_str = '%(levelname)s: %(message)s'
if verbose:
format_str = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
logging.basicConfig(
level=level,
format=format_str,
handlers=[logging.StreamHandler(sys.stderr)]
)
def find_image_for_frame(
frame: dict,
directory: Path,
roll_id: str | int
) -> Path | None:
"""
Recherche le fichier image correspondant à un frame.
Essaie plusieurs conventions de nommage courantes.
Args:
frame: Dictionnaire du frame.
directory: Répertoire contenant les images.
roll_id: Identifiant du roll.
Returns:
Chemin vers l'image trouvée, ou None si non trouvée.
"""
frame_id = frame.get('id', '')
# Extensions d'images supportées
extensions = ['.tif', '.tiff', '.jpg', '.jpeg', '.avif', '.heic', '.webp', '.png']
# Patterns de noms à essayer
patterns = [
f"{frame_id}", # 1, 2, 3...
f"{frame_id:02d}" if isinstance(frame_id, int) else frame_id, # 01, 02...
f"{frame_id:03d}" if isinstance(frame_id, int) else frame_id, # 001, 002...
f"frame_{frame_id}", # frame_1, frame_2...
f"frame{frame_id}", # frame1, frame2...
f"{roll_id}_{frame_id}", # roll_frame
f"img_{frame_id}", # img_1, img_2...
f"IMG_{frame_id}", # IMG_1, IMG_2...
]
for pattern in patterns:
for ext in extensions:
# Essayer en minuscule et majuscule
for name in [f"{pattern}{ext}", f"{pattern}{ext.upper()}"]:
filepath = directory / name
if filepath.exists():
return filepath
return None
def process_roll(
roll: dict,
image_dir: Path,
dry_run: bool = False,
force_xmp: bool = False
) -> tuple[int, int, int]:
"""
Traite un roll complet : valide et écrit les métadonnées pour chaque frame.
Args:
roll: Dictionnaire du roll validé.
image_dir: Répertoire contenant les images.
dry_run: Mode simulation.
force_xmp: Force la création de fichiers XMP.
Returns:
Tuple (succès, échecs, images non trouvées).
"""
success_count = 0
failure_count = 0
not_found_count = 0
roll_id = roll.get('id', 'unknown')
frames = roll.get('frames', [])
logger.info(f"Traitement du roll '{roll_id}' : {len(frames)} frames")
for frame in frames:
frame_id = frame.get('id', '?')
# Rechercher l'image
image_path = find_image_for_frame(frame, image_dir, roll_id)
if image_path is None:
logger.warning(f"Image non trouvée pour le frame {frame_id}")
not_found_count += 1
continue
logger.debug(f"Image trouvée : {image_path.name}")
# Mapper les métadonnées
try:
tags = map_frame_to_exif(frame, roll)
except Exception as e:
logger.error(f"Erreur de mapping pour le frame {frame_id} : {e}")
failure_count += 1
continue
if not tags:
logger.warning(f"Aucune métadonnée à écrire pour le frame {frame_id}")
continue
# Écrire les métadonnées
try:
if force_xmp:
xmp_path = write_xmp_sidecar(image_path, tags, dry_run)
if xmp_path:
success_count += 1
logger.info(f"Frame {frame_id} : XMP créé ({image_path.name})")
else:
success, method = write_exif_with_fallback(
image_path,
tags,
lambda p, t, d: write_xmp_sidecar(p, t, d),
dry_run
)
if success:
success_count += 1
logger.info(f"Frame {frame_id} : métadonnées écrites via {method}")
else:
failure_count += 1
logger.error(f"Frame {frame_id} : échec de l'écriture")
except ExiftoolError as e:
logger.error(f"Erreur exiftool pour le frame {frame_id} : {e}")
failure_count += 1
except IOError as e:
logger.error(f"Erreur I/O pour le frame {frame_id} : {e}")
failure_count += 1
return success_count, failure_count, not_found_count
def main(args: list[str] | None = None) -> int:
"""
Point d'entrée principal du CLI.
Args:
args: Arguments de ligne de commande (utilise sys.argv si None).
Returns:
Code de retour (0 = succès, 1 = erreur).
"""
parser = argparse.ArgumentParser(
prog='json-to-metadata',
description='Écrit les métadonnées EXIF depuis un fichier JSON Exif Notes.',
epilog='Exemple : python -m json_to_metadata roll.json -d ./images'
)
parser.add_argument(
'json_file',
type=Path,
help='Chemin vers le fichier JSON Exif Notes'
)
parser.add_argument(
'-d', '--directory',
type=Path,
default=None,
help='Répertoire contenant les images (défaut : même répertoire que le JSON)'
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Mode verbeux (niveau DEBUG)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Affiche les actions sans les exécuter'
)
parser.add_argument(
'--force-xmp',
action='store_true',
help='Force la création de fichiers XMP même pour les formats supportant EXIF'
)
parser.add_argument(
'--version',
action='version',
version=f'%(prog)s {__version__}'
)
parsed_args = parser.parse_args(args)
# Configuration du logging
setup_logging(parsed_args.verbose)
# Vérification de exiftool (sauf si force-xmp)
if not parsed_args.force_xmp:
try:
check_exiftool_available()
except ExiftoolNotFoundError as e:
logger.error(str(e))
return 1
# Chargement et validation du JSON
try:
data = load_json(parsed_args.json_file)
except FileNotFoundError as e:
logger.error(str(e))
return 1
except Exception as e:
logger.error(f"Erreur lors du chargement du JSON : {e}")
return 1
# Le JSON peut contenir un roll unique ou une liste de rolls
if isinstance(data, list):
rolls = data
elif isinstance(data, dict):
# Si c'est un objet avec une clé 'rolls', l'utiliser
if 'rolls' in data:
rolls = data['rolls']
else:
# Sinon, c'est un roll unique
rolls = [data]
else:
logger.error("Format JSON non reconnu : attendu un objet ou une liste")
return 1
# Validation des rolls
valid_rolls = []
for i, roll in enumerate(rolls):
try:
validate_roll(roll)
valid_rolls.append(roll)
except ValidationError as e:
logger.error(f"Roll {i + 1} invalide : {e}")
if not valid_rolls:
logger.error("Aucun roll valide trouvé dans le fichier JSON")
return 1
# Déterminer le répertoire des images
if parsed_args.directory:
image_dir = parsed_args.directory
else:
image_dir = parsed_args.json_file.parent
if not image_dir.is_dir():
logger.error(f"Répertoire d'images invalide : {image_dir}")
return 1
logger.info(f"Répertoire des images : {image_dir}")
if parsed_args.dry_run:
logger.info("=== MODE DRY-RUN : aucune modification ne sera effectuée ===")
# Traitement de chaque roll
total_success = 0
total_failure = 0
total_not_found = 0
for roll in valid_rolls:
success, failure, not_found = process_roll(
roll,
image_dir,
dry_run=parsed_args.dry_run,
force_xmp=parsed_args.force_xmp
)
total_success += success
total_failure += failure
total_not_found += not_found
# Résumé
logger.info("=" * 50)
logger.info(f"Résumé : {total_success} succès, {total_failure} échecs, "
f"{total_not_found} images non trouvées")
if total_failure > 0:
return 1
return 0
if __name__ == '__main__':
sys.exit(main())