Compare commits
17 Commits
71a3a89c0f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
2ffbf726ba
|
|||
|
4e69c3e05b
|
|||
|
dbcf5d55d7
|
|||
|
b99823f24d
|
|||
|
ef34ce82eb
|
|||
|
463e0ea38d
|
|||
|
cff086892b
|
|||
|
90f98ba38c
|
|||
|
d03786e1f5
|
|||
|
5fcae17970
|
|||
|
16f169cb81
|
|||
|
839edd785f
|
|||
|
ea2e07c749
|
|||
|
730919254f
|
|||
|
514ee164d1
|
|||
|
d82f7384e0
|
|||
|
df22df5108
|
61
CLAUDE.md
Normal file
61
CLAUDE.md
Normal 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).
|
||||||
16
README.md
16
README.md
@@ -1,26 +1,28 @@
|
|||||||
# vid_convert
|
# vid_convert
|
||||||
|
|
||||||
vid_convert est un script qui utilise ffmpeg pour convertir des vidéos au
|
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
|
format [AV1](https://fr.wikipedia.org/wiki/AV1) (libsvtav1), avec un audio en
|
||||||
[AAC-LC](https://fr.wikipedia.org/wiki/Advanced_Audio_Coding).
|
[Opus](https://fr.wikipedia.org/wiki/Opus_(codec_audio)).
|
||||||
Cela permet d'optimiser le poids du fichier sans baisse visible de
|
Cela permet d'optimiser le poids du fichier sans baisse visible de
|
||||||
la qualité avec les paramètres prévus dans le script.
|
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
|
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.
|
laisser à l'utilisateur des choix ou des calculs fastidieux à faire.
|
||||||
|
|
||||||
Le format de sortie est un fichier MPEG-4 (.mp4), lisible sur n'importe quel
|
Le format de sortie est un fichier Matroska (.mkv). Le CRF optimal est
|
||||||
appareil (ou presque) disposant d'une puissance de décodage suffisante.
|
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 format d'entrée peut être n'importe lequel connu par ffmpeg. Pour les BluRay,
|
||||||
le 4K, HDR et HDR10+ sont gérés. Concernant le son, pas de prise en charge du
|
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
|
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.
|
(vidéo, audios et sous-titres) sont conservées.
|
||||||
|
|
||||||
## Installation et dépendances
|
## Installation et dépendances
|
||||||
|
|
||||||
- [Python](https://www.python.org/) (>= 3.5)
|
- [Python](https://www.python.org/) (>= 3.5)
|
||||||
- [ffmpeg](https://ffmpeg.org/)
|
- [ffmpeg](https://ffmpeg.org/) compilé avec `libsvtav1` et `libopus`
|
||||||
- [hdr10plus_tool](https://github.com/quietvoid/hdr10plus_tool)
|
- [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
|
## Utilisation
|
||||||
|
|
||||||
|
|||||||
697
docs/superpowers/plans/2026-03-22-av1-opus.md
Normal file
697
docs/superpowers/plans/2026-03-22-av1-opus.md
Normal 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 81–104 (fonction `is_interlaced` entière).
|
||||||
|
|
||||||
|
- [ ] **Étape 3 : Supprimer la fonction `mkv_to_mp4`**
|
||||||
|
|
||||||
|
Supprimer les lignes 283–290 (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"
|
||||||
|
```
|
||||||
143
docs/superpowers/specs/2026-03-22-av1-opus-design.md
Normal file
143
docs/superpowers/specs/2026-03-22-av1-opus-design.md
Normal 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
|
||||||
229
vid_convert.py
229
vid_convert.py
@@ -5,6 +5,7 @@ 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):
|
||||||
@@ -63,46 +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'
|
||||||
|
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)
|
hdr10_cmd_res = subprocess.getoutput(hdr10_cmd)
|
||||||
logging.debug(hdr10_cmd_res)
|
logging.debug(hdr10_cmd_res)
|
||||||
v_infos.update({'hdr10plus': True, 'hdr10plus_metadata': f'/tmp/{file}_hdr10_metadata.json'})
|
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(f"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):
|
||||||
'''
|
'''
|
||||||
@@ -141,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,
|
||||||
@@ -162,7 +183,13 @@ def extract_subs(file, track, lang):
|
|||||||
def convert_audio(file, track, volume_adj, channels, channel_layout, language, title):
|
def convert_audio(file, track, volume_adj, channels, channel_layout, language, title):
|
||||||
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)
|
||||||
@@ -171,57 +198,67 @@ def convert_audio(file, track, volume_adj, channels, channel_layout, language, t
|
|||||||
logging.error(result)
|
logging.error(result)
|
||||||
|
|
||||||
|
|
||||||
def convert_video(file, infos, start, crop, crf, animation, interlaced, vhs):
|
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"
|
||||||
track = infos['video']['index']
|
track = infos['video']['index']
|
||||||
codec = 'libx265 -preset slower'
|
codec = 'libsvtav1'
|
||||||
hdr = ''
|
svtav1_params = enc_options
|
||||||
if animation:
|
|
||||||
tune = "-tune animation"
|
|
||||||
else:
|
|
||||||
tune = ""
|
|
||||||
if interlaced:
|
if interlaced:
|
||||||
crop = f"{crop},yadif"
|
crop = f"{crop},yadif"
|
||||||
if vhs:
|
if vhs:
|
||||||
crop = f"{crop},hqdn3d,unsharp=5:5:0.8:3:3:0.4"
|
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}'
|
||||||
|
if svtav1_params:
|
||||||
|
svtav1_params = f'{svtav1_params}:{hdr}'
|
||||||
|
else:
|
||||||
|
svtav1_params = hdr
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logging.debug(f"Aucune information HDR : {err}")
|
logging.debug(f"Aucune information HDR statique : {err}")
|
||||||
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}'
|
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)
|
||||||
if result != "":
|
if result != "":
|
||||||
@@ -231,48 +268,32 @@ def convert_video(file, infos, start, crop, crf, animation, interlaced, vhs):
|
|||||||
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 "t00000" not 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)
|
||||||
@@ -280,15 +301,6 @@ def create_mkv(filename):
|
|||||||
remove(file)
|
remove(file)
|
||||||
|
|
||||||
|
|
||||||
def mkv_to_mp4(filename):
|
|
||||||
options = "-c:a copy -c:v copy -c:s copy -movflags faststart"
|
|
||||||
command = f"ffmpeg -loglevel error -i {filename}_FINAL.mkv -map 0 {options} -y NEW_{filename}.mp4"
|
|
||||||
result = subprocess.getoutput(command)
|
|
||||||
if result != "":
|
|
||||||
logging.error(result)
|
|
||||||
else:
|
|
||||||
remove(f"{filename}_FINAL.mkv")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import argparse
|
import argparse
|
||||||
@@ -296,38 +308,33 @@ if __name__ == '__main__':
|
|||||||
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("-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')
|
||||||
else:
|
else:
|
||||||
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 = False
|
|
||||||
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']
|
||||||
for track in infos['subtitles']:
|
crf = find_crf(file, enc_options, hdr=is_hdr)
|
||||||
extract_subs(file, track['index'], track['language'])
|
for track in infos['subtitles']:
|
||||||
for track in infos['audio']:
|
extract_subs(file, track['index'], track['language'])
|
||||||
convert_audio(file, track['index'], volumes[track['index']], track['channels'], track['channel_layout'], track['language'], track['title'])
|
for track in infos['audio']:
|
||||||
if args.starttime:
|
convert_audio(file, track['index'], volumes[track['index']], track['channels'],
|
||||||
vid_part_time = int(args.starttime)
|
track['channel_layout'], track['language'], track['title'])
|
||||||
else:
|
convert_video(file, infos, crf, cropsize, enc_options, args.interlaced, args.vhs)
|
||||||
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)
|
create_mkv(file)
|
||||||
mkv_to_mp4(file)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user