Trucs et astuces
21 octobre 2025

Supprimer les freezes et coupures vidéos grâce à Python et FFmpeg

Image de couverture - Supprimer les freezes et coupures vidéos grâce à Python et FFmpeg

Le mois dernier, comme chaque septembre depuis maintenant plusieurs années, s’est déroulé sur Twitch le ZEvent. C’était l’occasion de découvrir de nouveaux streamers, de voir des collaborations improbables, c’était un moment de divertissement intense où quasiment tout le Twitch game français a participé. En bref, c’est le moment de l’année à ne surtout pas rater pour toute personne étant intéressée de près, ou de loin, par le monde du streaming.

Si je vous parle du ZEvent dans un article où vous allez manger du Python et des paramètres FFmpeg, c’est parce qu’il se trouve que j’ai dû développer un petit script pour résoudre un problème que j’ai rencontré lors de cet événement. :)

En effet, si vous êtes familiers de Twitch, vous connaissez sûrement ces micro-coupures qui surviennent parfois durant les lives. En général c’est dû à notre mauvaise connexion, parfois ça vient directement de la plateforme, ou du streamer. Il se trouve que durant le ZEvent, je suivais principalement Sylvvain… Qui était placé en bout de salle, dans une zone où le WiFi se faisait capricieux. 🤡

Et justement, il se trouve que j’ai enregistré plusieurs passages de son live, notamment celui de l’airbag. 😂 Parfois sans aucun soucis, parfois avec ces fameuses micro-coupures. C’est alors que m’est venue l’idée de créer un script Python qui se chargerait de supprimer tous ces moments de freeze de mes enregistrement.

Début du raisonnement

L’idée de base est simple. Une vidéo n’est ni plus, ni moins, qu’une suite d’images doublée d’une bande son. Mon idée originale était donc de calculer le hash md5 de chaque image de ma vidéo, afin de détecter des images potentiellement dupliquées, ce qui représenterai un moment de freeze. Mais assez rapidement, je me suis rendu compte que ça ne fonctionnait pas. En effet, même sur une image statique, les artefacts de compression font que chaque image est techniquement unique, en tout cas lors du calcul de son hash…

Le hash entre deux images visuellement identiques n'est pas le même

Alors je me suis mis à chercher sur Google. :)

FFmpeg à la rescousse !

Si la mise en pratique était un échec, l’idée reste bonne car c’est techniquement comme ça que doit fonctionner le script que je souhaite créer. En fouillant sur internet, je me suis rapidement rendu compte que FFmpeg disposait d’un filtre qui permettait justement de détecter les mouches sur les cadav moments de “freeze” d’une vidéo. freezedetect

Et il s’utilise comme ceci :

ffmpeg -i vod.mp4 -vf "freezedetect=d=0.1" -f null -

Ici j’ai mis une durée de détection de 0.1 seconde, mais on peut la modifier selon nos besoins. Il existe également un paramètre n pour le noise audio, qui permet de détecter une coupure audio. Le but est de définir une durée suffisamment proche de la durée des coupures.

En théorie, ce paramètre résout complètement notre problème. Pour le démontrer, j’ai simulé un stream dont le flux vidéo est coupé par des moment de pause, qui dans le cadre de Twitch, représentent un freeze.

Miniature - Démonstration de freezedetect
Démonstration du filtre freezedetect

On peut voir que FFmpeg retourne plusieurs informations :

  • Le timestamp du début du freeze
  • Sa durée en secondes
  • Le timestamp de fin du freeze

Parfait ! Maintenant que nous avons les informations que nous recherchions, on peut commencer à envisager l’écriture d’un script qui va récupérer ces informations, et couper notre vidéos avec les bon timestamp pour la rendre fluide. :D

Voici un début de script.

import subprocess
import re
import os
import sys
from pathlib import Path

FFMPEG_PATH = r"C:\Chemin\vers\ffmpeg\bin\ffmpeg.exe" # ffmpeg si sur linux
FFPROBE_PATH = r"C:\Chemin\vers\ffmpeg\bin\ffprobe.exe" # ffmpeg si sur linux

def detect_freezes(input_file, duration_threshold=0.1):
    cmd = [
        FFMPEG_PATH,
        '-i', input_file,
        '-vf', f'freezedetect=d={duration_threshold}',
        '-f', 'null',
        '-'
    ]
    
    result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    output = result.stdout
    
    # Regex permettant de parser les résultats de FFmpeg
    freeze_pattern = r'lavfi\.freezedetect\.freeze_start: ([\d.]+).*?lavfi\.freezedetect\.freeze_duration: ([\d.]+)'
    freezes = []
    
    for match in re.finditer(freeze_pattern, output, re.DOTALL):
        start = float(match.group(1))
        duration = float(match.group(2))
        end = start + duration
        freezes.append((start, end))
        print(f"Freeze détecté: {start:.2f}s -> {end:.2f}s (durée: {duration:.2f}s)")
    
    return freezes

def main():
    # Vérifier que FFmpeg est accessible
    if not os.path.exists(FFMPEG_PATH):
        print(f"Erreur: FFmpeg introuvable à : {FFMPEG_PATH}")
        sys.exit(1)
    
    if not os.path.exists(FFPROBE_PATH):
        print(f"Erreur: FFprobe introuvable à : {FFPROBE_PATH}")
        sys.exit(1)
    
    input_file = sys.argv[1]

    min_duration = 0.1
    
    if not os.path.exists(input_file):
        print(f"Erreur: Le fichier {input_file} n'existe pas")
        sys.exit(1)
    
    freezes = detect_freezes(input_file, min_duration)
    
    if not freezes:
        print("\nAucun freeze détecté")
        sys.exit(0)
    
    print(f"\n{len(freezes)} freeze(s) détecté(s)")

if __name__ == "__main__":
    main()

Ce qui dans mon cas retourne ceci :

python script1.py sample.mp4
Freeze détecté: 4.47s -> 4.57s (durée: 0.10s)
Freeze détecté: 4.57s -> 4.77s (durée: 0.20s)
Freeze détecté: 4.77s -> 4.90s (durée: 0.13s)
Freeze détecté: 8.30s -> 8.40s (durée: 0.10s)
Freeze détecté: 8.40s -> 8.60s (durée: 0.20s)
Freeze détecté: 8.60s -> 9.30s (durée: 0.70s)
Freeze détecté: 12.83s -> 12.93s (durée: 0.10s)
Freeze détecté: 12.93s -> 13.13s (durée: 0.20s)
Freeze détecté: 13.13s -> 13.23s (durée: 0.10s)
Freeze détecté: 15.83s -> 15.93s (durée: 0.10s)
Freeze détecté: 16.20s -> 16.30s (durée: 0.10s)
Freeze détecté: 19.53s -> 19.67s (durée: 0.13s)
Freeze détecté: 19.73s -> 19.87s (durée: 0.13s)
Freeze détecté: 25.30s -> 25.40s (durée: 0.10s)
Freeze détecté: 25.40s -> 25.57s (durée: 0.17s)
Freeze détecté: 25.57s -> 25.70s (durée: 0.13s)
Freeze détecté: 25.70s -> 26.00s (durée: 0.30s)
Freeze détecté: 26.00s -> 27.33s (durée: 1.33s)

18 freeze(s) détecté(s)

Les résultats sont plutôt satisfaisants, essayons de découper ces intervales de la vidéo. Pour le faire, nous allons utiliser les paramètres -ss et -t afin de créer les segments de vidéos à garder, que nous concaténerons par la suite. Mais dans un premier temps, nous devons déterminer ces segments. :)

Pour le faire, c’est assez simple. Comme nous disposons des timestamps des moments de freeze, nous devons calculer les timestamp des segments de vidéos à garder, par rapport à la durée totale de la vidéo. Nous devons donc utiliser FFprobe, un outil fournit avec FFmpeg qui permet de récupérer toutes les métadonnées et informations d’un fichier média.

Ainsi, ajoutons ces deux méthodes à notre script :

def get_video_duration(input_file):
    cmd = [
        FFPROBE_PATH,
        '-v', 'error',
        '-show_entries', 'format=duration',
        '-of', 'default=noprint_wrappers=1:nokey=1',
        input_file
    ]
    result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    return float(result.stdout.strip())

def create_segments(freezes, total_duration):    
    segments = []
    current_time = 0
    buffer=0.1
    
    for freeze_start, freeze_end in freezes:
        # Segment avant le freeze (avec buffer)
        segment_end = max(0, freeze_start - buffer)
        if segment_end > current_time:
            segments.append((current_time, segment_end))
        
        # Passer après le freeze (avec buffer)
        current_time = freeze_end + buffer
    
    # Dernier segment
    if current_time < total_duration:
        segments.append((current_time, total_duration))
    
    return segments

Vous l’aurez compris, la première permet de déterminer la longueur d’une vidéo en secondes, tandis que la deuxième nous retourne un tuple représentant les timestamps de début et de fin de chaque segment à garder. Nous pouvons enfin commencer la manipulation de notre fichier vidéo. 😁

Comme je l’ai dit, nous allons utiliser les paramètres -ss et -t afin de créer les segments de vidéos à garder. En pratique, ça va se dérouler ainsi :

  1. Nous bouclerons sur le tuple des segments à garder
  2. Pour chaque segment, nous lancerons FFmpeg avec -i vod.mp4 -ss XX -t XX segment_XX.mp4
  3. Nous créerons un fichier texte avec le chemin de tous les fichiers segments créés
  4. Et enfin, nous lancerons à nouveau FFmpeg mais avec le paramètre de concaténation en input

Voici ce que donne le fichier final :

import subprocess
import re
import os
import sys
from pathlib import Path

FFMPEG_PATH = r"C:\Chemin\vers\ffmpeg\bin\ffmpeg.exe" # ffmpeg si sur linux
FFPROBE_PATH = r"C:\Chemin\vers\ffmpeg\bin\ffprobe.exe" # ffmpeg si sur linux

def detect_freezes(input_file, duration_threshold=0.1):
    cmd = [
        FFMPEG_PATH,
        '-i', input_file,
        '-vf', f'freezedetect=d={duration_threshold}',
        '-f', 'null',
        '-'
    ]
    
    result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    output = result.stdout
    
    # Regex permettant de parser les résultats de FFmpeg
    freeze_pattern = r'lavfi\.freezedetect\.freeze_start: ([\d.]+).*?lavfi\.freezedetect\.freeze_duration: ([\d.]+)'
    freezes = []
    
    for match in re.finditer(freeze_pattern, output, re.DOTALL):
        start = float(match.group(1))
        duration = float(match.group(2))
        end = start + duration
        freezes.append((start, end))
        print(f"Freeze détecté: {start:.2f}s -> {end:.2f}s (durée: {duration:.2f}s)")
    
    return freezes

def get_video_duration(input_file):
    cmd = [
        FFPROBE_PATH,
        '-v', 'error',
        '-show_entries', 'format=duration',
        '-of', 'default=noprint_wrappers=1:nokey=1',
        input_file
    ]
    result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    return float(result.stdout.strip())

def create_segments(freezes, total_duration):    
    segments = []
    current_time = 0
    buffer=0.1
    
    for freeze_start, freeze_end in freezes:
        # Segment avant le freeze (avec buffer)
        segment_end = max(0, freeze_start - buffer)
        if segment_end > current_time:
            segments.append((current_time, segment_end))
        
        # Passer après le freeze (avec buffer)
        current_time = freeze_end + buffer
    
    # Dernier segment
    if current_time < total_duration:
        segments.append((current_time, total_duration))
    
    return segments

def cut_and_concatenate(input_file, segments, output_file):
    print(f"Découpage de la vidéo en {len(segments)} segment(s)...")
    
    # On créé un dossier temporaire pour les segments
    input_path = Path(input_file)
    temp_dir = input_path.parent / "temp_segments"
    temp_dir.mkdir(exist_ok=True)
    
    segment_files = []
    concat_file = temp_dir / "concat_list.txt"
    
    # On encapsule notre code dans un try/catch afin d'éviter de laisser des fichiers temporaires en cas d'échec
    try:
        # Boucle de découpage
        for i, (start, end) in enumerate(segments):
            segment_file = temp_dir / f"segment_{i:04d}.mp4"
            segment_files.append(segment_file)
            
            duration = end - start
            print(f"Segment {i+1}/{len(segments)}: {start:.2f}s -> {end:.2f}s ({duration:.2f}s)")
            
            cmd = [
                FFMPEG_PATH,
                '-y',
                '-i', input_file,
                '-ss', str(start),
                '-t', str(duration),
                '-c', 'copy',  # Copie sans ré-encodage (lossless)
                str(segment_file)
            ]
            
            subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
        
        # Boucle pour créer le fichier de concaténation
        with open(concat_file, 'w', encoding='utf-8') as f:
            for segment_file in segment_files:
                # Si comme moi vous êtes sur Windows, vous devrez remplacer les \\ par des /
                path_str = str(segment_file.absolute()).replace('\\', '/')
                f.write(f"file '{path_str}'\n")
        
        print("Concaténation des segments...")
        cmd = [
            FFMPEG_PATH,
            '-y',
            '-f', 'concat',
            '-safe', '0',
            '-i', str(concat_file),
            '-c', 'copy',
            output_file
        ]
        
        subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
        print(f"Vidéo finale créée: {output_file}")
        
    finally:
        # On supprime les fichiers temporaires
        print("\n🧹 Nettoyage des fichiers temporaires...")
        for segment_file in segment_files:
            if segment_file.exists():
                segment_file.unlink()
        if concat_file.exists():
            concat_file.unlink()
        try:
            if temp_dir.exists() and not any(temp_dir.iterdir()):
                temp_dir.rmdir()
        except:
            pass


def main():
    # Vérifier que FFmpeg est accessible
    if not os.path.exists(FFMPEG_PATH):
        print(f"Erreur: FFmpeg introuvable à : {FFMPEG_PATH}")
        sys.exit(1)
    
    if not os.path.exists(FFPROBE_PATH):
        print(f"Erreur: FFprobe introuvable à : {FFPROBE_PATH}")
        sys.exit(1)
    
    input_file = sys.argv[1]
    output_file = sys.argv[2] if len(sys.argv) > 2 else "output_no_freezes.mp4" # Ligne ajoutée

    min_duration = 0.1
    
    if not os.path.exists(input_file):
        print(f"Erreur: Le fichier {input_file} n'existe pas")
        sys.exit(1)
    
    total_duration = get_video_duration(input_file) # Ligne ajoutée
    freezes = detect_freezes(input_file, min_duration)
    
    if not freezes:
        print("\nAucun freeze détecté")
        sys.exit(0)
    
    print(f"\n{len(freezes)} freeze(s) détecté(s)")

    segments = create_segments(freezes, total_duration) # Ligne ajoutée
    cut_and_concatenate(input_file, segments, output_file)  # Ligne ajoutée

if __name__ == "__main__":
    main()

Et voilà, le travail est terminé… N’est-ce pas ? 😅

Miniature - Application du filtre freezedetect
Application du filtre freezedetect

Corriger les problèmes de désynchronisation

En réalité, c’est plus compliqué que ça. En écrivant cet article, je me suis confronté à un problème que je n’avais pas vraiment remarqué lorsque j’ai développé ce script. Il se trouve que sur de plus petites vidéos, il est assez commun de rencontrer des problèmes de désynchronisation en utilisant ce script. Pour y remédier, nous devons caler nos points de coupes sur les keyframes de la vidéo.

Petit aparté - Une keyframe est une image complète dans une vidéo qui sert de point de référence. Contrairement aux autres frames qui ne stockent que les changements, une keyframe contient toutes les informations pour afficher l’image entière.

Maintenant que vous savez ce qu’est une keyframe, nous allons essayer de corriger ce problème. Pour cela, nous devons utiliser FFprobe avec les paramètres suivants :

  • -skip_frame nokey : Nous ignorons toutes les frames, sauf les keyframes
  • -show_entries frame=pkt_pts_time : Montre le timestamp de chaque frame
  • -select_streams v:0 : Ne prend que le premier flux vidéo
  • -of csv=p=0 : Nous demandons de formater la sortie en CSV

Voici ce que ça donne dans notre script Python :

def find_keyframe_near_time(input_file, timestamp):
    cmd = [
        FFPROBE_PATH,
        '-v', 'error',
        '-skip_frame', 'nokey',
        '-show_entries', 'frame=pkt_pts_time',
        '-select_streams', 'v:0',
        '-of', 'csv=p=0',
        input_file
    ]
    result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    
    keyframes = [float(line) for line in result.stdout.strip().split('\n') if line]
    
    # Trouve la keyframe la plus proche avant le timestamp
    for keyframe in reversed(keyframes):
        if keyframe <= timestamp:
            return keyframe
    
    return 0.0

def create_segments(freezes, total_duration, input_file):
    segments = []
    current_time = 0
    buffer = 0.1
    
    for freeze_start, freeze_end in freezes:
        # Trouve la keyframe avant le début du freeze
        segment_end = find_keyframe_near_time(input_file, freeze_start - buffer)
        
        if segment_end > current_time:
            segments.append((current_time, segment_end))
        
        # Trouve la keyframe après la fin du freeze
        current_time = find_keyframe_near_time(input_file, freeze_end + buffer)
    
    # Dernier segment
    if current_time < total_duration:
        segments.append((current_time, total_duration))
    
    return segments

Et là pour le coup, ça fonctionne vraiment, nous n’avons plus ce problème de désynchronisation. :D

Miniature - Correction du problème de désynchronisation
Correction du problème de désynchronisation

Améliorons notre système de détection

Comme vous l’avez vu dans la vidéo ci-dessus, notre script ne résout pas vraiment notre problème. Pour l’améliorer, on peut doubler notre détection actuelle avec la détection de silence. La détection de silence seule n’est pas idéale car elle risque d’être trop stricte. Les moments de calme dans l’enregistrement risquent d’être confondus avec ce que nous considérons comme des freezes. C’est pour quoi nous allons la faire fonctionner de paire avec notre détection actuelle. L’analyse du son est plus précise, l’analyse des images statiques viendra confirmer les freezes détectés.

Pour ce faire, nous allons utiliser le filtre silencedetect, il s’utilise comme ceci :

ffmpeg -i vod.mp4 '-af', f'silencedetect=n=XXdB:d=XX' -f null -

Le paramètre n représente la tolérence de bruit, le palier à partir duquel FFmpeg va considérer le son comme du silence, et le paramètre d représente la durée minimale de détection. Les valeurs par défaut de FFmpeg sont -60dB et 2 secondes. Je pense que nous allons simplement modifier la durée pour qu’elle corresponde mieux à notre cas d’usage.

Voici une méthode qui met en pratique ce paramètre :

def detect_silences(input_file, duration_threshold=0.3):    
    cmd = [
        FFMPEG_PATH,
        '-i', input_file,
        '-af', f'silencedetect=d={duration_threshold}',
        '-f', 'null',
        '-'
    ]
    
    result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    output = result.stdout
    
    # Regexes pour parser les résultats
    silence_starts = re.findall(r'silence_start: ([\d.]+)', output)
    silence_ends = re.findall(r'silence_end: ([\d.]+)', output)
    
    silences = []
    for start, end in zip(silence_starts, silence_ends):
        start_time = float(start)
        end_time = float(end)
        duration = end_time - start_time
        silences.append((start_time, end_time))
        print(f"Silence audio: {start_time:.2f}s -> {end_time:.2f}s (durée: {duration:.2f}s)")
    
    return silences

Tout comme pour la détection de freeze, notre méthode retourne un tuple comportant le timestamp de début et de fin du freeze détecté. Nous devons maintenant créer une dernière méthode qui va nous permettre de comparer les résultats des détections. :D

def find_confirmed_silences(freezes, silences):
    confirmed_silences = []
    
    for silence_start, silence_end in silences:
        is_confirmed = False
        
        for freeze_start, freeze_end in freezes:
            # Vérifie s'il y a une intersection entre les 2 détections
            intersection_start = max(freeze_start, silence_start)
            intersection_end = min(freeze_end, silence_end)
            
            if intersection_start < intersection_end:
                is_confirmed = True
                break
        
        if is_confirmed:
            duration = silence_end - silence_start
            confirmed_silences.append((silence_start, silence_end))
            print(f"Silence confirmé: {silence_start:.2f}s -> {silence_end:.2f}s (durée: {duration:.2f}s)")
        else:
            print(f"Silence ignoré: {silence_start:.2f}s -> {silence_end:.2f}s (pas de freeze correspondant)")
    
    return confirmed_silences

Et voilà ! Notre système est maintenant prêt à être assemblé. Voici notre script complété et utilisable.

import subprocess
import re
import os
import sys
from pathlib import Path

FFMPEG_PATH = r"C:\Chemin\vers\ffmpeg\bin\ffmpeg.exe" # ffmpeg si sur linux
FFPROBE_PATH = r"C:\Chemin\vers\ffmpeg\bin\ffprobe.exe" # ffmpeg si sur linux

def detect_freezes(input_file, duration_threshold=0.1):
    cmd = [
        FFMPEG_PATH,
        '-i', input_file,
        '-vf', f'freezedetect=d={duration_threshold}',
        '-f', 'null',
        '-'
    ]
    
    result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    output = result.stdout
    
    freeze_pattern = r'lavfi\.freezedetect\.freeze_start: ([\d.]+).*?lavfi\.freezedetect\.freeze_duration: ([\d.]+)'
    freezes = []
    
    for match in re.finditer(freeze_pattern, output, re.DOTALL):
        start = float(match.group(1))
        duration = float(match.group(2))
        end = start + duration
        freezes.append((start, end))
        print(f"Freeze détecté: {start:.2f}s -> {end:.2f}s (durée: {duration:.2f}s)")
    
    return freezes

def detect_silences(input_file, duration_threshold=0.3):    
    cmd = [
        FFMPEG_PATH,
        '-i', input_file,
        '-af', f'silencedetect=d={duration_threshold}',
        '-f', 'null',
        '-'
    ]
    
    result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    output = result.stdout
    
    # Regexes pour parser les résultats
    silence_starts = re.findall(r'silence_start: ([\d.]+)', output)
    silence_ends = re.findall(r'silence_end: ([\d.]+)', output)
    
    silences = []
    for start, end in zip(silence_starts, silence_ends):
        start_time = float(start)
        end_time = float(end)
        duration = end_time - start_time
        silences.append((start_time, end_time))
        print(f"Silence audio: {start_time:.2f}s -> {end_time:.2f}s (durée: {duration:.2f}s)")
    
    return silences

def find_confirmed_silences(freezes, silences):
    confirmed_silences = []
    
    for silence_start, silence_end in silences:
        is_confirmed = False
        
        for freeze_start, freeze_end in freezes:
            # Vérifie s'il y a une intersection entre les 2 détections
            intersection_start = max(freeze_start, silence_start)
            intersection_end = min(freeze_end, silence_end)
            
            if intersection_start < intersection_end:
                is_confirmed = True
                break
        
        if is_confirmed:
            duration = silence_end - silence_start
            confirmed_silences.append((silence_start, silence_end))
            print(f"Silence confirmé: {silence_start:.2f}s -> {silence_end:.2f}s (durée: {duration:.2f}s)")
        else:
            print(f"Silence ignoré: {silence_start:.2f}s -> {silence_end:.2f}s (pas de freeze correspondant)")
    
    return confirmed_silences

def get_video_duration(input_file):
    cmd = [
        FFPROBE_PATH,
        '-v', 'error',
        '-show_entries', 'format=duration',
        '-of', 'default=noprint_wrappers=1:nokey=1',
        input_file
    ]
    result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    return float(result.stdout.strip())

def find_keyframe_near_time(input_file, timestamp):
    cmd = [
        FFPROBE_PATH,
        '-v', 'error',
        '-skip_frame', 'nokey',
        '-show_entries', 'frame=pkt_pts_time',
        '-select_streams', 'v:0',
        '-of', 'csv=p=0',
        input_file
    ]
    result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    
    keyframes = [float(line) for line in result.stdout.strip().split('\n') if line]
    
    # Trouve la keyframe la plus proche avant le timestamp
    for keyframe in reversed(keyframes):
        if keyframe <= timestamp:
            return keyframe
    
    return 0.0

def create_segments(freezes, total_duration, input_file):
    segments = []
    current_time = 0
    buffer = 0.1
    
    for freeze_start, freeze_end in freezes:
        # Trouve la keyframe avant le début du freeze
        segment_end = find_keyframe_near_time(input_file, freeze_start - buffer)
        
        if segment_end > current_time:
            segments.append((current_time, segment_end))
        
        # Trouve la keyframe après la fin du freeze
        current_time = find_keyframe_near_time(input_file, freeze_end + buffer)
    
    # Dernier segment
    if current_time < total_duration:
        segments.append((current_time, total_duration))
    
    return segments

def cut_and_concatenate(input_file, segments, output_file):
    print(f"Découpage de la vidéo en {len(segments)} segment(s)...")
    
    input_path = Path(input_file)
    temp_dir = input_path.parent / "temp_segments"
    temp_dir.mkdir(exist_ok=True)
    
    segment_files = []
    concat_file = temp_dir / "concat_list.txt"
    
    try:
        # Découpage avec ré-encodage pour assurer l'intégrité
        for i, (start, end) in enumerate(segments):
            segment_file = temp_dir / f"segment_{i:04d}.mp4"
            segment_files.append(segment_file)
            
            duration = end - start
            print(f"Segment {i+1}/{len(segments)}: {start:.2f}s -> {end:.2f}s ({duration:.2f}s)")
            
            cmd = [
                FFMPEG_PATH,
                '-y',
                '-i', input_file,
                '-ss', str(start),
                '-t', str(duration),
                '-c', 'copy',  # Copie sans ré-encodage (lossless)
                str(segment_file)
            ]
            
            subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
        
        # Création du fichier de concaténation
        with open(concat_file, 'w', encoding='utf-8') as f:
            for segment_file in segment_files:
                path_str = str(segment_file.absolute()).replace('\\', '/')
                f.write(f"file '{path_str}'\n")
        
        print("Concaténation des segments...")
        cmd = [
            FFMPEG_PATH,
            '-y',
            '-f', 'concat',
            '-safe', '0',
            '-i', str(concat_file),
            '-c', 'copy',           # Copy safe car les segments sont déjà encodés proprement
            '-movflags', '+faststart',
            output_file
        ]
        
        subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
        print(f"Vidéo finale créée: {output_file}")
        
    finally:
        print("\n🧹 Nettoyage des fichiers temporaires...")
        for segment_file in segment_files:
            if segment_file.exists():
                segment_file.unlink()
        if concat_file.exists():
            concat_file.unlink()
        try:
            if temp_dir.exists() and not any(temp_dir.iterdir()):
                temp_dir.rmdir()
        except:
            pass

def main():
    if not os.path.exists(FFMPEG_PATH):
        print(f"Erreur: FFmpeg introuvable à : {FFMPEG_PATH}")
        sys.exit(1)
    
    if not os.path.exists(FFPROBE_PATH):
        print(f"Erreur: FFprobe introuvable à : {FFPROBE_PATH}")
        sys.exit(1)
    
    if len(sys.argv) < 2:
        print("Usage: python script.py <input_file> [output_file]")
        sys.exit(1)
        
    input_file = sys.argv[1]
    output_file = sys.argv[2] if len(sys.argv) > 2 else "output_no_freezes.mp4"

    min_duration = 0.1
    silence_duration = 0.3
    
    if not os.path.exists(input_file):
        print(f"Erreur: Le fichier {input_file} n'existe pas")
        sys.exit(1)
    
    total_duration = get_video_duration(input_file)
    freezes = detect_freezes(input_file, min_duration)
    
    if not freezes:
        print("\nAucun freeze détecté")
        sys.exit(0)
    
    print(f"\n{len(freezes)} freeze(s) détecté(s)")

    silences = detect_silences(input_file, silence_duration)

    if not silences:
        print("\nAucun silence détecté")
        sys.exit(0)

    confirmed_cuts = find_confirmed_silences(freezes, silences)

    # Ici on a remplacé le tuple 'freeze' par 'confirmed_cuts'
    segments = create_segments(confirmed_cuts, total_duration, input_file)
    cut_and_concatenate(input_file, segments, output_file)

if __name__ == "__main__":
    main()

Et voici ce que ça donne une fois en action :

Miniature - Démonstration du script final
Démonstration du script final

Toutefois, vous remarquerez que ce n’est toujours pas parfait… 😅

Limites du script

En raison du problème de désynchronisation mentionné plus tôt, nous avons dû baser notre découpage sur les keyframes de la vidéo. Cela a pour effet de décaler les zones de coupes, et donc de garder des morceaux de vidéo non désirés. Je pense qu’une solution pour régler le problème aurait été d’ajouter du transcodage à notre script afin de nous passer du système d’alignements des keyframes. Malheuresement, durant mes tests, je n’ai pas réussi à obtenir des résultats convainquants. Cela mérite encore quelques réflexions…

2025 - Sofiane Lasri-Trienpont, développé sur Laravel.