382 lines
11 KiB
Python
382 lines
11 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 os
|
|
import sys
|
|
from datetime import datetime
|
|
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, parse_date
|
|
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 set_file_mtime(filepath: Path, dt: datetime, dry_run: bool = False) -> bool:
|
|
"""
|
|
Modifie la date de modification (mtime) d'un fichier.
|
|
|
|
Args:
|
|
filepath: Chemin vers le fichier.
|
|
dt: Date/heure à appliquer.
|
|
dry_run: Si True, affiche sans modifier.
|
|
|
|
Returns:
|
|
True si la modification a réussi.
|
|
"""
|
|
try:
|
|
timestamp = dt.timestamp()
|
|
|
|
if dry_run:
|
|
logger.info(f"[DRY-RUN] Modification date fichier : {filepath.name} → {dt}")
|
|
return True
|
|
|
|
# Modifie atime et mtime
|
|
os.utime(filepath, (timestamp, timestamp))
|
|
logger.debug(f"Date fichier modifiée : {filepath.name} → {dt}")
|
|
return True
|
|
|
|
except (OSError, ValueError) as e:
|
|
logger.warning(f"Impossible de modifier la date du fichier {filepath.name} : {e}")
|
|
return False
|
|
|
|
|
|
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,
|
|
set_file_date: 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.
|
|
set_file_date: Modifie la date de modification du fichier.
|
|
|
|
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
|
|
write_success = False
|
|
xmp_path = None
|
|
try:
|
|
if force_xmp:
|
|
xmp_path = write_xmp_sidecar(image_path, tags, dry_run)
|
|
if xmp_path:
|
|
write_success = True
|
|
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:
|
|
write_success = True
|
|
success_count += 1
|
|
logger.info(f"Frame {frame_id} : métadonnées écrites via {method}")
|
|
# Si XMP créé, récupérer le chemin
|
|
if method == 'xmp':
|
|
xmp_path = image_path.with_suffix('.xmp')
|
|
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
|
|
|
|
# Modifier la date du fichier si demandé
|
|
if write_success and set_file_date and frame.get('date'):
|
|
try:
|
|
frame_date = parse_date(frame['date'])
|
|
set_file_mtime(image_path, frame_date, dry_run)
|
|
# Modifier aussi le fichier XMP sidecar s'il existe
|
|
if xmp_path and xmp_path.exists():
|
|
set_file_mtime(xmp_path, frame_date, dry_run)
|
|
except ValueError as e:
|
|
logger.warning(f"Date invalide pour le frame {frame_id} : {e}")
|
|
|
|
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(
|
|
'--set-file-date',
|
|
action='store_true',
|
|
help='Modifie la date de modification des fichiers selon la date de prise de vue'
|
|
)
|
|
|
|
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,
|
|
set_file_date=parsed_args.set_file_date
|
|
)
|
|
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())
|