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>
324 lines
9.1 KiB
Python
324 lines
9.1 KiB
Python
"""
|
|
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())
|