""" 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())