Compare commits
27 Commits
v2.0
...
2ffbf726ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
2ffbf726ba
|
|||
|
4e69c3e05b
|
|||
|
dbcf5d55d7
|
|||
|
b99823f24d
|
|||
|
ef34ce82eb
|
|||
|
463e0ea38d
|
|||
|
cff086892b
|
|||
|
90f98ba38c
|
|||
|
d03786e1f5
|
|||
|
5fcae17970
|
|||
|
16f169cb81
|
|||
|
839edd785f
|
|||
|
ea2e07c749
|
|||
|
730919254f
|
|||
|
514ee164d1
|
|||
|
d82f7384e0
|
|||
|
df22df5108
|
|||
|
71a3a89c0f
|
|||
|
605d777572
|
|||
|
f7efbab2aa
|
|||
|
ddbf16504a
|
|||
|
d82cb8a0b3
|
|||
|
b738a3106c
|
|||
|
c58ef4c305
|
|||
|
f2b77697da
|
|||
|
2479ee4b96
|
|||
|
7634ad4fad
|
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).
|
||||||
4
LICENSE
4
LICENSE
@@ -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:
|
||||||
|
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -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.
|
||||||
|
|||||||
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
|
||||||
234
vid_convert.py
234
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):
|
||||||
'''
|
'''
|
||||||
@@ -110,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
|
||||||
|
|
||||||
@@ -137,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,
|
||||||
@@ -158,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)
|
||||||
@@ -167,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 != "":
|
||||||
@@ -227,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)
|
||||||
@@ -276,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
|
||||||
@@ -292,9 +308,9 @@ 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')
|
||||||
@@ -302,27 +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']
|
||||||
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 = 19
|
|
||||||
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