#!/usr/bin/env python3 import os import sys import mimetypes import subprocess import shutil import math import json import argparse import ffmpeg import openpyxl from openpyxl.styles import PatternFill from openpyxl.formatting.rule import CellIsRule # Définir la largeur de chaque colonne à environ 3 cm (approximativement 10.5 unités) def set_column_width(ws, column_range, width=15): for col in column_range: ws.column_dimensions[col].width = width def export_xlsx(lignes, fichier_sortie="résumé.xlsx"): # Créer un nouveau classeur et sélectionner la feuille active wb = openpyxl.Workbook() ws = wb.active ws.title = "Résumé" # En-têtes headers = ["Fichier", "Codec", "Résolution", "Action", "Durée", "Taille SRC", "Bitrate SRC", "Taille DES", "Bitrate DES", "Gain %", "Résultat"] ws.append(headers) ws.auto_filter.ref = "A1:K1" ws.freeze_panes = "B2" # Appliquer à toutes les colonnes set_column_width(ws, ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K']) # Styles pour les cellules styles = { "Réussi": PatternFill(start_color="d4f5dd", end_color="d4f5dd", fill_type="solid"), "Erreur": PatternFill(start_color="f8d7da", end_color="f8d7da", fill_type="solid"), "Interrompu": PatternFill(start_color="fdd692", end_color="fdd692", fill_type="solid"), "Existant": PatternFill(start_color="dbeff7", end_color="dbeff7", fill_type="solid"), "Simulé": PatternFill(start_color="d1e7ff", end_color="d1e7ff", fill_type="solid"), "Réencodé": PatternFill(start_color="e9d5e5", end_color="e9d5e5", fill_type="solid"), "Copié": PatternFill(start_color="fff3cd", end_color="fff3cd", fill_type="solid"), "Gain > 50%": PatternFill(start_color="d4f5dd", end_color="d4f5dd", fill_type="solid"), "Gain > 30%": PatternFill(start_color="fff3cd", end_color="fff3cd", fill_type="solid"), "Gain < 30%": PatternFill(start_color="f8d7da", end_color="f8d7da", fill_type="solid"), "Taille <": PatternFill(start_color="d4f5dd", end_color="d4f5dd", fill_type="solid"), "Taille =": PatternFill(start_color="fff3cd", end_color="fff3cd", fill_type="solid"), "Taille >": PatternFill(start_color="f8d7da", end_color="f8d7da", fill_type="solid"), } # Données for ligne in lignes: file, codec, resolution, action, duration, input_size, output_size, input_bitrate, output_bitrate, result = ligne gain_percent = calc_gain_percent(input_size, output_size, raw=True) row = [ file, # string codec, # string resolution, # string action, # string duration / 86400 if isinstance(duration, (float, int)) else duration, # float fraction de jour input_size / 1024 ** 2, # int (octets) input_bitrate, # int output_size / 1024 ** 2, # int output_bitrate, # int gain_percent, # float (vrai pourcentage) result # string ] ws.append(row) nb_lignes = ws.max_row for row in ws.iter_rows(min_row=2, max_col=len(headers)): # skip header row[4].number_format = '[h]"h"mm"m"ss"s"' # Excel-style format row[5].number_format = '#,##0 "Mo"' # Taille SRC row[6].number_format = '#,##0 "kb/s"' # Bitrate SRC row[7].number_format = '#,##0 "Mo"' # Taille DES row[8].number_format = '#,##0 "kb/s"' # Bitrate DES row[9].number_format = '0.0%' # Gain % # Appliquer couleurs sur colonne Action (colonne D = index 3) for row in ws.iter_rows(min_row=2, min_col=4, max_col=4, max_row=ws.max_row): cell = row[0] action = cell.value if action in styles: for col_index in [0, 1, 2, 3, 4]: # Colonnes Fichier, Codec, Résolution, Action, Durée ws.cell(row=cell.row, column=col_index + 1).fill = styles[action] # Appliquer couleurs sur colonne Résultat (colonne K = index 10) for row in ws.iter_rows(min_row=2, min_col=11, max_col=11, max_row=ws.max_row): cell = row[0] resultat = cell.value if resultat in styles: cell.fill = styles[resultat] # Ajouter la mise en forme conditionnelle # Taille DES ws.conditional_formatting.add(f'H2:H{nb_lignes}', CellIsRule(operator='lessThan', formula=[f'F2:F{nb_lignes}'], stopIfTrue=True, fill=styles["Taille <"])) ws.conditional_formatting.add(f'H2:H{nb_lignes}', CellIsRule(operator='equal', formula=[f'F2:F{nb_lignes}'], stopIfTrue=True, fill=styles["Taille ="])) ws.conditional_formatting.add(f'H2:H{nb_lignes}', CellIsRule(operator='greaterThan', formula=[f'F2:F{nb_lignes}'], stopIfTrue=True, fill=styles["Taille >"])) # Gain % ws.conditional_formatting.add(f'J2:J{nb_lignes}', CellIsRule(operator='greaterThan', formula=['50%'], stopIfTrue=True, fill=styles["Gain > 50%"])) ws.conditional_formatting.add(f'J2:J{nb_lignes}', CellIsRule(operator='greaterThan', formula=['30%'], stopIfTrue=True, fill=styles["Gain > 30%"])) ws.conditional_formatting.add(f'J2:J{nb_lignes}', CellIsRule(operator='lessThan', formula=['30%'], stopIfTrue=True, fill=styles["Gain < 30%"])) # Sauvegarder le fichier wb.save(fichier_sortie) def human_readable_size(size_bytes): """Convertit une taille en octets en une taille lisible (Ko, Mo, Go).""" if size_bytes == 0: return "0 B" size_name = ("B", "KB", "MB", "GB", "TB") i = int(math.floor(math.log(size_bytes, 1024))) p = math.pow(1024, i) s = round(size_bytes / p, 2) return f"{s} {size_name[i]}" def format_duration(seconds): """Formate la durée en h/m/s lisible""" if not isinstance(seconds, (float, int)) or seconds <= 0: return "" else: return f"{int(seconds // 3600)}h" * (seconds >= 3600) + f"{int((seconds % 3600) // 60)}m" * (seconds >= 60) + f"{int(seconds % 60)}s" def calc_ratio(size, duration): """Calcule le bitrate approximatif (octets/sec).""" return str(int(size / duration)) if duration else "" def calc_gain_percent(input_size, output_size, raw=False): if input_size > 0 and output_size > 0: value = (input_size - output_size) / input_size return value if raw else value * 100 return 0 def check_file_exists(file_path): """Vérifie si un fichier existe.""" if not os.path.exists(file_path): print(f"\033[91mErreur : Le fichier {file_path} n'existe pas.\033[0m") sys.exit(1) def check_directory_existence(directory_path, create_if_missing=False): """Vérifie si un dossier existe et le crée si nécessaire.""" if not os.path.exists(directory_path): if create_if_missing: try: os.makedirs(directory_path) print(f"Dossier créé : {directory_path}") except OSError as e: print(f"\033[91mErreur : Impossible de créer le dossier {directory_path}. {e}\033[0m") sys.exit(1) else: print(f"\033[91mErreur : Le dossier {directory_path} n'existe pas.\033[0m") sys.exit(1) def check_directory_writable(directory_path): """Vérifie si un dossier est accessible en écriture.""" if not os.access(directory_path, os.W_OK): print(f"\033[91mErreur : Le dossier {directory_path} n'est pas accessible en écriture.\033[0m") sys.exit(1) def get_video_duration(file_path): """Retourne la durée d'une vidéo en secondes.""" try: probe = ffmpeg.probe(file_path) return float(probe['format']['duration']) except Exception as e: print(f"\033[91mErreur lors de la récupération de la durée pour {file_path}: {e}\033[0m") return 0.0 def get_video_codec(file_path): """Retourne le codec vidéo d'un fichier avec ffmpeg.""" try: probe = ffmpeg.probe(file_path) video_stream = next((stream for stream in probe['streams'] if stream['codec_type'] == 'video'), None) if video_stream: return video_stream['codec_name'] return "inconnu" except ffmpeg.Error as e: print(f"\033[91mErreur lors de l'analyse du codec vidéo pour {file_path}: {e}\033[0m") return "inconnu" def get_video_resolution(file_path): """Retourne la résolution de la vidéo au format 720p, 1080p, etc.""" try: probe = ffmpeg.probe(file_path) height = next((stream['height'] for stream in probe['streams'] if stream['codec_type'] == 'video'), None) return f"{height}p" if height else "" except Exception as e: print(f"\033[91mErreur lors de l'analyse de la résolution vidéo pour {file_path}: {e}\033[0m") return "" def get_video_bitrate(file_path): """Récupère le bitrate vidéo d'un fichier via ffmpeg.""" try: probe = ffmpeg.probe(file_path) for stream in probe['streams']: if stream['codec_type'] == 'video': # 1. Priorité : le champ 'bit_rate' s'il est présent if 'bit_rate' in stream: return int(stream['bit_rate']) // 1000 # 2. Sinon : on tente d'utiliser 'stream_size' s'il existe elif 'stream_size' in stream: return int((int(stream.get('stream_size')) * 8) / float(probe['format']['duration']) / 1000) # 3. Fallback : calcul à partir de la taille du fichier entier (moins précis) else: return int((os.path.getsize(file_path) * 8) / float(probe['format']['duration']) / 1000) except Exception as e: print(f"\033[91mErreur lors de l'analyse du bitrate vidéo pour {file_path}: {e}\033[0m") return 0 def execute_command(command, dry_run=False): """Exécute ou simule une commande en ligne.""" if dry_run: print(f"Commande simulée : {' '.join(command)}") result = "Simulé" else: print(f"Commande exécuté : {' '.join(command)}") subprocess.run(command, check=True) result = "Réussi" return result def copyfile(file, destination, dry_run=False): """Copie ou simule une copie de fichier.""" if dry_run: result = "Simulé" else: shutil.copyfile(file, destination) result = "Réussi" return result def find_video_files(directory): """Trouve tous les fichiers vidéo dans un dossier.""" video_files = [] for root, dirs, files in os.walk(directory): for file in files: file_path = os.path.join(root, file) mime_type, _ = mimetypes.guess_type(file_path) if mime_type and mime_type.startswith('video'): video_files.append(os.path.relpath(file_path, directory)) return video_files def get_preset_name_from_file(preset_file): """Extrait le nom du preset à partir du fichier JSON.""" try: with open(preset_file, 'r', encoding='utf-8') as f: data = json.load(f) return data["PresetList"][0]["PresetName"] except Exception as e: print(f"\033[91mErreur : Impossible de lire le preset name depuis {preset_file} : {e}\033[0m") sys.exit(1) def afficher_tableau(lignes, use_pager=False): buffer = [] # En-tête avec colonnes ajustées header = ( f"{'Fichier':<100} | " f"{'Codec':<10} | " f"{'Résolution':<10} | " f"{'Action':<10} | " f"{'Durée':<10} | " f"{'Taille SRC':<10} | " f"{'Bitrate SRC':<12} | " f"{'Taille DES':<10} | " f"{'Bitrate DES':<12} | " f"{'Gain %':<6} | " f"{'Résultat':<10} |" ) # Calculer la longueur de la ligne de séparation en fonction de la longueur de l'en-tête separator_length = len(header) # En-tête et ligne de séparation buffer.append("\nRésumé des opérations :") buffer.append(header) buffer.append("-" * separator_length) for ligne in lignes: file, codec, resolution, action, duration, input_size, output_size, input_bitrate, output_bitrate, result = ligne gain_percent = calc_gain_percent(input_size, output_size) action_color = "" result_color = "" reset_color = "\033[0m" # Couleurs d'action if action == "Réencodé": action_color = "\033[1;35m" # Magenta gras elif action == "Copié": action_color = "\033[1;33m" # Jaune gras # Couleurs de résultat if result == "Réussi": result_color = "\033[1;32m" # Vert gras elif result == "Erreur": result_color = "\033[1;31m" # Rouge gras elif result == "Interrompu": result_color = "\033[1;38;5;208m" # Orange gras elif result == "Existant": result_color = "\033[1;36m" # Cyan gras elif result == "Simulé": result_color = "\033[1;34m" # Bleu vif gras # Couleur de la taille destination selon gain ou perte if output_size < input_size: output_size_color = "\033[1;32m" # Vert gras (gain) elif output_size == input_size: output_size_color = "\033[1;38;5;208m" # Orange gras (égal) else: output_size_color = "\033[1;31m" # Rouge gras (perte) # Définir une couleur selon le gain # > 50% => vert, > 30% => orange, sinon rouge if gain_percent > 50: gain_color = "\033[1;32m" # Vert gras elif gain_percent > 30: gain_color = "\033[1;38;5;208m" # Orange gras else: gain_color = "\033[1;31m" # Rouge gras buffer.append( f"{action_color}{file[:100]:<100}{reset_color} | " # Fichier f"{action_color}{codec[:10]:<10}{reset_color} | " # Codec f"{action_color}{resolution[:10]:>10}{reset_color} | " # Résolution f"{action_color}{action[:10]:<10}{reset_color} | " # Action f"{action_color}{format_duration(duration)[:10]:>10}{reset_color} | " # Durée f"{human_readable_size(input_size)[:10]:>10} | " # Taille SRC f"{(f'{input_bitrate} kb/s' if isinstance(input_bitrate, (float, int)) else '')[:12]:>12} | " # Bitrate SRC f"{output_size_color}{human_readable_size(output_size)[:10]:>10}{reset_color} | " # Taille DES f"{(f'{output_bitrate} kb/s' if isinstance(output_bitrate, (float, int)) else '')[:12]:>12} | " # Bitrate DES f"{gain_color}{gain_percent:>5.1f}%{reset_color} | " # Gain % f"{result_color}{result[:10]:<10}{reset_color} |" # Résultat ) tableau = "\n".join(buffer) if use_pager: subprocess.run(["less", "-SR"], input=tableau.encode('utf-8')) else: print(tableau) def main(): # Configuration des arguments en ligne de commande parser = argparse.ArgumentParser(description='Traitement des fichiers vidéo avec HandBrakeCLI.') parser.add_argument('-p', '--preset', required=True, help='Fichier preset JSON à utiliser avec HandBrakeCLI') parser.add_argument('-i', '--input_directory', required=True, help='Dossier d’entrée contenant les fichiers vidéo') parser.add_argument('-o', '--output_directory', required=True, help='Dossier de sortie pour les fichiers traités') parser.add_argument('--dry-run', action='store_true', help='Simule les actions sans modifier les fichiers') parser.add_argument('--pager', action='store_true', help='Utilise less pour paginer le résumé final') args = parser.parse_args() # Récupération des arguments preset_file = args.preset input_directory = args.input_directory output_directory = args.output_directory dry_run = args.dry_run use_pager = args.pager # Vérification de l'existence des fichiers et dossiers check_file_exists(preset_file) check_directory_existence(input_directory) if not dry_run: check_directory_existence(output_directory, create_if_missing=True) check_directory_writable(output_directory) # Récupération du nom du preset preset_name = get_preset_name_from_file(preset_file) # Recherche des fichiers vidéo dans le dossier d'entrée video_files = find_video_files(input_directory) # Liste pour enregistrer le résumé lignes = [] try: # Traitement de chaque fichier vidéo for video_file in video_files: input_file_path = os.path.join(input_directory, video_file) output_file_path = os.path.join(output_directory, video_file) if not dry_run: check_directory_existence(os.path.dirname(output_file_path), create_if_missing=True) # Récupération du codec du fichier vidéo codec = get_video_codec(input_file_path) # Choix de l'action if codec == "hevc": action = "Copié" output_file_path = os.path.join(output_directory, video_file) else: action = "Réencodé" output_file_path = os.path.splitext(os.path.join(output_directory, video_file))[0] + '.mp4' try: # Si le fichier existe déjà if os.path.exists(output_file_path): result = "Existant" print(f"\033[96mFichier existant : {input_file_path} vers {output_file_path} (action aurait été : {action})\033[0m") elif action == "Réencodé": print(f"\033[95mRéencodage de {input_file_path} vers {output_file_path} (Non-HEVC détecté)\033[0m") command = ['HandBrakeCLI', '--preset-import-file', preset_file, '-Z', preset_name, '-i', input_file_path, '-o', output_file_path] result = execute_command(command, dry_run) elif action == "Copié": print(f"\033[93mCopie de {input_file_path} vers {output_file_path} (HEVC détecté)\033[0m") result = copyfile(input_file_path, output_file_path, dry_run) except KeyboardInterrupt: print("\033[91mInterruption clavier détectée.\033[0m") if os.path.exists(output_file_path): os.remove(output_file_path) result = "Interrompu" raise except Exception as e: print(f"\033[91mErreur lors du traitement de {input_file_path} : {e}\033[0m") if os.path.exists(output_file_path): os.remove(output_file_path) result = "Erreur" finally: # Enregistrer le résultat même en cas d'erreur ou interruption # Récupère des informations du fichier de départ resolution = get_video_resolution(input_file_path) input_size = os.path.getsize(input_file_path) input_bitrate = get_video_bitrate(input_file_path) # Récupère des informations du fichier final output_size = os.path.getsize(output_file_path) if os.path.exists(output_file_path) else 0 output_bitrate = get_video_bitrate(output_file_path) if os.path.exists(output_file_path) else "" duration = get_video_duration(input_file_path) lignes.append((video_file, codec, resolution, action, duration, input_size, output_size, input_bitrate, output_bitrate, result)) except KeyboardInterrupt: pass finally: # Calcul des tailles des dossiers source et destination (après traitement) input_total = sum(r[5] for r in lignes) output_total = sum(r[6] for r in lignes) # Insertion d'une ligne récapitulative des tailles des dossiers lignes.insert(0, ( "[Taille Totale]", # Fichier "", # Codec "", # Résolution "", # Action "", # Durée input_total, # Taille SRC output_total, # Taille DES "", # Bitrate SRC "", # Bitrate DES "" # Résultat )) # Affichage du résumé afficher_tableau(lignes, use_pager=use_pager) xlsx_file = "résumé.xlsx" export_xlsx(lignes, fichier_sortie=xlsx_file) print(f"📝 Classeur généré : {xlsx_file}") if __name__ == "__main__": main()