Compare commits

...

27 Commits

Author SHA1 Message Date
2ffbf726ba docs: corrections CLAUDE.md (description convert_video, contrainte CWD)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 00:15:42 +01:00
4e69c3e05b feat: profils d'encodage SVT-AV1 par type de contenu
- Défaut (BluRay live action) : tune=0 + film-grain=8 + enable-qm=1:qm-min=0:scd=1
- --animation : tune=2 (SSIM, meilleur pour aplats/contours) + film-grain=4 (dithering)
- --vhs : tune=0 + irefresh-type=1 + enable-tf=0 (source débruitée, pas de film-grain)
- enable-qm=1:qm-min=0 et scd=1 appliqués à tous les profils (gains gratuits)
- --animation est maintenant fonctionnel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 00:03:31 +01:00
dbcf5d55d7 fix: corrections post-review (escapes, find_crf, color_args, chemins /tmp/)
- Supprime les séquences d'échappement invalides \( \) dans les f-strings HDR10
  (SyntaxWarning → SyntaxError en Python 3.14)
- find_crf : prend la dernière ligne "crf X VMAF Y" au lieu de la première
  (évite de retourner un CRF intermédiaire d'ab-av1 crf-search)
- color_args : garde contre les valeurs None pour color_primaries/transfer/space
- Chemins /tmp/ : utilise os.path.basename() pour éviter les chemins malformés
  si le fichier source est spécifié avec un chemin absolu ou relatif

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:53:58 +01:00
b99823f24d docs: mise à jour README et CLAUDE.md pour AV1+Opus+MKV
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:50:36 +01:00
ef34ce82eb feat: flux principal AV1+Opus — find_crf, encode one-pass, sortie MKV
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:45:57 +01:00
463e0ea38d fix: réutilisation full_v_infos pour format/durée, -vbsf → -bsf:v
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:44:44 +01:00
cff086892b refactor: create_mkv simplifié — une seule piste vidéo, sortie NEW_{filename}.mkv
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:42:02 +01:00
90f98ba38c feat: détection Dolby Vision avec avertissement (fallback HDR10)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:41:43 +01:00
d03786e1f5 fix: détection conteneur source pour extraction HDR10+ (MKV vs MP4)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:41:30 +01:00
5fcae17970 fix: ajout color_primaries/trc/matrix dans commande FFmpeg pour HDR10
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:38:44 +01:00
16f169cb81 feat: encodage vidéo libsvtav1 avec HDR10 statique et HDR10+ via svtav1-params
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:28:43 +01:00
839edd785f feat: ajout find_crf via ab-av1 crf-search (VMAF 96)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:26:04 +01:00
ea2e07c749 feat: encodage audio libopus VBR avec bitrate adaptatif
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:18:07 +01:00
730919254f refactor: suppression code obsolète (chunks, is_interlaced, mkv_to_mp4, -t)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:15:41 +01:00
514ee164d1 Ajout du plan d'implémentation AV1-SVT + Opus
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:13:19 +01:00
d82f7384e0 Mise à jour spec : preset=3 et tune=0 (VQ) par défaut
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:02:56 +01:00
df22df5108 Ajout de la spec de migration AV1-SVT + Opus
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 22:56:41 +01:00
71a3a89c0f disable interlaced check; speedest detection 2026-03-22 22:13:11 +01:00
605d777572 [Foirage de git merge...] 2022-07-25 17:29:04 +02:00
f7efbab2aa Gestion du désentrelacement, correction de bugs et passage au containeur MP4 2022-07-25 17:27:06 +02:00
ddbf16504a Résolution d'un bug dans le conversion Matroska vers MPEG-4 2022-07-07 19:38:22 +02:00
d82cb8a0b3 Meilleure gestion du journal des erreurs 2022-07-06 20:08:13 +02:00
b738a3106c Un README un peu plus constructif 2022-07-06 19:57:44 +02:00
c58ef4c305 Fichier final au format MPEG-4 2022-07-06 19:51:51 +02:00
f2b77697da Suppression de "MIT License" (license Expat, c'est pareil mais bref...) 2022-07-06 17:45:31 +02:00
2479ee4b96 Amélioration de code 2022-07-06 17:38:21 +02:00
7634ad4fad correction de typo dans la fonction pour hdr10+ 2022-07-06 17:24:09 +02:00
6 changed files with 1086 additions and 111 deletions

61
CLAUDE.md Normal file
View File

@@ -0,0 +1,61 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Projet
Script Python standalone de conversion vidéo vers AV1 (libsvtav1) / Opus (libopus) en conteneur MKV, avec sélection automatique du CRF via `ab-av1 crf-search` (VMAF 96), support HDR10/HDR10+, détection de bandes noires, et normalisation audio.
## Utilisation
```bash
./vid_convert.py <fichier_entree>
./vid_convert.py -h # aide
./vid_convert.py -d <fichier> # mode debug
./vid_convert.py -s <fichier> # avec stabilisation vidéo
./vid_convert.py -a <fichier> # tuning animation
./vid_convert.py -c <fichier> # restauration VHS
./vid_convert.py --interlaced <fichier> # forcer le désentrelacement (yadif)
```
Pas de build, pas de tests, pas de linter configuré. Exécution directe Python 3.
## Dépendances externes requises
- `ffmpeg` et `ffprobe` — encodage et extraction de métadonnées (libsvtav1 + libopus requis)
- `ab-av1` — sélection automatique du CRF par crf-search (VMAF 96)
- `mkvmerge` (MkvToolNix) — assemblage du conteneur MKV final
- `hdr10plus_parser` (binaire inclus) — extraction/injection des métadonnées HDR10+
## Architecture
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+, Dolby Vision, conteneur source
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 → CRF optimal pour VMAF 96
5. **`extract_subs(file, track, lang)`** — extraction des pistes de sous-titres
6. **`convert_audio(...)`** — encodage libopus VBR par piste (128k/320k/450k selon canaux)
7. **`convert_video(...)`** — encodage libsvtav1, profil enc_options selon contenu, HDR10/HDR10+ via `-svtav1-params`
8. **`create_mkv(filename)`** — mkvmerge → assemblage MKV final (`NEW_{basename}.mkv`)
### Points clés
- Sortie vidéo : `yuv420p10le` (10 bits), encodage en une seule passe
- HDR10 statique : mastering display + content-light via `-svtav1-params`
- HDR10+ : injection native SVT-AV1 via `hdr10plus-json=` dans `-svtav1-params`
- Dolby Vision : détection uniquement, fallback automatique vers HDR10 (Profile 8) ou avertissement (Profile 5)
- Les fichiers temporaires passent par `/tmp/` (métadonnées HDR10+, options mkvmerge JSON)
- Le filtre VHS combine `hqdn3d` + `unsharp`
- La stabilisation utilise `vidstabdetect` + `vidstabtransform` en deux passes
## Gotchas
- **libsvtav1 + libopus requis** : le binaire FFmpeg doit être compilé avec `--enable-libsvtav1` et `--enable-libopus`. Vérifier avec `ffmpeg -codecs | grep -E 'svt|opus'`.
- **ab-av1 dans le PATH** : `ab-av1 crf-search` doit être accessible. En cas d'échec, fallback automatique sur CRF 32.
- **Fichier de sortie** : `NEW_{nom_source}.mkv`, généré dans le même répertoire que la source. Les fichiers temporaires (`_video.mkv`, `_audio_*.mka`, `_subtitle_*.mkv`) sont supprimés après assemblage.
- **Fichiers temporaires orphelins** : en cas d'interruption, les intermédiaires peuvent rester dans le répertoire de la source. Les supprimer manuellement si besoin.
- **HDR10+ et conteneur source** : `hevc_mp4toannexb` est appliqué uniquement pour les sources MP4/MOV (pas MKV) lors de l'extraction HDR10+ par `hdr10plus_parser`.
- **CWD requis** : `create_mkv` cherche les fichiers temporaires dans le répertoire courant (`listdir()`). Lancer le script depuis le répertoire du fichier source, ou passer un chemin relatif depuis ce répertoire.
- **Profils d'encodage par contenu** : les flags `--animation` et `--vhs` modifient `enc_options` passé à SVT-AV1. Défaut (BluRay live action) : `tune=0:film-grain=8`. Animation : `tune=2:film-grain=4`. VHS : `tune=0:irefresh-type=1:enable-tf=0` (pas de film-grain, source déjà débruitée par hqdn3d).

View File

@@ -1,6 +1,4 @@
MIT License Copyright 2022 Antoine Van Elstraete <antoine@van-elstraete.net>
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@@ -1,2 +1,46 @@
# vid_convert # vid_convert
vid_convert est un script qui utilise ffmpeg pour convertir des vidéos au
format [AV1](https://fr.wikipedia.org/wiki/AV1) (libsvtav1), avec un audio en
[Opus](https://fr.wikipedia.org/wiki/Opus_(codec_audio)).
Cela permet d'optimiser le poids du fichier sans baisse visible de
la qualité avec les paramètres prévus dans le script.
Le but est de faire un script qui enchaine les différentes opérations, sans
laisser à l'utilisateur des choix ou des calculs fastidieux à faire.
Le format de sortie est un fichier Matroska (.mkv). Le CRF optimal est
déterminé automatiquement via `ab-av1 crf-search` pour atteindre un VMAF de 96.
Le format d'entrée peut être n'importe lequel connu par ffmpeg. Pour les BluRay,
le 4K, HDR10 et HDR10+ sont gérés. Concernant le son, pas de prise en charge du
canal Atmos, mais le multi-canal (5.1, 7.1, ...) est géré. Toutes les pistes
(vidéo, audios et sous-titres) sont conservées.
## Installation et dépendances
- [Python](https://www.python.org/) (>= 3.5)
- [ffmpeg](https://ffmpeg.org/) compilé avec `libsvtav1` et `libopus`
- [ab-av1](https://github.com/alexheretic/ab-av1) — sélection automatique du CRF
- [MkvToolNix](https://mkvtoolnix.download/) (`mkvmerge`) — assemblage du conteneur MKV
- `hdr10plus_parser` (binaire inclus) — extraction/injection des métadonnées HDR10+
## Utilisation
```bash
./vid_convert.py -h
```
## Contributions
Les remarques et contributions sont les bienvenues. Par mail, avec un fichier de
patch si vous le souhaitez.
## Licence
Licence [Expat](https://commons.wikimedia.org/wiki/Template:Expat).
La licence donne à toute personne recevant le logiciel (et ses fichiers) le
droit illimité de l'utiliser, le copier, le modifier, le fusionner, le
publier, le distribuer, le vendre et le « sous-licencier » (l'incorporer
dans une autre licence). La seule obligation est d'incorporer la notice de
licence et de copyright dans toutes les copies.

View File

@@ -0,0 +1,697 @@
# 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 :
```python
# infos = get_infos(file)
interlaced = False
interlaced = is_interlaced(file, infos)
```
Par :
```python
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 :
```python
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 :
```python
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) :
```python
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) :
```python
pass
```
- [ ] **Étape 6 : Vérifier que le script parse sans erreur**
```bash
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"
```
Résultat attendu : `OK`
- [ ] **Étape 7 : Commit**
```bash
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**
```bash
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 :
```python
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**
```bash
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"
```
Résultat attendu : `OK`
- [ ] **Étape 4 : Commit**
```bash
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 :
```python
import os
```
- [ ] **Étape 2 : Ajouter la fonction `find_crf`**
Insérer après la fonction `volume_audio` et avant `stabilization` :
```python
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**
```bash
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"
```
Résultat attendu : `OK`
- [ ] **Étape 4 : Commit**
```bash
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` :
```python
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 :
```python
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 :
```python
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) :
```python
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**
```bash
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"
```
Résultat attendu : `OK`
- [ ] **Étape 5 : Commit**
```bash
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 :
```python
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 :
```python
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**
```bash
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"
```
Résultat attendu : `OK`
- [ ] **Étape 3 : Commit**
```bash
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 :
```python
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) :
```python
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**
```bash
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"
```
Résultat attendu : `OK`
- [ ] **Étape 3 : Commit**
```bash
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 :
```python
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**
```bash
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"
```
Résultat attendu : `OK`
- [ ] **Étape 3 : Commit**
```bash
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 :
```python
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**
```bash
python3 -c "import ast; ast.parse(open('vid_convert.py').read()); print('OK')"
```
Résultat attendu : `OK`
- [ ] **Étape 3 : Vérifier l'aide CLI**
```bash
python3 vid_convert.py --help
```
Résultat attendu : affichage des arguments `f_input`, `-d`, `-s`, `-a`, `-c`, `--interlaced` — sans `-t`.
- [ ] **Étape 4 : Commit**
```bash
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**
```bash
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**
```bash
git add README.md CLAUDE.md
git commit -m "docs: mise à jour README et CLAUDE.md pour AV1+Opus+MKV"
```

View File

@@ -0,0 +1,143 @@
# Spec : Migration AV1-SVT + Opus
**Date :** 2026-03-22
**Objectif :** Remplacer le pipeline H.265/AAC par AV1-SVT/Opus avec sélection automatique du CRF via `ab-av1 crf-search`. Passer la sortie en MKV. Simplifier la structure (suppression du chunking, de l'entrelacement automatique, du remux MP4).
---
## Contexte
`vid_convert.py` est un script Python standalone de conversion vidéo pour l'archivage de demux DVD/BluRay. Il orchestre FFmpeg, mkvmerge et des outils tiers pour produire un fichier optimisé qualité/poids.
**Sources attendues :** demux HEVC (H.265) en conteneur MKV ou MP4, ou fichiers DVD (MPEG-2, SD).
État actuel : H.265 (libx265), AAC-LC (libfdk_aac), encodage en chunks de 300s, sortie MP4.
---
## Changements
### 1. Codec vidéo : libx265 → libsvtav1
- Encodeur : `libsvtav1`
- Pixel format : `yuv420p10le` (10 bits, inchangé)
- Paramètres SVT-AV1 via `-svtav1-params` (remplace `-x265-params`)
- Suppression de la logique de chunks (300s) : encodage en une seule passe
- Sortie intermédiaire : `{file}_video.mkv` (une seule piste vidéo, remplace les multiples `{file}_video_t*.mkv`)
### 2. Sélection du CRF via ab-av1
Nouvelle fonction `find_crf(file, enc_options)` :
- Commande : `ab-av1 crf-search --input {file} --encoder libsvtav1 --vmaf 96 --enc {enc_options}`
- Cible VMAF : **96** (codé en dur)
- **Pour les sources HDR10**, ajouter `--vmaf-model path=/usr/share/vmaf/model/vmaf_4k_v0.6.1.json` si disponible, afin d'utiliser un modèle VMAF calibré HDR. Si le modèle est absent, `ab-av1` utilise le modèle par défaut (acceptable, légère imprécision sur HDR).
- **Parsing de la sortie :** `ab-av1 crf-search` émet une ligne finale du type `crf 32 VMAF 96.21 ...`. On extrait le CRF en cherchant une ligne contenant à la fois `crf` et `VMAF` et en lisant le token suivant `crf`.
- Fallback à CRF 32 si la commande échoue ou si le parsing ne trouve rien.
**`enc_options`** est une chaîne de paramètres SVT-AV1 au format `key=value:key=value`, construite dans le `main` :
| Flag CLI | Option SVT-AV1 |
|---|---|
| (base) | `preset=3:tune=0` (VQ, qualité perçue — défaut pour tous les contenus) |
| `--animation` | aucun changement de tune (VQ reste optimal) |
| `--interlaced` | aucun paramètre SVT-AV1 (yadif géré côté FFmpeg) |
Les métadonnées HDR (mastering display, HDR10+) sont passées **séparément** à `convert_video` via `-svtav1-params`, et non via `enc_options`. `enc_options` ne contient que les options de qualité/preset.
### 3. Codec audio : libfdk_aac → libopus
- Encodeur : `libopus` (doit être compilé dans FFmpeg — vérifier avec `ffmpeg -codecs | grep opus`)
- Mode VBR (`-vbr on`)
- Bitrate adaptatif :
- ≤ 2 canaux : 128k
- 6 canaux (5.1) : 320k
- 8 canaux (7.1) : 450k
- Multicanal : `-mapping_family 1`
- Workaround `5.1(side)``5.1` conservé
- Normalisation de volume (`volumedetect` + filtre `volume=`) inchangée
### 4. HDR10 statique
Les métadonnées mastering display et content-light sont passées via `-svtav1-params` :
```
mastering-display=G(x,y)B(x,y)R(x,y)WP(x,y)L(max,min):content-light=maxcll,maxfall
```
La logique de calcul des coordonnées chromatiques (depuis `side_data_list`) est conservée.
### 5. HDR10+
- **Extraction :** `hdr10plus_parser` (binaire inclus)
- **Conteneur source :** si le fichier source est en MKV, la commande d'extraction doit omettre `-vbsf hevc_mp4toannexb` (ce bitstream filter est uniquement nécessaire pour les sources HEVC en MP4). `get_infos` détecte le conteneur via `ffprobe format_name` et adapte la commande.
- **Injection :** via `hdr10plus-json={path}` dans `-svtav1-params` (SVT-AV1 natif)
### 6. Dolby Vision
- `get_infos` détecte la présence d'un stream DV (entrée `DOVI` dans `side_data_list`)
- **Profile 8** (dual-layer, cas le plus fréquent) : fallback automatique vers HDR10 — la couche HDR10 est encodée normalement, l'enhancement layer DV est perdu
- **Profile 5** (RPU uniquement, sans couche HDR10 indépendante) : avertissement loggé ; la couche de base est traitée telle quelle (HDR10 si les métadonnées sont présentes dans le stream, sinon SDR)
- Dans tous les cas, un message indique : `"Dolby Vision détecté (Profile X). Encodage sans couche DV."`
- Pas d'intégration `dovi_tool`
### 7. Entrelacement
- `is_interlaced()` supprimée
- Nouvel argument CLI `--interlaced` (flag booléen)
- Le filtre `yadif` dans `convert_video` n'est appliqué que si `--interlaced` est passé
### 8. Assemblage MKV simplifié
- `create_mkv` assemble : `{file}_video.mkv` + N `{file}_audio_*.mka` + N `{file}_subtitle_*.mkv``NEW_{filename}.mkv`
- Le fichier de sortie est directement nommé `NEW_{filename}.mkv` (suppression du nom intermédiaire `_FINAL.mkv`)
- Nettoyage des fichiers temporaires : `{file}_video.mkv`, `{file}_audio_*.mka`, `{file}_subtitle_*.mkv` supprimés après assemblage
- `mkv_to_mp4` supprimée
### 9. Corrections
- Ligne 308 : `infos = get_infos(file)` décommenté
- `interlaced = False` orphelin supprimé
- `-t` / `--starttime` supprimé
---
## Arguments CLI finaux
| Argument | Description |
|---|---|
| `f_input` | Fichier source |
| `-d` / `--debug` | Logging DEBUG |
| `-s` / `--stabilise` | Stabilisation vidéo (vidstab) |
| `-a` / `--animation` | Tuning animation (tune=0) |
| `-c` / `--vhs` | Restauration VHS (hqdn3d + unsharp) |
| `--interlaced` | Forcer le désentrelacement (yadif) |
---
## Flux d'exécution
```
get_infos → [stabilization] → cropping → volume_audio → find_crf
→ extract_subs (×N) → convert_audio (×N) → convert_video → create_mkv
```
`stabilization` n'est exécutée que si `--stabilise` est passé, avant le crop.
---
## Dépendances
| Outil | Usage | Changement |
|---|---|---|
| `ffmpeg` + `ffprobe` | Encodage, analyse | Inchangé (libopus requis) |
| `ab-av1` | Sélection CRF | **Nouveau** |
| `hdr10plus_parser` | Extraction HDR10+ | Inchangé |
| `mkvmerge` | Assemblage final | Inchangé |
| `libfdk_aac` | Audio AAC | **Supprimé** |
| `libopus` | Audio Opus | **Nouveau** (dans FFmpeg) |
---
## Hors scope
- Dolby Vision Profile 5 avec `dovi_tool`
- DV Profile 10 pour AV1 (expérimental, non viable)
- Interface utilisateur, tests automatisés

View File

@@ -5,6 +5,8 @@ import json
import subprocess import subprocess
import logging import logging
from os import listdir, remove from os import listdir, remove
import os
def get_infos(file): def get_infos(file):
''' '''
@@ -53,7 +55,7 @@ def get_infos(file):
} }
try: try:
a_stream_infos.update({'title': a_stream['tags']['title']}) a_stream_infos.update({'title': a_stream['tags']['title']})
except: except KeyError:
a_stream_infos.update({'title': 'No title'}) a_stream_infos.update({'title': 'No title'})
a_infos.append(a_stream_infos) a_infos.append(a_stream_infos)
s_infos = [] s_infos = []
@@ -62,44 +64,35 @@ def get_infos(file):
'index': s_stream['index'], 'index': s_stream['index'],
'language': s_stream['tags']['language']} 'language': s_stream['tags']['language']}
s_infos.append(s_stream_infos) s_infos.append(s_stream_infos)
duration = subprocess.getoutput(f"ffprobe -v quiet -print_format json -show_format {file}") format_name = full_v_infos.get('format', {}).get('format_name', '')
duration = json.loads(duration) duration = float(full_v_infos['format']['duration'])
duration = float(duration['format']['duration']) is_mp4_container = 'mp4' in format_name or 'mov' in format_name
hdr10_v_cmd = f'ffmpeg -loglevel panic -i {file} -c:v copy -vbsf hevc_mp4toannexb -f hevc - | hdr10plus_parser -o metadata.json --verify -' bsf = '-bsf:v 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) hdr10_v_raw = subprocess.getoutput(hdr10_v_cmd)
if 'metadata detected' in hdr10_v_raw: 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_meta = f'/tmp/{os.path.basename(file)}_hdr10_metadata.json'
v_infos.update({'hdr10': True, 'hdr10_metdata': f'/tmp/{file}_hdr10_metadata.json'}) hdr10_cmd = f'ffmpeg -loglevel panic -i {file} -c:v copy {bsf} -f hevc - | hdr10plus_parser -o {hdr10_meta} -'
hdr10_cmd_res = subprocess.getoutput(hdr10_cmd)
logging.debug(hdr10_cmd_res)
v_infos.update({'hdr10plus': True, 'hdr10plus_metadata': hdr10_meta})
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
infos = {'duration': duration, 'video': v_infos, 'audio': a_infos, 'subtitles': s_infos} infos = {'duration': duration, 'video': v_infos, 'audio': a_infos, 'subtitles': s_infos}
logging.debug("Informations du film : \n" + json.dumps(infos, indent=True)) logging.debug("Informations du film : \n" + json.dumps(infos, indent=True))
return infos return infos
def is_interlaced(file, infos):
'''
Cette fonction detecte si la vidéo est entrelacée.
-> https://fr.wikipedia.org/wiki/Entrelacement_(vid%C3%A9o)
'''
duration_tier = int(infos['duration'] / 3)
command = f"ffmpeg -loglevel info -ss {duration_tier} -t {duration_tier} -i {file} -an -filter:v idet -f null -y /dev/null"
result = subprocess.getoutput(command)
for line in result.splitlines():
if "Multi" in line:
TFF = int(line.split('TFF:')[1].split()[0])
BFF = int(line.split('BFF:')[1].split()[0])
Progressive = int(line.split('Progressive:')[1].split()[0])
try:
pct = ((TFF + BFF) / (TFF + BFF + Progressive)) * 100
pct = round(pct)
except ZeroDivisionError:
pct = 100
if pct > 10:
logging.debug("Vidéo entrelacée à {pct}%")
return True
else:
logging.debug("Vidéo non entrelacée")
return False
def cropping(file, infos): def cropping(file, infos):
''' '''
@@ -107,9 +100,13 @@ def cropping(file, infos):
''' '''
logging.info("Détection de la taille de l'image...") logging.info("Détection de la taille de l'image...")
duration_tier = int(infos['duration'] / 3) duration_tier = int(infos['duration'] / 3)
command = f"ffmpeg -loglevel info -i {file} -ss {duration_tier} -t {duration_tier} -an -f null -vf cropdetect -y /dev/null" command = f"ffmpeg -loglevel info -i {file} -ss {duration_tier} -t {int(duration_tier / 10)} -an -f null -vf cropdetect -y /dev/null"
logging.debug(command) logging.debug(command)
cropsize = subprocess.getoutput(command).splitlines()[-3].split()[-1] line = -3
cropsize = "times"
while cropsize == "times" or ":" not in cropsize:
cropsize = subprocess.getoutput(command).splitlines()[line].split()[-1]
line -= 1
logging.debug(f"Paramètre de découpe : {cropsize}") logging.debug(f"Paramètre de découpe : {cropsize}")
return cropsize return cropsize
@@ -134,6 +131,37 @@ def volume_audio(file, infos):
return volumes return volumes
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)
found_crf = None
for line in result.splitlines():
# ab-av1 émet des lignes intermédiaires puis une ligne finale "crf 32 VMAF 96.21 ..."
if 'crf' in line and 'VMAF' in line:
try:
found_crf = int(line.split('crf')[1].split()[0])
except (IndexError, ValueError):
pass
if found_crf is not None:
logging.info(f"CRF optimal trouvé : {found_crf}")
return found_crf
logging.warning("ab-av1 crf-search a échoué, utilisation du CRF par défaut (32)")
return 32
def stabilization(file): def stabilization(file):
''' '''
Cette fonction permet de stabiliser l'image, Cette fonction permet de stabiliser l'image,
@@ -149,118 +177,123 @@ def extract_subs(file, track, lang):
logging.debug(command) logging.debug(command)
result = subprocess.getoutput(command) result = subprocess.getoutput(command)
if result != "": if result != "":
logging.info(result) logging.error(result)
def convert_audio(file, track, volume_adj, channels, channel_layout, language, title): def convert_audio(file, track, volume_adj, channels, channel_layout, language, title):
bitrate = f'{64*channels}k'
if channel_layout == "5.1(side)": if channel_layout == "5.1(side)":
channel_layout = "5.1" channel_layout = "5.1"
codec = 'libfdk_aac -vbr 5' 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}"' 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' 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) logging.debug(command)
result = subprocess.getoutput(command) result = subprocess.getoutput(command)
logging.info(result) if result != "":
logging.error(result)
def convert_video(file, infos, start, crop, crf, animation): def convert_video(file, infos, crf, crop, enc_options, interlaced, vhs):
str_start = "{:05d}".format(start) output = f'{file}_video.mkv'
output = f'{file}_video_t{str_start}.mkv' fmt = "yuv420p10le"
fmt = "yuv420p10le" # Always 10-bits
track = infos['video']['index'] track = infos['video']['index']
codec = 'libx265 -preset slower' codec = 'libsvtav1'
hdr = '' svtav1_params = enc_options
if animation: if interlaced:
tune = "-tune animation" crop = f"{crop},yadif"
else: if vhs:
tune = "" crop = f"{crop},hqdn3d,unsharp=5:5:0.8:3:3:0.4"
color_args = ''
if 'side_data_list' in infos['video'].keys(): if 'side_data_list' in infos['video'].keys():
try: try:
light_level = f"{infos['video']['side_data_list'][1]['max_content']},{infos['video']['side_data_list'][1]['max_average']}" 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_primaries = infos['video']['color_primaries']
color_transfer = infos['video']['color_transfer'] color_transfer = infos['video']['color_transfer']
color_space = infos['video']['color_space'] color_space = infos['video']['color_space']
if all([color_primaries, color_transfer, color_space]):
color_args = f'-color_primaries {color_primaries} -color_trc {color_transfer} -colorspace {color_space}'
green_x = infos['video']['side_data_list'][0]['green_x'].split('/') green_x = infos['video']['side_data_list'][0]['green_x'].split('/')
green_x = int(int(green_x[0])*(int(green_x[1])/50000)) 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 = infos['video']['side_data_list'][0]['green_y'].split('/')
green_y = int(int(green_y[0])*(int(green_y[1])/50000)) green_y = int(int(green_y[0])*(int(green_y[1])/50000))
green = f'G\({green_x},{green_y}\)' green = f'G({green_x},{green_y})'
blue_x = infos['video']['side_data_list'][0]['blue_x'].split('/') blue_x = infos['video']['side_data_list'][0]['blue_x'].split('/')
blue_x = int(int(blue_x[0])*(int(blue_x[1])/50000)) 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 = infos['video']['side_data_list'][0]['blue_y'].split('/')
blue_y = int(int(blue_y[0])*(int(blue_y[1])/50000)) blue_y = int(int(blue_y[0])*(int(blue_y[1])/50000))
blue = f'B\({blue_x},{blue_y}\)' blue = f'B({blue_x},{blue_y})'
red_x = infos['video']['side_data_list'][0]['red_x'].split('/') red_x = infos['video']['side_data_list'][0]['red_x'].split('/')
red_x = int(int(red_x[0])*(int(red_x[1])/50000)) 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 = infos['video']['side_data_list'][0]['red_y'].split('/')
red_y = int(int(red_y[0])*(int(red_y[1])/50000)) red_y = int(int(red_y[0])*(int(red_y[1])/50000))
red = f'R\({red_x},{red_y}\)' red = f'R({red_x},{red_y})'
white_point_x = infos['video']['side_data_list'][0]['white_point_x'].split('/') 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_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 = 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_y = int(int(white_point_y[0])*(int(white_point_y[1])/50000))
white_point = f'WP\({white_point_x},{white_point_y}\)' white_point = f'WP({white_point_x},{white_point_y})'
min_luminance = infos['video']['side_data_list'][0]['min_luminance'].split('/') min_luminance = infos['video']['side_data_list'][0]['min_luminance'].split('/')
min_luminance = int(int(min_luminance[0])*(int(min_luminance[1])/10000)) 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 = infos['video']['side_data_list'][0]['max_luminance'].split('/')
max_luminance = int(int(max_luminance[0])*(int(max_luminance[1])/10000)) max_luminance = int(int(max_luminance[0])*(int(max_luminance[1])/10000))
luminance = f'L\({max_luminance},{min_luminance}\)' luminance = f'L({max_luminance},{min_luminance})'
master_display = green + blue + red + white_point + luminance master_display = green + blue + red + white_point + luminance
hdr = f'-x265-params hdr-opt=1:repeat-headers=1:colorprim={color_primaries}:transfer={color_transfer}:colormatrix={color_space}:master-display={master_display}:max-cll={light_level}' hdr = f'mastering-display={master_display}:content-light={light_level}'
except: if svtav1_params:
logging.debug("Aucune information HDR") svtav1_params = f'{svtav1_params}:{hdr}'
command = f'ffmpeg -loglevel error -i {file} -map 0:{track} -ss {start} -t 300 -an -sn -c:v {codec} {tune} {hdr} -crf {crf} -pix_fmt {fmt} -filter:v {crop} -y {output}' else:
svtav1_params = hdr
except Exception as err:
logging.debug(f"Aucune information HDR statique : {err}")
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} {color_args} -filter:v {crop} -y {output}'
logging.info("Encodage vidéo en cours (AV1-SVT)...")
logging.debug(command) logging.debug(command)
result = subprocess.getoutput(command) result = subprocess.getoutput(command)
logging.info(result) if result != "":
logging.error(result)
def create_mkv(filename): def create_mkv(filename):
json_data = [] json_data = []
json_data.append("--output") json_data.append("--output")
json_data.append(f"{filename}_FINAL.mkv") json_data.append(f"NEW_{filename}.mkv")
for file in listdir(): video_file = f"{filename}_video.mkv"
if f"{filename}_video_t" in file: json_data += ["--no-track-tags", "--no-global-tags", "--no-chapters",
json_data.append("--no-track-tags") "(", video_file, ")"]
json_data.append("--no-global-tags")
json_data.append("--no-chapters")
if not "t00000" in file:
json_data.append("+")
json_data.append("(")
json_data.append(file)
json_data.append(")")
for file in listdir(): for file in listdir():
if f"{filename}_audio" in file: if f"{filename}_audio" in file:
lang = file[-7:][:-4] lang = file[-7:][:-4]
json_data.append("--no-track-tags") json_data += ["--no-track-tags", "--no-global-tags", "--no-chapters",
json_data.append("--no-global-tags") "--language", f"0:{lang}",
json_data.append("--no-chapters") "(", file, ")"]
json_data.append("--language")
json_data.append(f"0:{lang}")
json_data.append("(")
json_data.append(file)
json_data.append(")")
for file in listdir(): for file in listdir():
if f"{filename}_subtitle" in file: if f"{filename}_subtitle" in file:
json_data.append("--no-track-tags")
json_data.append("--no-global-tags")
json_data.append("--no-chapters")
lang = file[-7:][:-4] lang = file[-7:][:-4]
json_data.append("--language") json_data += ["--no-track-tags", "--no-global-tags", "--no-chapters",
json_data.append(f"0:{lang}") "--language", f"0:{lang}",
json_data.append("(") "(", file, ")"]
json_data.append(file) json_path = f"/tmp/{os.path.basename(filename)}.json"
json_data.append(")") with open(json_path, "w") as mkvmerge_options:
with open(f"/tmp/{filename}.json", "w") as mkvmerge_options:
mkvmerge_options.write(json.dumps(json_data)) mkvmerge_options.write(json.dumps(json_data))
command = f"mkvmerge -v @/tmp/{filename}.json" command = f"mkvmerge -v @{json_path}"
logging.debug(command) logging.debug(command)
result = subprocess.getoutput(command) result = subprocess.getoutput(command)
logging.info(result) logging.info(result)
remove(f"/tmp/{filename}.json") remove(json_path)
for file in listdir(): for file in listdir():
if f"{filename}_video" in file: if file == video_file:
remove(file) remove(file)
if f"{filename}_audio" in file: if f"{filename}_audio" in file:
remove(file) remove(file)
@@ -268,14 +301,16 @@ def create_mkv(filename):
remove(file) remove(file)
if __name__ == '__main__': if __name__ == '__main__':
import argparse import argparse
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("f_input") parser.add_argument("f_input")
parser.add_argument("-d", "--debug", dest="debug", action="store_true") parser.add_argument("-d", "--debug", dest="debug", action="store_true")
parser.add_argument("-s", "--stabilise", dest="stab", action="store_true") parser.add_argument("-s", "--stabilise", dest="stab", action="store_true")
parser.add_argument("-t", "--starttime", dest="starttime")
parser.add_argument("-a", "--animation", dest="animation", action="store_true") 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")
args = parser.parse_args() args = parser.parse_args()
if args.debug: if args.debug:
logging.basicConfig(format='[%(asctime)s]\n%(message)s', level=logging.DEBUG, datefmt='%d/%m/%Y %H:%M:%S') logging.basicConfig(format='[%(asctime)s]\n%(message)s', level=logging.DEBUG, datefmt='%d/%m/%Y %H:%M:%S')
@@ -283,26 +318,23 @@ if __name__ == '__main__':
logging.basicConfig(format='[%(asctime)s]\n%(message)s', level=logging.INFO, datefmt='%d/%m/%Y %H:%M:%S') logging.basicConfig(format='[%(asctime)s]\n%(message)s', level=logging.INFO, datefmt='%d/%m/%Y %H:%M:%S')
file = args.f_input file = args.f_input
infos = get_infos(file) infos = get_infos(file)
# interlaced = is_interlaced(file, infos)
cropsize = cropping(file, infos)
volumes = volume_audio(file, infos)
if args.stab: if args.stab:
stabilization(file) stabilization(file)
cropsize = cropping(file, infos)
volumes = volume_audio(file, infos)
base = 'preset=3:enable-qm=1:qm-min=0:scd=1'
if args.animation: if args.animation:
animation = True enc_options = f'{base}:tune=2:film-grain=4'
elif args.vhs:
enc_options = f'{base}:tune=0:irefresh-type=1:enable-tf=0'
else: else:
animation = False enc_options = f'{base}:tune=0:film-grain=8'
if not args.starttime: 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']: for track in infos['subtitles']:
extract_subs(file, track['index'], track['language']) extract_subs(file, track['index'], track['language'])
for track in infos['audio']: for track in infos['audio']:
convert_audio(file, track['index'], volumes[track['index']], track['channels'], track['channel_layout'], track['language'], track['title']) convert_audio(file, track['index'], volumes[track['index']], track['channels'],
if args.starttime: track['channel_layout'], track['language'], track['title'])
vid_part_time = int(args.starttime) convert_video(file, infos, crf, cropsize, enc_options, args.interlaced, args.vhs)
else:
vid_part_time = 0
while vid_part_time < infos['duration']:
crf = 19
convert_video(file, infos, vid_part_time, cropsize, crf, animation)
vid_part_time += 300
create_mkv(file) create_mkv(file)