Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ FULL Vibecoded App for Proof of Concept - no human code, only human prompts and

Interface graphique pour préparer des fichiers vidéo, remuxer sans perte, réencoder avec `ffmpeg`, et fusionner des métadonnées Dolby Vision / HDR10+.

Cette documentation correspond à **Mediarecode v1.4.0**.
Cette documentation correspond à **Mediarecode v1.4.x**.

## Sommaire

Expand Down Expand Up @@ -149,6 +149,7 @@ Les artefacts sont toujours déposés dans `dist/releases/`.
| Cible | Commande | Artefact produit |
|-------|----------|-----------------|
| AppImage Linux | `python3 package.py --allinc` | `dist/releases/Mediarecode-x86_64.AppImage` + `dist/releases/Mediarecode-x86_64.AppImage.zsync` |
| Package macOS natif | `python3 package.py --dmg` | `Mediarecode.app` + `dist/releases/Mediarecode-<version>.dmg` |
| Package Windows (natif + MSIX) | `py package.py --msix` | `dist/releases/Mediarecode.msix` |
| Soumission Microsoft Store | `py package.py --msix --msixupload --store-config packaging/msix_store.json` | `dist/releases/Mediarecode.msixupload` |
| Installateur Windows cross (depuis Linux) | `python3 package.py --windows` | `dist/releases/Mediarecode-Setup.exe` via Wine + NSIS |
Expand Down Expand Up @@ -178,6 +179,7 @@ Options utiles de `package.py` :
| Option | Effet |
|--------|-------|
| `--allinc` | intègre toutes les dépendances dans l'AppImage et génère le `.zsync` (Linux) |
| `--dmg` | sur macOS natif, produit un `.dmg` distribuable depuis `Mediarecode.app` |
| `--msix` | produit un package MSIX sur Windows natif, signé si les variables `MEDIARECODE_MSIX_*` sont définies |
| `--msixupload` | génère un `.msixupload` pour Partner Center à partir du package MSIX |
| `--store-config PATH` | charge les métadonnées Store/MSIX depuis un JSON dédié |
Expand Down Expand Up @@ -206,6 +208,10 @@ Le workflow unifié permet de :
- inspecter vidéo, audio, sous-titres, chapitres, pièces jointes et tags MKV
- activer, exclure et réordonner les pistes
- éditer langue, titre et flags de chaque piste
- créer des variantes audio indépendantes depuis l'onglet Encodage, sans modifier la piste d'origine
- réordonner ou supprimer ces variantes sans perdre leur lien avec le workflow
- visualiser dans le panel remux le codec et le bitrate cibles lorsqu'une piste audio sera réencodée
- extraire une piste de sous-titre depuis le menu contextuel du tableau des pistes
- définir le titre du conteneur, les balises globales, les chapitres et les pièces jointes
- ouvrir une fenêtre de recherche **TMDB** depuis le panneau balises pour rechercher film/série (préremplissage auto depuis titre/nom de fichier)
- détecter automatiquement les motifs de série (`SxxExx`, `x`) pour préremplir saison/épisode et positionner la recherche sur **Séries** si pertinent
Expand All @@ -232,9 +238,11 @@ Backend remux `ffmpeg` (par défaut) :

- sortie limitée à `MKV`
- écrit la langue de piste en BCP47 sur `language` (ex. `fr-FR`) et purge le champ legacy `language-ietf` pour éviter les doublons incohérents
- corrige au besoin les tags de langue Matroska en post-action, sans repasser par MKVToolNix
- permet la recopie ou l'édition des chapitres
- permet d'écrire les tags globaux choisis
- permet de recopier les pièces jointes source sélectionnées et d'ajouter des fichiers externes (cover incluse)
- peut générer un fichier `.nfo` MediaInfo à côté du MKV final après un remux ou un encodage réussi
- télécharge la cover TMDB différée juste avant l'exécution (dans le dossier temporaire du process), puis nettoie ce dossier en fin de run
- purge explicitement les balises techniques source `ENCODER` et `CREATION_TIME` avant écriture des métadonnées de sortie
- n'écrit plus le tag libre `MUXING_APPLICATION` via `-metadata`
Expand Down Expand Up @@ -291,7 +299,7 @@ Le panneau **Paramètres** est un éditeur complet de `config.ini` intégré à
- **Remux** : backend `ffmpeg` (nominal)
- **Outils externes** : chemins explicites pour chaque outil (`ffmpeg`, `ffprobe`, `mediainfo`, `dovi_tool`, `hdr10plus_tool`, etc.)
- **Encodage** : profil DoVi, compat-id, buffer RAM
- **Métadonnées** : auth TMDB via clé API v3 (`tmdb_api_key`) ou token Bearer v4 (`tmdb_bearer_token`)
- **Métadonnées** : auth TMDB via clé API v3 (`tmdb_api_key`) ou token Bearer v4 (`tmdb_bearer_token`), génération optionnelle de `.nfo` (`generate_nfo`)

Les changements sont appliqués section par section ou en une seule fois via le bouton **Sauvegarder toute la configuration**. Un rechargement depuis `config.ini` est possible sans redémarrer l'application.

Expand Down Expand Up @@ -338,6 +346,7 @@ Sous Windows, `setup.py` et le démarrage de l'application peuvent auto-détecte
| `backend` (section `[remux]`) | `ffmpeg` | backend de remux (`ffmpeg`) |
| `tmdb_api_key` | vide | clé API TMDB v3 utilisée par la recherche IMDb/TMDB |
| `tmdb_bearer_token` | vide | token Bearer TMDB v4 (utilisé si `tmdb_api_key` est vide, ou via `MEDIARECODE_TMDB_BEARER_TOKEN`) |
| `generate_nfo` | `true` | génère un fichier `.nfo` MediaInfo à côté du MKV final après workflow réussi |
| `ram_buffer_enabled` | `true` | autorise l'usage de `/dev/shm` pour les HEVC intermédiaires si disponible |
| `ram_buffer_threshold_pct` | `15` | pourcentage minimal de RAM libre à conserver pour activer ce buffer |

Expand Down Expand Up @@ -392,6 +401,7 @@ startup_panel = container
[metadata]
tmdb_api_key = <VOTRE_CLE_API_TMDB_V3>
tmdb_bearer_token = <VOTRE_TOKEN_BEARER_TMDB_V4>
generate_nfo = true
```

## Workflows
Expand Down Expand Up @@ -534,11 +544,11 @@ flowchart TD
|-------|----------------|
| `ffprobe` | analyse des flux, chapitres et métadonnées |
| `mediainfo` | frame count et informations HDR fines |
| `ffmpeg` | encodage, remux, copie de flux, écriture metadata/chapitres/tags, patch binaire MuxingApp |
| `ffmpeg` | encodage, remux, copie de flux, extraction de sous-titres, écriture metadata/chapitres/tags, patch binaire MuxingApp |
| `dovi_tool` | extraction, injection et vérification Dolby Vision |
| `hdr10plus_tool` | extraction et injection HDR10+ |
| `nvidia-smi` | fallback de détection NVENC sous Linux |

---

*Mediarecode v1.3*
*Mediarecode v1.4.x*
99 changes: 99 additions & 0 deletions core/extractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""
core/extractor.py — Extraction de pistes individuelles vers des fichiers autonomes.

Centralise la logique d'extraction. Pour l'instant : sous-titres uniquement.
Extensible plus tard à l'audio et la vidéo via des méthodes dédiées.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path

from core.subtitle_codec import CONVERT_TO_SRT, MKV_COPY_SAFE, UNSUPPORTED


@dataclass(frozen=True)
class ExtractPlan:
"""Plan d'extraction d'une piste vers un fichier autonome."""

codec_arg: str # argument -c:s / -c:a / -c:v
extension: str # extension avec point (".srt")
format_label: str # libellé affiché à l'utilisateur
file_filter: str # filtre QFileDialog
extra_args: tuple[str, ...] = field(default_factory=tuple)


class TrackExtractor:
"""Construit les plans et commandes ffmpeg d'extraction de pistes."""

_SUBTITLE_PLANS: dict[str, ExtractPlan] = {
"subrip": ExtractPlan("copy", ".srt", "SubRip", "SubRip (*.srt)"),
"srt": ExtractPlan("copy", ".srt", "SubRip", "SubRip (*.srt)"),
"ass": ExtractPlan("copy", ".ass", "ASS", "SSA/ASS (*.ass *.ssa)"),
"ssa": ExtractPlan("copy", ".ssa", "SSA", "SSA/ASS (*.ass *.ssa)"),
"webvtt": ExtractPlan("copy", ".vtt", "WebVTT", "WebVTT (*.vtt)"),
"hdmv_pgs_subtitle": ExtractPlan("copy", ".sup", "PGS", "PGS (*.sup)"),
"dvd_subtitle": ExtractPlan("copy", ".idx", "VobSub", "VobSub (*.idx *.sub)"),
"hdmv_text_subtitle": ExtractPlan("srt", ".srt", "SubRip", "SubRip (*.srt)"),
}

_SRT_PLAN = ExtractPlan("srt", ".srt", "SubRip", "SubRip (*.srt)")

@classmethod
def plan_subtitle(cls, codec: str) -> ExtractPlan:
"""Retourne le plan d'extraction pour un codec de sous-titre.

Lève ``ValueError`` pour les codecs explicitement non supportés
(DVB, teletext, etc.) qui requièrent un OCR ou un outil externe.
"""
c = (codec or "").lower().strip()

if c in UNSUPPORTED:
raise ValueError(
f"Codec de sous-titre '{codec}' non extractible nativement "
"(nécessite OCR ou outil externe)."
)
if c in cls._SUBTITLE_PLANS:
return cls._SUBTITLE_PLANS[c]
if c in CONVERT_TO_SRT:
return cls._SRT_PLAN
if c in MKV_COPY_SAFE:
return ExtractPlan("copy", ".mks", f"{codec} (brut)", "Tous les fichiers (*)")
# Inconnu : on tente copy avec extension générique
return ExtractPlan("copy", ".bin", f"{codec} (brut)", "Tous les fichiers (*)")

@classmethod
def build_subtitle_command(
cls,
ffmpeg_bin: str,
source: Path,
stream_index: int,
codec: str,
output: Path,
) -> list[str]:
"""Construit la commande ffmpeg pour extraire un sous-titre."""
plan = cls.plan_subtitle(codec)
return [
ffmpeg_bin,
"-y",
"-i", str(source),
"-map", f"0:{stream_index}",
"-c:s", plan.codec_arg,
*plan.extra_args,
str(output),
]

@staticmethod
def default_output_name(
source_stem: str,
language: str,
stream_index: int,
extension: str,
) -> str:
"""Nom de fichier par défaut pour la boîte de dialogue : stem.lang.tN.ext"""
lang = (language or "und").strip() or "und"
return f"{source_stem}.{lang}.t{stream_index}{extension}"


__all__ = ["ExtractPlan", "TrackExtractor"]
1 change: 1 addition & 0 deletions core/workflows/encode/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ class AudioTrackSettings:
input_channels: int | None = None # nb de canaux de la piste source (ffprobe)
input_channel_layout: str | None = None # layout source (ex: "7.1", "5.1(side)")
source_path: Path | None = None # None = même fichier que la vidéo (config.source)
track_entry_id: str | None = None # GUID de l'objet TrackEntry synchronisé entre panels


@dataclass
Expand Down
50 changes: 41 additions & 9 deletions core/workflows/remux.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,13 @@ def write_mediainfo_nfo(
mediainfo_bin: str = "mediainfo",
) -> None:
"""Génère un fichier .nfo (même nom que le MKV) avec la sortie brute de mediainfo."""
output_path = Path(output_path).expanduser()
if not output_path.is_absolute():
output_path = output_path.resolve()
nfo_path = output_path.with_suffix(".nfo")
try:
result = subprocess.run(
[mediainfo_bin, _cli_path(output_path)],
[mediainfo_bin, str(output_path)],
capture_output=True,
**subprocess_text_kwargs(),
)
Expand Down Expand Up @@ -313,18 +316,27 @@ def validate(self, config: RemuxConfig) -> list[str]:
if not config.track_order:
errors.append("Aucune piste sélectionnée.")

track_map = {
(src.file_index, t.mkv_tid): t
track_map_by_id = {
(src.file_index, t.entry_id): t
for src in config.sources
for t in src.tracks
}
track_map_by_pair: dict[tuple[int, int], list[TrackEntry]] = {}
for src in config.sources:
for track in src.tracks:
track_map_by_pair.setdefault((src.file_index, track.mkv_tid), []).append(track)
valid_file_indexes = {src.file_index for src in config.sources}

for file_index, mkv_tid in config.track_order:
for order_item in config.track_order:
file_index, mkv_tid, entry_id = self._track_order_parts(order_item)
if file_index not in valid_file_indexes:
errors.append(f"track_order référence une source inconnue : file_index={file_index}")
continue
track = track_map.get((file_index, mkv_tid))
track = (
track_map_by_id.get((file_index, entry_id))
if entry_id
else next(iter(track_map_by_pair.get((file_index, mkv_tid), [])), None)
)
if track is None:
errors.append(
"track_order référence une piste introuvable : "
Expand Down Expand Up @@ -894,21 +906,32 @@ def _resolve_mapped_tracks(self, config: RemuxConfig) -> list[_MappedTrack]:
for i, src in enumerate(config.sources)
}

track_map = {
(src.file_index, t.mkv_tid): (src.path, t)
track_map_by_id = {
(src.file_index, t.entry_id): (src.path, t)
for src in config.sources
for t in src.tracks
}
track_map_by_pair: dict[tuple[int, int], list[tuple[Path, TrackEntry]]] = {}
for src in config.sources:
for track in src.tracks:
track_map_by_pair.setdefault((src.file_index, track.mkv_tid), []).append(
(src.path, track)
)

type_counters: dict[str, int] = {"video": 0, "audio": 0, "subtitle": 0}
mapped: list[_MappedTrack] = []

for file_index, mkv_tid in config.track_order:
for order_item in config.track_order:
file_index, mkv_tid, entry_id = self._track_order_parts(order_item)
input_idx = file_index_to_input_idx.get(file_index)
if input_idx is None:
raise RemuxError(f"Source inconnue dans track_order : file_index={file_index}")

found = track_map.get((file_index, mkv_tid))
found = (
track_map_by_id.get((file_index, entry_id))
if entry_id
else next(iter(track_map_by_pair.get((file_index, mkv_tid), [])), None)
)
if found is None:
raise RemuxError(
"Piste introuvable dans track_order : "
Expand All @@ -934,6 +957,15 @@ def _resolve_mapped_tracks(self, config: RemuxConfig) -> list[_MappedTrack]:

return mapped

@staticmethod
def _track_order_parts(
item: tuple[int, int] | tuple[int, int, str],
) -> tuple[int, int, str | None]:
if len(item) >= 3:
file_index, mkv_tid, entry_id = item[0], item[1], str(item[2] or "").strip()
return int(file_index), int(mkv_tid), entry_id or None
return int(item[0]), int(item[1]), None

@staticmethod
def _chapter_map_value(config: RemuxConfig, chapter_input_index: int | None) -> str:
if config.chapter_overrides is not None:
Expand Down
55 changes: 51 additions & 4 deletions core/workflows/remux_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@

from __future__ import annotations

from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path
from uuid import uuid4

from core.inspector import AttachmentInfo, FileInfo, HDRType

Expand Down Expand Up @@ -41,9 +42,14 @@ class TrackEntry:
enabled: bool = True
file_id: str = "" # UUID du SourceFile parent (usage UI uniquement)
time_shift_ms: int = 0 # décalage signé appliqué à la piste (ms)
entry_id: str = field(default_factory=lambda: uuid4().hex)
source_entry_id: str = field(default="", repr=False)
is_new: bool = field(default=False, repr=False)

orig_language: str = field(default="", repr=False)
orig_title: str = field(default="", repr=False)
orig_codec: str = field(default="", repr=False)
orig_display_info: str = field(default="", repr=False)

# Flags MKV éditables (transmis à FFmpeg si modifiés)
flag_enabled: bool = field(default=True, repr=False) # --track-enabled-flag
Expand All @@ -62,6 +68,12 @@ class TrackEntry:
orig_flag_original: bool = field(default=False, repr=False)
orig_flag_commentary: bool = field(default=False, repr=False)

def __post_init__(self) -> None:
if not self.orig_codec:
self.orig_codec = self.codec
if not self.orig_display_info:
self.orig_display_info = self.display_info

@property
def flags_label(self) -> str:
"""Résumé court des flags actifs (pour la colonne Info du tableau)."""
Expand All @@ -85,7 +97,16 @@ def flags_label(self) -> str:
@property
def full_info_label(self) -> str:
"""Info technique + flags actifs (affichage colonne Info)."""
parts = [p for p in (self.display_info, self.flags_label, self.time_shift_label) if p]
parts = [
p
for p in (
"NEW" if self.is_new else "",
self.display_info,
self.flags_label,
self.time_shift_label,
)
if p
]
return " · ".join(parts)

@property
Expand Down Expand Up @@ -164,14 +185,16 @@ class RemuxConfig:
Configuration complète d'un remuxage multi-source.

sources : liste ordonnée des fichiers source (chacun avec ses pistes).
track_order : liste de (file_index, mkv_tid) dans l'ordre désiré en sortie.
track_order : liste de (file_index, mkv_tid[, entry_id]) dans l'ordre désiré
en sortie. ``entry_id`` permet de distinguer deux lignes UI
basées sur la même piste source.
Seules les pistes présentes dans track_order sont incluses.
extra_attachments : fichiers externes à attacher en plus (--attach-file).
"""

sources: list[SourceInput]
output: Path
track_order: list[tuple[int, int]] # (file_index, mkv_tid) ordonnés
track_order: list[tuple[int, int] | tuple[int, int, str]]
keep_chapters: bool = True
#: None → FFmpeg recopie les chapitres des sources (comportement par défaut).
#: list → un fichier ffmetadata temporaire est généré depuis ces entrées ;
Expand Down Expand Up @@ -300,3 +323,27 @@ def _flags_from_disp(raw: dict) -> dict:
))

return entries


def clone_track_entry(
entry: TrackEntry,
*,
entry_id: str | None = None,
) -> TrackEntry:
"""Clone une piste pour en faire une entrée UI indépendante."""
source_entry_id = entry.source_entry_id or entry.entry_id
return replace(
entry,
entry_id=entry_id or uuid4().hex,
source_entry_id=source_entry_id,
is_new=True,
orig_language=entry.language,
orig_title=entry.title,
orig_flag_enabled=entry.flag_enabled,
orig_flag_default=entry.flag_default,
orig_flag_forced=entry.flag_forced,
orig_flag_hearing_impaired=entry.flag_hearing_impaired,
orig_flag_visual_impaired=entry.flag_visual_impaired,
orig_flag_original=entry.flag_original,
orig_flag_commentary=entry.flag_commentary,
)
Loading
Loading