Files
vid_convert/docs/superpowers/plans/2026-03-22-av1-opus.md
2026-03-22 23:13:19 +01:00

25 KiB
Raw Permalink Blame History

Migration AV1-SVT + Opus Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal : Migrer vid_convert.py de H.265/AAC-LC vers AV1-SVT/Opus avec sélection automatique du CRF via ab-av1 crf-search, sortie MKV, et support HDR10/HDR10+/Dolby Vision complet.

Architecture : Script Python mono-fichier modifié en place. Les fonctions obsolètes (mkv_to_mp4, is_interlaced) sont supprimées, la nouvelle fonction find_crf est ajoutée, et toutes les fonctions existantes sont mises à jour. Le flux principal perd la logique de chunking.

Tech Stack : Python 3, FFmpeg (libsvtav1, libopus), ab-av1, hdr10plus_parser, mkvmerge

Spec : docs/superpowers/specs/2026-03-22-av1-opus-design.md


Chunk 1 : Nettoyage, corrections et audio Opus

Task 1 : Correction du bug et suppression du code obsolète

Fichiers :

  • Modifier : vid_convert.py

  • Étape 1 : Décommenter get_infos et supprimer la ligne orpheline

Dans vid_convert.py, ligne 308, remplacer :

    # infos = get_infos(file)
    interlaced = False
    interlaced = is_interlaced(file, infos)

Par :

    infos = get_infos(file)
  • Étape 2 : Supprimer la fonction is_interlaced

Supprimer les lignes 81104 (fonction is_interlaced entière).

  • Étape 3 : Supprimer la fonction mkv_to_mp4

Supprimer les lignes 283290 (fonction mkv_to_mp4 entière).

  • Étape 4 : Mettre à jour les arguments CLI

Dans le bloc __main__, remplacer :

    parser.add_argument("-t", "--starttime", dest="starttime")
    parser.add_argument("-a", "--animation", dest="animation", action="store_true")
    parser.add_argument("-c", "--vhs", dest="vhs", action="store_true")

Par :

    parser.add_argument("-a", "--animation", dest="animation", action="store_true")
    parser.add_argument("-c", "--vhs", dest="vhs", action="store_true")
    parser.add_argument("--interlaced", dest="interlaced", action="store_true")
  • Étape 5 : Supprimer le flux principal obsolète

⚠️ Après cette étape, le script sera syntaxiquement valide mais non fonctionnel. Il sera complété au Task 8.

Dans le bloc __main__, supprimer exactement ce bloc (état du fichier après Step 1, à partir de file = args.f_input jusqu'à la fin du fichier) :

    file = args.f_input
    infos = get_infos(file)
    cropsize = cropping(file, infos)
    volumes = volume_audio(file, infos)
    if args.stab:
        stabilization(file)
    if args.animation:
        animation = True
    else:
        animation = False
    if not args.starttime:
        for track in infos['subtitles']:
            extract_subs(file, track['index'], track['language'])
        for track in infos['audio']:
            convert_audio(file, track['index'], volumes[track['index']], track['channels'], track['channel_layout'], track['language'], track['title'])
    if args.starttime:
        vid_part_time = int(args.starttime)
    else:
        vid_part_time = 0
    while vid_part_time < infos['duration']:
        crf = 20
        convert_video(file, infos, vid_part_time, cropsize, crf, animation, interlaced, args.vhs)
        vid_part_time += 300
    create_mkv(file)
    mkv_to_mp4(file)

Remplacer par un placeholder temporaire (sera remplacé au Task 8) :

    pass
  • Étape 6 : Vérifier que le script parse sans erreur
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"

Résultat attendu : OK

  • Étape 7 : Commit
git add vid_convert.py
git commit -m "refactor: suppression code obsolète (chunks, is_interlaced, mkv_to_mp4, -t)"

Task 2 : Encodage audio — libfdk_aac → libopus

Fichiers :

  • Modifier : vid_convert.py (fonction convert_audio)

  • Étape 1 : Vérifier que libopus est disponible dans FFmpeg

ffmpeg -codecs 2>/dev/null | grep -i opus

Résultat attendu : une ligne contenant libopus et A..... libopus. Si absent, libopus doit être compilé dans FFmpeg avant de continuer.

  • Étape 2 : Remplacer la fonction convert_audio

Remplacer l'intégralité de la fonction convert_audio par :

def convert_audio(file, track, volume_adj, channels, channel_layout, language, title):
    if channel_layout == "5.1(side)":
        channel_layout = "5.1"
    if channels <= 2:
        bitrate = "128k"
    elif channels == 6:
        bitrate = "320k"
    else:
        bitrate = "450k"
    codec = f'libopus -vbr on -b:a {bitrate}'
    metadatas = f'-metadata language="{language}" -metadata title="{title}"'
    command = f'ffmpeg -loglevel error -i {file} -map 0:{track} -map_metadata -1 -vn -sn -c:a {codec} -mapping_family 1 -filter:a volume={volume_adj},aformat=channel_layouts={channel_layout} {metadatas} -y {file}_audio_{track}_{language}.mka'
    logging.debug(command)
    result = subprocess.getoutput(command)
    if result != "":
        logging.error(result)
  • Étape 3 : Vérifier le parsing
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"

Résultat attendu : OK

  • Étape 4 : Commit
git add vid_convert.py
git commit -m "feat: encodage audio libopus VBR avec bitrate adaptatif"

Chunk 2 : Sélection CRF et encodage vidéo AV1

Task 3 : Nouvelle fonction find_crf

Fichiers :

  • Modifier : vid_convert.py (ajout de fonction avant convert_video)

  • Étape 1 : Ajouter la fonction find_crf dans vid_convert.py

  • Étape 1 : Ajouter import os dans les imports du module

En haut du fichier, après from os import listdir, remove, ajouter :

import os
  • Étape 2 : Ajouter la fonction find_crf

Insérer après la fonction volume_audio et avant stabilization :

VMAF_HDR_MODEL = '/usr/share/vmaf/model/vmaf_4k_v0.6.1.json'

def find_crf(file, enc_options, hdr=False):
    '''
    Détermine le CRF optimal via ab-av1 crf-search pour atteindre VMAF 96.
    Pour les sources HDR, utilise un modèle VMAF calibré 4K HDR si disponible.
    '''
    logging.info("Recherche du CRF optimal (VMAF 96)...")
    vmaf_model = ''
    if hdr and os.path.exists(VMAF_HDR_MODEL):
        vmaf_model = f'--vmaf-model path={VMAF_HDR_MODEL}'
        logging.debug(f"Modèle VMAF HDR utilisé : {VMAF_HDR_MODEL}")
    cmd = f'ab-av1 crf-search --input {file} --encoder libsvtav1 --vmaf 96 {vmaf_model} --enc {enc_options}'
    logging.debug(cmd)
    result = subprocess.getoutput(cmd)
    logging.debug(result)
    for line in result.splitlines():
        # ab-av1 émet une ligne du type : "crf 32 VMAF 96.21 ..."
        if 'crf' in line and 'VMAF' in line:
            try:
                crf = int(line.split('crf')[1].split()[0])
                logging.info(f"CRF optimal trouvé : {crf}")
                return crf
            except (IndexError, ValueError):
                pass
    logging.warning("ab-av1 crf-search a échoué, utilisation du CRF par défaut (32)")
    return 32
  • Étape 3 : Vérifier le parsing
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"

Résultat attendu : OK

  • Étape 4 : Commit
git add vid_convert.py
git commit -m "feat: ajout find_crf via ab-av1 crf-search (VMAF 96)"

Task 4 : Encodage vidéo — libx265 → libsvtav1

Fichiers :

  • Modifier : vid_convert.py (fonction convert_video)

  • Étape 1 : Remplacer la signature et les variables de base

Remplacer le début de convert_video :

def convert_video(file, infos, start, crop, crf, animation, interlaced, vhs):
    str_start = "{:05d}".format(start)
    output = f'{file}_video_t{str_start}.mkv'
    fmt = "yuv420p10le"
    track = infos['video']['index']
    codec = 'libx265 -preset slower'
    hdr = ''
    if animation:
        tune = "-tune animation"
    else:
        tune = ""
    if interlaced:
        crop = f"{crop},yadif"
    if vhs:
        crop = f"{crop},hqdn3d,unsharp=5:5:0.8:3:3:0.4"

Par :

def convert_video(file, infos, crf, crop, enc_options, interlaced, vhs):
    output = f'{file}_video.mkv'
    fmt = "yuv420p10le"
    track = infos['video']['index']
    codec = 'libsvtav1'
    svtav1_params = enc_options
    hdr = ''
    if interlaced:
        crop = f"{crop},yadif"
    if vhs:
        crop = f"{crop},hqdn3d,unsharp=5:5:0.8:3:3:0.4"
  • Étape 2 : Mettre à jour le bloc HDR10 statique

Remplacer le bloc if 'side_data_list' in infos['video'].keys(): jusqu'à sa fin :

    if 'side_data_list' in infos['video'].keys():
        try:
            light_level = f"{infos['video']['side_data_list'][1]['max_content']},{infos['video']['side_data_list'][1]['max_average']}"
            color_primaries = infos['video']['color_primaries']
            color_transfer = infos['video']['color_transfer']
            color_space = infos['video']['color_space']
            green_x = infos['video']['side_data_list'][0]['green_x'].split('/')
            green_x = int(int(green_x[0])*(int(green_x[1])/50000))
            green_y = infos['video']['side_data_list'][0]['green_y'].split('/')
            green_y = int(int(green_y[0])*(int(green_y[1])/50000))
            green = f'G\({green_x},{green_y}\)'
            blue_x = infos['video']['side_data_list'][0]['blue_x'].split('/')
            blue_x = int(int(blue_x[0])*(int(blue_x[1])/50000))
            blue_y = infos['video']['side_data_list'][0]['blue_y'].split('/')
            blue_y = int(int(blue_y[0])*(int(blue_y[1])/50000))
            blue = f'B\({blue_x},{blue_y}\)'
            red_x = infos['video']['side_data_list'][0]['red_x'].split('/')
            red_x = int(int(red_x[0])*(int(red_x[1])/50000))
            red_y = infos['video']['side_data_list'][0]['red_y'].split('/')
            red_y = int(int(red_y[0])*(int(red_y[1])/50000))
            red = f'R\({red_x},{red_y}\)'
            white_point_x = infos['video']['side_data_list'][0]['white_point_x'].split('/')
            white_point_x = int(int(white_point_x[0])*(int(white_point_x[1])/50000))
            white_point_y = infos['video']['side_data_list'][0]['white_point_y'].split('/')
            white_point_y = int(int(white_point_y[0])*(int(white_point_y[1])/50000))
            white_point = f'WP\({white_point_x},{white_point_y}\)'
            min_luminance = infos['video']['side_data_list'][0]['min_luminance'].split('/')
            min_luminance = int(int(min_luminance[0])*(int(min_luminance[1])/10000))
            max_luminance = infos['video']['side_data_list'][0]['max_luminance'].split('/')
            max_luminance = int(int(max_luminance[0])*(int(max_luminance[1])/10000))
            luminance = f'L\({max_luminance},{min_luminance}\)'
            master_display = green + blue + red + white_point + luminance
            hdr = f'mastering-display={master_display}:content-light={light_level}'
            if svtav1_params:
                svtav1_params = f'{svtav1_params}:{hdr}'
            else:
                svtav1_params = hdr
        except Exception as err:
            logging.debug(f"Aucune information HDR statique : {err}")
  • Étape 3 : Mettre à jour l'injection HDR10+ et la commande FFmpeg finale

Remplacer la fin de convert_video (bloc HDR10+ + commande) :

    if 'hdr10plus' in infos['video']:
        hdr10plus_param = f"hdr10plus-json={infos['video']['hdr10plus_metadata']}"
        if svtav1_params:
            svtav1_params = f'{svtav1_params}:{hdr10plus_param}'
        else:
            svtav1_params = hdr10plus_param
    svtav1_args = f'-svtav1-params {svtav1_params}' if svtav1_params else ''
    command = f'ffmpeg -loglevel error -i {file} -map 0:{track} -an -sn -c:v {codec} {svtav1_args} -crf {crf} -pix_fmt {fmt} -filter:v {crop} -y {output}'
    logging.info("Encodage vidéo en cours (AV1-SVT)...")
    logging.debug(command)
    result = subprocess.getoutput(command)
    if result != "":
        logging.error(result)
  • Étape 4 : Vérifier le parsing
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"

Résultat attendu : OK

  • Étape 5 : Commit
git add vid_convert.py
git commit -m "feat: encodage vidéo libsvtav1 avec HDR10 statique et HDR10+ via svtav1-params"

Chunk 3 : HDR10+, Dolby Vision, MKV et flux principal

Task 5 : Détection du conteneur source pour HDR10+

Fichiers :

  • Modifier : vid_convert.py (fonction get_infos)

  • Étape 1 : Détecter le format du conteneur dans get_infos

Dans get_infos, remplacer le bloc de récupération de la durée ET le bloc d'extraction HDR10+ (les deux appels ffprobe sont fusionnés en un seul) :

Remplacer :

    duration = subprocess.getoutput(f"ffprobe -v quiet -print_format json -show_format {file}")
    duration = json.loads(duration)
    duration = float(duration['format']['duration'])
    hdr10_v_cmd = f'ffmpeg -loglevel panic -i {file} -c:v copy -vbsf hevc_mp4toannexb -f hevc - | hdr10plus_parser -o metadata.json --verify -'
    hdr10_v_raw = subprocess.getoutput(hdr10_v_cmd)
    if 'metadata detected' in hdr10_v_raw:
        hdr10_cmd = f'ffmpeg -loglevel panic -i {file} -c:v copy -vbsf hevc_mp4toannexb -f hevc - | hdr10plus_parser -o /tmp/{file}_hdr10_metadata.json -'
        hdr10_cmd_res = subprocess.getoutput(hdr10_cmd)
        logging.debug(hdr10_cmd_res)
        v_infos.update({'hdr10plus': True, 'hdr10plus_metadata': f'/tmp/{file}_hdr10_metadata.json'})

Par :

    format_data = json.loads(subprocess.getoutput(f"ffprobe -v quiet -print_format json -show_format {file}"))
    duration = float(format_data['format']['duration'])
    format_name = format_data.get('format', {}).get('format_name', '')
    is_mp4_container = 'mp4' in format_name or 'mov' in format_name
    bsf = '-vbsf hevc_mp4toannexb' if is_mp4_container else ''
    hdr10_v_cmd = f'ffmpeg -loglevel panic -i {file} -c:v copy {bsf} -f hevc - | hdr10plus_parser --verify -'
    hdr10_v_raw = subprocess.getoutput(hdr10_v_cmd)
    if 'metadata detected' in hdr10_v_raw:
        hdr10_cmd = f'ffmpeg -loglevel panic -i {file} -c:v copy {bsf} -f hevc - | hdr10plus_parser -o /tmp/{file}_hdr10_metadata.json -'
        hdr10_cmd_res = subprocess.getoutput(hdr10_cmd)
        logging.debug(hdr10_cmd_res)
        v_infos.update({'hdr10plus': True, 'hdr10plus_metadata': f'/tmp/{file}_hdr10_metadata.json'})
  • Étape 2 : Vérifier le parsing
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"

Résultat attendu : OK

  • Étape 3 : Commit
git add vid_convert.py
git commit -m "fix: détection conteneur source pour extraction HDR10+ (MKV vs MP4)"

Task 6 : Détection Dolby Vision

Fichiers :

  • Modifier : vid_convert.py (fonction get_infos)

  • Étape 1 : Ajouter la détection DV après le bloc HDR10+

Dans get_infos, insérer le code suivant au niveau d'indentation de la fonction (pas à l'intérieur du if), après la ligne v_infos.update({'hdr10plus': True, ...}) et avant la ligne infos = {'duration': duration, ...}.

Concrètement, le code s'insère entre ces deux ancres exactes :

        v_infos.update({'hdr10plus': True, 'hdr10plus_metadata': f'/tmp/{file}_hdr10_metadata.json'})
    # ← fin du bloc if 'metadata detected' (dés-indentation ici)
    infos = {'duration': duration, 'video': v_infos, 'audio': a_infos, 'subtitles': s_infos}

Insérer (au niveau d'indentation de la fonction, soit 4 espaces) :

    try:
        for side_data in full_v_infos['frames'][0].get('side_data_list', []):
            if side_data.get('side_data_type') == 'DOVI configuration record':
                dv_profile = side_data.get('dv_profile', '?')
                logging.warning(
                    f"Dolby Vision détecté (Profile {dv_profile}). "
                    f"Encodage sans couche DV — HDR10 utilisé si disponible."
                )
                v_infos.update({'dolby_vision_profile': dv_profile})
                break
    except (KeyError, IndexError):
        pass
  • Étape 2 : Vérifier le parsing
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"

Résultat attendu : OK

  • Étape 3 : Commit
git add vid_convert.py
git commit -m "feat: détection Dolby Vision avec avertissement (fallback HDR10)"

Task 7 : Simplification de create_mkv

Fichiers :

  • Modifier : vid_convert.py (fonction create_mkv)

  • Étape 1 : Réécrire create_mkv pour une seule piste vidéo

Remplacer l'intégralité de la fonction create_mkv par :

def create_mkv(filename):
    json_data = []
    json_data.append("--output")
    json_data.append(f"NEW_{filename}.mkv")
    video_file = f"{filename}_video.mkv"
    json_data += ["--no-track-tags", "--no-global-tags", "--no-chapters",
                  "(", video_file, ")"]
    for file in listdir():
        if f"{filename}_audio" in file:
            lang = file[-7:][:-4]
            json_data += ["--no-track-tags", "--no-global-tags", "--no-chapters",
                          "--language", f"0:{lang}",
                          "(", file, ")"]
    for file in listdir():
        if f"{filename}_subtitle" in file:
            lang = file[-7:][:-4]
            json_data += ["--no-track-tags", "--no-global-tags", "--no-chapters",
                          "--language", f"0:{lang}",
                          "(", file, ")"]
    with open(f"/tmp/{filename}.json", "w") as mkvmerge_options:
        mkvmerge_options.write(json.dumps(json_data))
    command = f"mkvmerge -v @/tmp/{filename}.json"
    logging.debug(command)
    result = subprocess.getoutput(command)
    logging.info(result)
    remove(f"/tmp/{filename}.json")
    for file in listdir():
        if file == video_file:
            remove(file)
        if f"{filename}_audio" in file:
            remove(file)
        if f"{filename}_subtitle" in file:
            remove(file)
  • Étape 2 : Vérifier le parsing
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"

Résultat attendu : OK

  • Étape 3 : Commit
git add vid_convert.py
git commit -m "refactor: create_mkv simplifié — une seule piste vidéo, sortie NEW_{filename}.mkv"

Task 8 : Mise à jour du flux principal (__main__)

Fichiers :

  • Modifier : vid_convert.py (bloc __main__)

  • Étape 1 : Réécrire le flux principal

Remplacer tout le contenu du bloc __main__ à partir de file = args.f_input jusqu'à la fin du fichier par :

    file = args.f_input
    infos = get_infos(file)
    if args.stab:
        stabilization(file)
    cropsize = cropping(file, infos)
    volumes = volume_audio(file, infos)
    enc_options = 'preset=3:tune=0'
    is_hdr = 'side_data_list' in infos['video'] or 'hdr10plus' in infos['video']
    crf = find_crf(file, enc_options, hdr=is_hdr)
    for track in infos['subtitles']:
        extract_subs(file, track['index'], track['language'])
    for track in infos['audio']:
        convert_audio(file, track['index'], volumes[track['index']], track['channels'],
                      track['channel_layout'], track['language'], track['title'])
    convert_video(file, infos, crf, cropsize, enc_options, args.interlaced, args.vhs)
    create_mkv(file)

Note : tune=0 (VQ) est utilisé pour tous les contenus y compris l'animation, conformément à la spec.

  • Étape 2 : Vérifier le parsing
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"

Résultat attendu : OK

  • Étape 3 : Vérifier l'aide CLI
python3 vid_convert.py --help

Résultat attendu : affichage des arguments f_input, -d, -s, -a, -c, --interlaced — sans -t.

  • Étape 4 : Commit
git add vid_convert.py
git commit -m "feat: flux principal AV1+Opus — find_crf, encode one-pass, sortie MKV"

Task 9 : Mise à jour du README et du CLAUDE.md

Fichiers :

  • Modifier : README.md

  • Modifier : CLAUDE.md

  • Étape 1 : Mettre à jour README.md

Remplacer (noter l'espace de fin en fin de ligne 2 : ...avec un audio en ) :

vid_convert est un script qui utilise ffmpeg pour convertir des vidéos au
format [H.265](https://fr.wikipedia.org/wiki/H.265/HEVC), avec un audio en
[AAC-LC](https://fr.wikipedia.org/wiki/Advanced_Audio_Coding).

Par :

vid_convert est un script qui utilise ffmpeg pour convertir des vidéos au
format [AV1](https://fr.wikipedia.org/wiki/AV1), avec un audio en
[Opus](https://fr.wikipedia.org/wiki/Opus_(codec)).

Remplacer :

Le format de sortie est un fichier MPEG-4 (.mp4), lisible sur n'importe quel
appareil (ou presque) disposant d'une puissance de décodage suffisante.

Par :

Le format de sortie est un fichier Matroska (.mkv), optimisé pour l'archivage
de demux DVD et BluRay.

Remplacer la section dépendances :

 - [Python](https://www.python.org/) (>= 3.5)
 - [ffmpeg](https://ffmpeg.org/)
 - [hdr10plus_tool](https://github.com/quietvoid/hdr10plus_tool)

Par :

 - [Python](https://www.python.org/) (>= 3.5)
 - [ffmpeg](https://ffmpeg.org/) (avec libsvtav1 et libopus)
 - [ab-av1](https://github.com/alexheretic/ab-av1)
 - hdr10plus_parser (binaire inclus dans le dépôt)
  • Étape 2 : Mettre à jour CLAUDE.md — section Projet

Remplacer :

Script Python standalone de conversion vidéo vers H.265/AAC-LC en conteneur MP4, avec support HDR10+, désentrelacement, détection de bandes noires, et normalisation audio.

Par :

Script Python standalone de conversion vidéo vers AV1/Opus en conteneur MKV, avec support HDR10/HDR10+/Dolby Vision (fallback HDR10), sélection automatique du CRF via ab-av1, détection de bandes noires, et normalisation audio. Cible : archivage de demux DVD/BluRay.
  • Étape 3 : Mettre à jour CLAUDE.md — section Utilisation

Remplacer :

./vid_convert.py -t <hh:mm:ss> <fichier>  # reprendre à partir d'une position

Par :

./vid_convert.py --interlaced <fichier>    # forcer le désentrelacement (yadif)
  • Étape 4 : Mettre à jour CLAUDE.md — section Dépendances

Remplacer :

- `ffmpeg` et `ffprobe` — encodage et extraction de métadonnées
- `mkvmerge` (MkvToolNix) — assemblage du conteneur intermédiaire
- `hdr10plus_tool` (binaire inclus : `hdr10plus_parser`) — métadonnées HDR10+

Par :

- `ffmpeg` et `ffprobe` — encodage et extraction de métadonnées (libsvtav1 et libopus requis)
- `ab-av1` — sélection automatique du CRF par VMAF
- `mkvmerge` (MkvToolNix) — assemblage du conteneur MKV final
- `hdr10plus_parser` (binaire inclus) — extraction des métadonnées HDR10+
  • Étape 5 : Mettre à jour CLAUDE.md — section Architecture

Remplacer l'intégralité de la section Architecture (de la ligne Fichier unique jusqu'à deux passes) par :

Fichier unique : `vid_convert.py`. Flux d'exécution séquentiel :

1. **`get_infos(file)`** — ffprobe JSON → métadonnées vidéo/audio/sous-titres + détection HDR10+/DV
2. **`cropping(file, infos)`** — cropdetect FFmpeg → suppression des bandes noires
3. **`volume_audio(file, infos)`** — volumedetect FFmpeg → ajustement par piste audio
4. **`find_crf(file, enc_options, hdr)`** — ab-av1 crf-search VMAF 96 → CRF optimal
5. **`extract_subs(file, track, lang)`** — extraction des pistes de sous-titres
6. **`convert_audio(...)`** — encodage libopus VBR par piste
7. **`convert_video(...)`** — encodage libsvtav1 CRF=auto preset=3, passe unique
8. **`create_mkv(filename)`** — mkvmerge → MKV final `NEW_{filename}.mkv`

### Points clés

- Sortie vidéo : `yuv420p10le` (10 bits), HDR10 via `-svtav1-params`, HDR10+ via `hdr10plus-json`
- `find_crf` utilise le modèle VMAF HDR (`vmaf_4k_v0.6.1.json`) si disponible sur le système
- Les fichiers temporaires passent par `/tmp/` (métadonnées HDR10+, options mkvmerge JSON)
- Dolby Vision Profile 8 → fallback HDR10 automatique ; Profile 5 → avertissement
- Le filtre VHS combine `hqdn3d` + `unsharp`
- La stabilisation utilise `vidstabdetect` + `vidstabtransform` en deux passes
  • Étape 6 : Mettre à jour CLAUDE.md — section Gotchas

Remplacer l'intégralité de la section Gotchas par :

## Gotchas

- **libsvtav1 et libopus requis** : le binaire FFmpeg doit être compilé avec `--enable-libsvtav1` et `--enable-libopus`. Vérifier avec `ffmpeg -codecs | grep -E 'svtav1|opus'`.
- **ab-av1 requis** : doit être installé et disponible dans le PATH. Voir https://github.com/alexheretic/ab-av1.
- **Fichiers temporaires orphelins** : en cas d'interruption, `{fichier}_video.mkv`, `{fichier}_audio_*.mka` et fichiers HDR10+ peuvent rester dans le répertoire courant ou `/tmp/`. Les supprimer manuellement si besoin.
- **Fichier de sortie** : `NEW_{nom_source}.mkv` généré dans le même répertoire que la source.
  • Étape 7 : Vérification
grep -c "AV1\|Opus\|MKV\|ab-av1" README.md
grep -c "libsvtav1\|libopus\|ab-av1\|find_crf" CLAUDE.md

Résultat attendu : chaque commande retourne un entier > 0.

  • Étape 8 : Commit
git add README.md CLAUDE.md
git commit -m "docs: mise à jour README et CLAUDE.md pour AV1+Opus+MKV"