mmu-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. mixed_media_utility/__init__.py +1 -0
  2. mixed_media_utility/cadence_previz.py +1997 -0
  3. mixed_media_utility/cli.py +5325 -0
  4. mixed_media_utility/codec_profiles.py +2373 -0
  5. mixed_media_utility/color_calibration.py +4303 -0
  6. mixed_media_utility/color_metrics.py +1314 -0
  7. mixed_media_utility/color_pipeline.py +388 -0
  8. mixed_media_utility/constants.py +6 -0
  9. mixed_media_utility/declaration_de_rush.py +1335 -0
  10. mixed_media_utility/detection/__init__.py +35 -0
  11. mixed_media_utility/detection/aruco.py +463 -0
  12. mixed_media_utility/encode.py +3204 -0
  13. mixed_media_utility/encode_master.py +724 -0
  14. mixed_media_utility/encode_previz.py +1976 -0
  15. mixed_media_utility/extraction.py +1723 -0
  16. mixed_media_utility/extraction_previz.py +1002 -0
  17. mixed_media_utility/ffmpeg_utils.py +629 -0
  18. mixed_media_utility/frame_selection.py +994 -0
  19. mixed_media_utility/gamut_map.py +393 -0
  20. mixed_media_utility/gui/__init__.py +6 -0
  21. mixed_media_utility/gui/__main__.py +102 -0
  22. mixed_media_utility/gui/application.py +125 -0
  23. mixed_media_utility/gui/atelier_scan.py +639 -0
  24. mixed_media_utility/gui/barre_de_vue.py +310 -0
  25. mixed_media_utility/gui/cartes_taches.py +214 -0
  26. mixed_media_utility/gui/catalogue.py +399 -0
  27. mixed_media_utility/gui/chargeur_detections.py +119 -0
  28. mixed_media_utility/gui/chutier.py +1153 -0
  29. mixed_media_utility/gui/coquille.py +1384 -0
  30. mixed_media_utility/gui/depot_projets.py +266 -0
  31. mixed_media_utility/gui/ecran_projet.py +958 -0
  32. mixed_media_utility/gui/executeur.py +193 -0
  33. mixed_media_utility/gui/feuille_application.py +257 -0
  34. mixed_media_utility/gui/jetons.py +432 -0
  35. mixed_media_utility/gui/lecture_detection.py +431 -0
  36. mixed_media_utility/gui/modele_chutier.py +680 -0
  37. mixed_media_utility/gui/modele_projets.py +279 -0
  38. mixed_media_utility/gui/modele_zone_tampon.py +478 -0
  39. mixed_media_utility/gui/preferences_projets.py +98 -0
  40. mixed_media_utility/gui/raster_de_page.py +153 -0
  41. mixed_media_utility/gui/scan_jugement.py +1351 -0
  42. mixed_media_utility/gui/surimpressions.py +520 -0
  43. mixed_media_utility/gui/zone_tampon.py +587 -0
  44. mixed_media_utility/io/__init__.py +67 -0
  45. mixed_media_utility/io/calibration_profile.py +1316 -0
  46. mixed_media_utility/io/encode_manifest.py +940 -0
  47. mixed_media_utility/io/extraction_manifest.py +2018 -0
  48. mixed_media_utility/io/manifest.py +319 -0
  49. mixed_media_utility/io/metadata_matrix.py +237 -0
  50. mixed_media_utility/io/naming.py +1516 -0
  51. mixed_media_utility/io/payload.py +1334 -0
  52. mixed_media_utility/io/pdf_manifest.py +483 -0
  53. mixed_media_utility/io/profile_designation.py +478 -0
  54. mixed_media_utility/io/project_layout.py +660 -0
  55. mixed_media_utility/io/reconstruction.py +1120 -0
  56. mixed_media_utility/io/scan_manifest.py +2216 -0
  57. mixed_media_utility/io/version_ranks.py +164 -0
  58. mixed_media_utility/layout.py +313 -0
  59. mixed_media_utility/makepdf.py +1097 -0
  60. mixed_media_utility/numeric_guards.py +44 -0
  61. mixed_media_utility/page_payload.py +360 -0
  62. mixed_media_utility/page_roles.py +149 -0
  63. mixed_media_utility/page_templates.py +2701 -0
  64. mixed_media_utility/patch_presets.py +1599 -0
  65. mixed_media_utility/patch_values.py +1018 -0
  66. mixed_media_utility/pdf_composition.py +2763 -0
  67. mixed_media_utility/pdf_previz.py +1017 -0
  68. mixed_media_utility/pdf_render.py +417 -0
  69. mixed_media_utility/previz_common.py +361 -0
  70. mixed_media_utility/progression.py +340 -0
  71. mixed_media_utility/project_inventory.py +1213 -0
  72. mixed_media_utility/project_maintenance.py +2995 -0
  73. mixed_media_utility/qr_codes.py +920 -0
  74. mixed_media_utility/relink.py +634 -0
  75. mixed_media_utility/scan_calibrate.py +855 -0
  76. mixed_media_utility/scan_chain.py +240 -0
  77. mixed_media_utility/scan_corrections.py +1166 -0
  78. mixed_media_utility/scan_crop.py +481 -0
  79. mixed_media_utility/scan_detect.py +1395 -0
  80. mixed_media_utility/scan_detection.py +1432 -0
  81. mixed_media_utility/scan_ingest.py +1974 -0
  82. mixed_media_utility/scan_output_frames.py +2238 -0
  83. mixed_media_utility/scan_previz.py +3113 -0
  84. mixed_media_utility/scan_sorting.py +825 -0
  85. mixed_media_utility/scan_write.py +2298 -0
  86. mixed_media_utility/source_confirmation.py +1225 -0
  87. mixed_media_utility/specs/project.schema.json +999 -0
  88. mixed_media_utility/specs/project.schema.legacy.json +52 -0
  89. mixed_media_utility/specs/project.schema.v2-0.json +323 -0
  90. mixed_media_utility/video_metadata.py +416 -0
  91. mmu_cli-0.1.0.dist-info/METADATA +217 -0
  92. mmu_cli-0.1.0.dist-info/RECORD +96 -0
  93. mmu_cli-0.1.0.dist-info/WHEEL +4 -0
  94. mmu_cli-0.1.0.dist-info/entry_points.txt +2 -0
  95. mmu_cli-0.1.0.dist-info/licenses/LICENSE +674 -0
  96. mmu_cli-0.1.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +72 -0
@@ -0,0 +1,2373 @@
1
+ """FFmpeg encode profile catalog for the `encode` step (story 6.2).
2
+
3
+ Centralizes the codec/profile/container choices in one place instead of the
4
+ CLI, so `cli.py`'s `encode` skeleton (and the future real implementation) can
5
+ select a profile by id and build both the FFmpeg command and the manifest
6
+ representation from the same source of truth.
7
+
8
+ Per ARCHITECTURE.md / ARCHITECTURE_DETAILED.md section 11-12:
9
+ - ProRes MOV is the default mezzanine master.
10
+ - DNxHR MOV and delivery derivatives (H.264/H.265) are secondary, explicit,
11
+ never implicit.
12
+ - The manifest must be able to express codec, profile, container, target
13
+ colorspace and encode constraints (this module's ``manifest_fields``).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import functools
20
+ import logging
21
+ import os
22
+ import re
23
+ import shutil
24
+ import subprocess
25
+ import tempfile
26
+ import time
27
+ import uuid
28
+ from collections.abc import Iterator, Sequence
29
+ from dataclasses import dataclass, field
30
+ from fractions import Fraction
31
+ from pathlib import Path
32
+
33
+ from . import progression
34
+ from .numeric_guards import is_strict_int, is_strict_number
35
+
36
+ _JOURNAL = logging.getLogger(__name__)
37
+
38
+ #: La cible de `-progress` quand le canal est ouvert. `pipe:1` est la sortie
39
+ #: **standard du sous-processus**, jamais celle du notre : `run_encode` la lit
40
+ #: par `Popen(stdout=PIPE)` et rien n'atteint le terminal.
41
+ #:
42
+ #: Elle vaut la sortie standard et non un fichier parce que le fichier
43
+ #: imposerait une scrutation a intervalle, la ou le tube donne les jalons a la
44
+ #: cadence de ffmpeg lui-meme -- et `stderr` allant, lui, dans un fichier, le
45
+ #: tube de sortie standard ne peut pas bloquer.
46
+ PROGRESS_TARGET_STDOUT = "pipe:1"
47
+
48
+ #: La SEULE cle du flux `-progress` que ce module lit, et la plus stable du
49
+ #: format. Tout le reste -- `out_time`, `speed`, `bitrate` -- est ignore.
50
+ PROGRESS_FRAME_KEY = "frame"
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class EncodeProfile:
55
+ """A named, concrete FFmpeg encode profile."""
56
+
57
+ profile_id: str
58
+ category: str # "primary" (mezzanine master) or "secondary" (delivery/alt)
59
+ container: str # file extension, e.g. "mov", "mp4"
60
+ vcodec: str # FFmpeg -c:v value (the *encoder*: prores_ks, libx264, ...)
61
+ probe_codec_name: str # what ffprobe reports back (prores, h264, ...)
62
+ pix_fmt: str
63
+ colorspace: str # target colorspace tag, e.g. "bt709"
64
+ extra_args: tuple[str, ...] = field(default_factory=tuple)
65
+ notes: str = ""
66
+
67
+ def ffmpeg_video_args(self) -> list[str]:
68
+ """Return the FFmpeg video-encode arguments (without -i/input/-o/output).
69
+
70
+ All three color tags (-colorspace, -color_primaries, -color_trc) are
71
+ set explicitly and identically: story 6.2/6.3 experiments showed that
72
+ prores_ks only reliably tags `color_space` in the output container
73
+ when all three are set together — `-colorspace` alone is not enough
74
+ for ProRes, even though it is sufficient for libx264/libx265/dnxhd.
75
+ Setting all three everywhere keeps the profiles consistent and
76
+ externally verifiable regardless of codec quirks.
77
+ """
78
+ args = [
79
+ "-c:v", self.vcodec,
80
+ "-pix_fmt", self.pix_fmt,
81
+ "-colorspace", self.colorspace,
82
+ "-color_primaries", self.colorspace,
83
+ "-color_trc", self.colorspace,
84
+ ]
85
+ args.extend(self.extra_args)
86
+ return args
87
+
88
+ def manifest_fields(self) -> dict:
89
+ """Return the manifest-ready representation of this profile."""
90
+ return {
91
+ "codec": self.vcodec,
92
+ "probe_codec_name": self.probe_codec_name,
93
+ "profile": self.profile_id,
94
+ "container": self.container,
95
+ "target_colorspace": self.colorspace,
96
+ "category": self.category,
97
+ }
98
+
99
+
100
+ # ProRes profile numbers for FFmpeg's prores_ks encoder:
101
+ # 0=proxy, 1=LT, 2=standard (422), 3=HQ, 4=4444, 5=4444XQ.
102
+ PROFILES: dict[str, EncodeProfile] = {
103
+ "prores_hq": EncodeProfile(
104
+ profile_id="prores_hq",
105
+ category="primary",
106
+ container="mov",
107
+ vcodec="prores_ks",
108
+ probe_codec_name="prores",
109
+ pix_fmt="yuv422p10le",
110
+ colorspace="bt709",
111
+ extra_args=("-profile:v", "3", "-vendor", "apl0"),
112
+ notes="Master mezzanine par defaut (ProRes 422 HQ). Choix retenu pour equilibrer qualite/poids.",
113
+ ),
114
+ "prores_422": EncodeProfile(
115
+ profile_id="prores_422",
116
+ category="secondary",
117
+ container="mov",
118
+ vcodec="prores_ks",
119
+ probe_codec_name="prores",
120
+ pix_fmt="yuv422p10le",
121
+ colorspace="bt709",
122
+ extra_args=("-profile:v", "2", "-vendor", "apl0"),
123
+ notes="Variante ProRes 422 standard, plus legere que HQ, usage secondaire explicite.",
124
+ ),
125
+ "prores_lt": EncodeProfile(
126
+ profile_id="prores_lt",
127
+ category="secondary",
128
+ container="mov",
129
+ vcodec="prores_ks",
130
+ probe_codec_name="prores",
131
+ pix_fmt="yuv422p10le",
132
+ colorspace="bt709",
133
+ extra_args=("-profile:v", "1", "-vendor", "apl0"),
134
+ notes="Variante ProRes LT, plus legere encore, reservee aux previews/echanges rapides.",
135
+ ),
136
+ "dnxhr_hq": EncodeProfile(
137
+ profile_id="dnxhr_hq",
138
+ category="secondary",
139
+ container="mov",
140
+ vcodec="dnxhd",
141
+ probe_codec_name="dnxhd",
142
+ pix_fmt="yuv422p",
143
+ colorspace="bt709",
144
+ extra_args=("-profile:v", "dnxhr_hq",),
145
+ notes=(
146
+ "Alternative DNxHR HQ pour cibles post-production non-Apple. "
147
+ "8 bits/422 (yuv422p): confirme empiriquement (story 6.2) que le "
148
+ "profil HQ standard de dnxhd/ffmpeg refuse yuv422p10le (reserve a "
149
+ "HQX/444); ne pas changer ce pix_fmt sans re-tester."
150
+ ),
151
+ ),
152
+ "dnxhr_hqx": EncodeProfile(
153
+ profile_id="dnxhr_hqx",
154
+ category="secondary",
155
+ container="mov",
156
+ vcodec="dnxhd",
157
+ probe_codec_name="dnxhd",
158
+ pix_fmt="yuv422p10le",
159
+ colorspace="bt709",
160
+ extra_args=("-profile:v", "dnxhr_hqx",),
161
+ notes=(
162
+ "Variante 10 bits/422 de DNxHR (profil Avid HQX), pour les cibles "
163
+ "post-production qui exigent explicitement du 10 bits sans passer "
164
+ "par ProRes. A utiliser plutot que de forcer yuv422p10le sur HQ."
165
+ ),
166
+ ),
167
+ "h264_delivery": EncodeProfile(
168
+ profile_id="h264_delivery",
169
+ category="secondary",
170
+ container="mp4",
171
+ vcodec="libx264",
172
+ probe_codec_name="h264",
173
+ pix_fmt="yuv420p",
174
+ colorspace="bt709",
175
+ extra_args=("-crf", "18", "-preset", "slow"),
176
+ notes="Derive de diffusion H.264, jamais utilise comme master.",
177
+ ),
178
+ "hevc_delivery": EncodeProfile(
179
+ profile_id="hevc_delivery",
180
+ category="secondary",
181
+ container="mp4",
182
+ vcodec="libx265",
183
+ probe_codec_name="hevc",
184
+ pix_fmt="yuv420p10le",
185
+ colorspace="bt709",
186
+ extra_args=("-crf", "20", "-preset", "slow"),
187
+ notes="Derive de diffusion H.265, jamais utilise comme master.",
188
+ ),
189
+ }
190
+
191
+ DEFAULT_PROFILE_ID = "prores_hq"
192
+
193
+
194
+ def get_profile(profile_id: str) -> EncodeProfile:
195
+ """Return the ``EncodeProfile`` for ``profile_id``, raising ``KeyError``
196
+ with the list of valid ids if unknown."""
197
+ try:
198
+ return PROFILES[profile_id]
199
+ except KeyError as exc:
200
+ raise KeyError(
201
+ f"Profil d'encode inconnu: {profile_id!r}. Profils valides: {sorted(PROFILES)}"
202
+ ) from exc
203
+
204
+
205
+ # Broadcast rates whose exact value is a ratio, keyed by the rounded decimal a
206
+ # manifest realistically stores. `str(23.976)` hands ffmpeg a decimal it turns
207
+ # into an arbitrary rational (2997/125 instead of 24000/1001), producing a
208
+ # master with a non-standard time base that drifts in edit and breaks timecode
209
+ # conformance. Only the full-precision float round-trips; the rounded one does
210
+ # not, and the rounded one is the nominal case.
211
+ _EXACT_RATES = {
212
+ Fraction(24000, 1001): (23.976, 23.98),
213
+ Fraction(30000, 1001): (29.97,),
214
+ Fraction(60000, 1001): (59.94,),
215
+ Fraction(48000, 1001): (47.952,),
216
+ Fraction(120000, 1001): (119.88,),
217
+ }
218
+ NTSC_RATES = tuple(_EXACT_RATES)
219
+
220
+ # Le champ `ff` n'est **pas** de largeur fixe: ffmpeg l'ecrit sur
221
+ # `len(str(ceil(fps) - 1))` chiffres (voir `timecode_frame_field_width`). La
222
+ # borne haute de 9 chiffres n'a aucune valeur semantique -- c'est la borne
223
+ # semantique `ff < ceil(fps)` qui refuse, dans `validate_timecode`, avec un
224
+ # message qui nomme la cadence. Elle est la pour qu'une chaine absurde ne parte
225
+ # pas dans `int()`, dont CPython refuse les litteraux de plus de 4300 chiffres
226
+ # par une `ValueError` hors de la famille documentee ici.
227
+ #
228
+ # `\Z` et non `$`: en Python, `$` accepte un saut de ligne **final**, si bien
229
+ # que `"00:00:00:04\n"` passait la grammaire et repartait tel quel dans l'argv
230
+ # d'ffmpeg -- mesure du 2026-08-10, trou preexistant trouve en ecrivant le
231
+ # temoin de non-regression de cette porte. `\Z` ne colle qu'a la fin reelle.
232
+ _TIMECODE_RE = re.compile(r"^(\d{2}):(\d{2}):(\d{2})([:;])(\d{1,9})\Z")
233
+
234
+
235
+ def exact_frame_rate(fps: float | int | str | Fraction) -> str:
236
+ """Return ``fps`` as an exact ``num/den`` string for FFmpeg.
237
+
238
+ Accepts a rational string (``"24000/1001"``) verbatim, and snaps the
239
+ rounded NTSC decimals a manifest stores (23.976, 29.97, ...) back onto
240
+ their exact ratio instead of letting FFmpeg guess.
241
+ """
242
+ if isinstance(fps, str):
243
+ value = Fraction(fps)
244
+ elif isinstance(fps, Fraction):
245
+ value = fps
246
+ elif not is_strict_number(fps):
247
+ raise TypeError(f"fps doit etre un nombre, une Fraction ou 'num/den': {fps!r}")
248
+ else:
249
+ # NaN raises ValueError and inf raises OverflowError out of Fraction,
250
+ # the latter escaping a caller that catches ValueError as documented.
251
+ # A corrupt manifest fps is exactly how one gets here.
252
+ if fps != fps or fps in (float("inf"), float("-inf")):
253
+ raise ValueError(f"fps non fini: {fps!r}")
254
+ value = Fraction(fps).limit_denominator(1000000)
255
+ for exact, rounded in _EXACT_RATES.items():
256
+ if float(fps) in rounded or abs(float(fps) - float(exact)) < 1e-6:
257
+ value = exact
258
+ break
259
+ if value <= 0:
260
+ raise ValueError(f"fps doit etre strictement positif: {fps!r}")
261
+ return f"{value.numerator}/{value.denominator}"
262
+
263
+
264
+ @dataclass(frozen=True)
265
+ class TimecodePosition:
266
+ """L'image que **designe** un timecode SMPTE, largeur d'ecriture exclue.
267
+
268
+ Deux timecodes designent la meme image si et seulement si leurs cinq champs
269
+ coincident. La largeur sur laquelle ``ff`` est ecrit n'en fait
270
+ volontairement pas partie: c'est tout l'objet de la correction du
271
+ 2026-08-10 (voir ``timecode_frame_field_width``). ``drop_frame`` en fait
272
+ partie en revanche -- ``00:00:00;04`` et ``00:00:00:04`` sont deux
273
+ timelines differentes, pas deux ecritures de la meme.
274
+ """
275
+
276
+ hours: int
277
+ minutes: int
278
+ seconds: int
279
+ frames: int
280
+ drop_frame: bool
281
+
282
+
283
+ #: Une journee de timecode, en secondes. `hh:mm:ss:ff` ne porte pas de jour :
284
+ #: au-dela de 23 h le compteur reboucle, et `validate_timecode` refuse tout ce
285
+ #: qui deborde. Un rush de vingt minutes demarrant a `23:50:00:00` en depend.
286
+ _SECONDS_PER_JOUR = 24 * 3600
287
+
288
+
289
+ def _frame_limit(rate: Fraction) -> int:
290
+ """Borne stricte du champ ``ff`` a ``rate``: ``ceil(rate)``.
291
+
292
+ C'est la convention d'ffmpeg, mesuree: a 12,5 im/s le champ compte jusqu'a
293
+ 12, et ``00:00:00:13`` est absorbe dans la seconde suivante.
294
+ """
295
+ return -(-rate.numerator // rate.denominator) # ceil, for 23.976 -> 24
296
+
297
+
298
+ def timecode_frame_field_width(fps: float | int | str | Fraction) -> int:
299
+ """Nombre de chiffres sur lequel ffmpeg **ecrit** le champ ``ff`` a ``fps``.
300
+
301
+ Mesure du 2026-08-10 (ffmpeg 6.1.1-3ubuntu5, ProRes en MOV, ``tmcd`` relu a
302
+ ffprobe), et non une inference: ffmpeg n'ecrit pas ``ff`` sur une largeur
303
+ fixe, il l'ecrit sur ``len(str(ceil(fps) - 1))`` chiffres -- la largeur de
304
+ la plus grande valeur que le champ puisse prendre a cette cadence.
305
+
306
+ Pour un meme ``-timecode 00:00:00:04`` demande:
307
+
308
+ | cadence | ceil | ``tmcd`` relu |
309
+ | --- | --- | --- |
310
+ | 5 | 5 | ``00:00:00:4`` |
311
+ | 10 | 10 | ``00:00:00:4`` |
312
+ | 12,5 | 13 | ``00:00:00:04`` |
313
+ | 15 | 15 | ``00:00:00:04`` |
314
+ | 25 | 25 | ``00:00:00:04`` |
315
+ | 100 | 100 | ``00:00:00:04`` |
316
+ | 120 | 120 | ``00:00:00:004``|
317
+
318
+ Les champs ``hh``, ``mm`` et ``ss`` restent, eux, sur deux chiffres a toutes
319
+ ces cadences. Hors de la fenetre ``11 <= ceil(fps) <= 100``, le timecode
320
+ relu s'ecrit donc autrement que celui demande **sans que rien ne soit
321
+ faux**, et les cadences reelles de ce depot (1, 5, 10, 12,5 et 15 im/s) sont
322
+ majoritairement hors de cette fenetre.
323
+ """
324
+ return len(str(_frame_limit(Fraction(exact_frame_rate(fps))) - 1))
325
+
326
+
327
+ def timecode_position(value: object) -> TimecodePosition | None:
328
+ """Decompose ``value`` en l'image qu'il designe, ou rend ``None``.
329
+
330
+ Rend ``None`` -- jamais une exception -- sur tout ce qui n'est pas un
331
+ timecode: ``None``, un entier, ``"N/A"``, une chaine vide. C'est la forme
332
+ dont a besoin la relecture d'un fichier produit, ou l'absence de timecode
333
+ est un resultat possible et non une panne du lecteur.
334
+
335
+ La largeur du champ ``ff`` est **absorbee** ici: ``00:00:00:4``,
336
+ ``00:00:00:04`` et ``00:00:00:004`` rendent la meme position. Aucune
337
+ validation de cadence n'est faite -- ``timecode_position`` ne sait pas a
338
+ quelle cadence le timecode se lit, c'est ``validate_timecode`` qui le sait.
339
+ """
340
+ if not isinstance(value, str):
341
+ return None
342
+ match = _TIMECODE_RE.match(value)
343
+ if match is None:
344
+ return None
345
+ return _position_from_match(match)
346
+
347
+
348
+ def _position_from_match(match: re.Match) -> TimecodePosition:
349
+ """Construit la position depuis un `match` de ``_TIMECODE_RE``.
350
+
351
+ Point de decodage **unique**: `validate_timecode` a besoin du `match` pour
352
+ mesurer la largeur d'ecriture, `timecode_position` n'a besoin que de la
353
+ position. Les faire decoder deux fois la meme chaine ouvrirait deux
354
+ conventions divergentes pour le meme format.
355
+ """
356
+ hours, minutes, seconds, separator, frames = match.groups()
357
+ return TimecodePosition(
358
+ hours=int(hours),
359
+ minutes=int(minutes),
360
+ seconds=int(seconds),
361
+ frames=int(frames),
362
+ drop_frame=separator == ";",
363
+ )
364
+
365
+
366
+ def timecodes_equivalent(first: object, second: object) -> bool:
367
+ """Dit si ``first`` et ``second`` designent la **meme image**.
368
+
369
+ C'est la confrontation a faire entre un timecode demande et le timecode
370
+ relu d'un master: comparer les deux chaines declare non conforme un master
371
+ parfaitement juste des que ffmpeg n'ecrit pas ``ff`` sur deux chiffres.
372
+
373
+ Un timecode absent ou illisible n'est equivalent a rien, pas meme a
374
+ lui-meme: ``timecodes_equivalent(None, None)`` est ``False``. "Aucun des
375
+ deux n'est lisible" n'est pas "les deux designent la meme image", et le
376
+ seul appelant qui compte ici -- la verification du master -- doit refuser un
377
+ conteneur sans ``tmcd`` au lieu de l'assimiler a une absence attendue.
378
+ """
379
+ left = timecode_position(first)
380
+ return left is not None and left == timecode_position(second)
381
+
382
+
383
+ def timecode_to_frame_index(timecode: str, rate: float | int | str | Fraction) -> int:
384
+ """Convertir ``timecode`` (``hh:mm:ss:ff``) en index absolu de frame a ``rate``.
385
+
386
+ Arithmetique ``((h*60+m)*60+s)*nominal_fps+f``, ou ``nominal_fps =
387
+ ceil(rate)`` -- la meme convention que ``timecode_frame_field_width`` et
388
+ ``validate_timecode`` retiennent deja pour ce module. C'est aussi,
389
+ formule pour formule, ce que ``frame_selection._timecode_to_frames``
390
+ calcule de son cote -- prive a ce module distinct, non importable. Cette
391
+ fonction existe pour que l'encodage (story 6.6) dispose de la meme
392
+ arithmetique sans importer un prive d'un module qu'elle s'interdit
393
+ d'ouvrir, et sans la dupliquer a l'aveugle.
394
+
395
+ Seul le **plafond** de ``rate`` entre dans le calcul: deux cadences reelles
396
+ de meme plafond (24 et 24,5, par exemple) rendent le meme index pour le
397
+ meme timecode -- la notation ``hh:mm:ss:ff`` ne porte jamais la partie
398
+ fractionnaire d'une cadence, seulement sa borne entiere sur le champ ``ff``.
399
+
400
+ Leve ``EncodeConfigurationError`` sur un timecode illisible ou drop-frame:
401
+ le drop-frame n'est jamais emis ni accepte par ce depot (meme refus que
402
+ ``frame_selection._timecode_to_frames``).
403
+ """
404
+ position = timecode_position(timecode)
405
+ if position is None:
406
+ raise EncodeConfigurationError(
407
+ f"Timecode illisible, forme hh:mm:ss:ff attendue: {timecode!r}"
408
+ )
409
+ if position.drop_frame:
410
+ raise EncodeConfigurationError(
411
+ f"Timecode drop-frame refuse: {timecode!r}. Le drop-frame n'est "
412
+ "jamais emis ni accepte par ce depot."
413
+ )
414
+ nominal_fps = _frame_limit(Fraction(exact_frame_rate(rate)))
415
+ return (
416
+ (position.hours * 60 + position.minutes) * 60 + position.seconds
417
+ ) * nominal_fps + position.frames
418
+
419
+
420
+ def frame_index_to_timecode(index: int, rate: float | int | str | Fraction) -> str:
421
+ """L'inverse exact de :func:`timecode_to_frame_index` : index -> ``hh:mm:ss:ff``.
422
+
423
+ **Elle est publiee ici, et pas ailleurs, pour n'exister qu'une fois.**
424
+ L'arithmetique inverse vivait dans `frame_selection._format_timecode`,
425
+ prive d'un module que le reste du depot s'interdit d'ouvrir -- c'est
426
+ exactement le motif qui avait deja fait naitre
427
+ :func:`timecode_to_frame_index` ici. La completion d'une identite saisie a
428
+ la main (`EPIC7-ARB-103`) a besoin du meme calcul : sans point unique, ce
429
+ serait une troisieme redaction, et trois arithmetiques de timecode
430
+ divergent silencieusement -- l'ecart ne se voit que sur le nom des TIFF
431
+ produits.
432
+
433
+ **Le rebouclage a 24 h n'est pas optionnel** : `validate_timecode` leve
434
+ « Timecode hors bornes » des ``hh > 23``, et un rush de vingt minutes
435
+ demarrant a ``23:50:00:00`` deborde.
436
+
437
+ Seul le **plafond** de ``rate`` entre dans le calcul, comme dans la
438
+ fonction directe : la notation ``hh:mm:ss:ff`` ne porte jamais la partie
439
+ fractionnaire d'une cadence.
440
+
441
+ **Portee depuis la branche de l'Epic 7 le 2026-08-29**, ou elle etait ecrite
442
+ mais **sans aucun test**. C'est ce qui a laisse `scan_corrections` etre
443
+ recupere sans elle : le banc de ce module est vert a 72 sur 72 et ne touche
444
+ jamais `payload_depuis_l_identite`, donc l'absence ne se voyait pas. Son
445
+ banc est ecrit dans le meme mouvement que ce portage.
446
+ """
447
+ nominal_fps = _frame_limit(Fraction(exact_frame_rate(rate)))
448
+ boucle = index % (_SECONDS_PER_JOUR * nominal_fps)
449
+ frames = boucle % nominal_fps
450
+ secondes_totales = boucle // nominal_fps
451
+ secondes = secondes_totales % 60
452
+ minutes = (secondes_totales // 60) % 60
453
+ heures = secondes_totales // 3600
454
+ return f"{heures:02d}:{minutes:02d}:{secondes:02d}:{frames:02d}"
455
+
456
+
457
+ def validate_timecode(timecode: str, fps: float | int | str | Fraction) -> str:
458
+ """Validate an SMPTE timecode against ``fps``, returning it unchanged.
459
+
460
+ FFmpeg accepts every invalid form below with ``rc=0``: ``01:00:00:24`` at
461
+ 24 fps is silently rewritten to ``01:00:01:00`` (a one-second offset from
462
+ the manifest's page/slot mapping), while ``abc``, ``""`` and a drop-frame
463
+ ``;`` on a non-NTSC rate simply produce a master with **no** ``tmcd``
464
+ track at all. Only an explicit check catches those before encoding.
465
+
466
+ Correction du 2026-08-10 -- **la largeur d'ecriture du champ ``ff`` n'est
467
+ plus imposee**. La fonction n'acceptait qu'un champ de deux chiffres, si
468
+ bien que le timecode relu d'un master a 5, 10 ou 120 im/s -- ecrit par
469
+ ffmpeg sur une ou trois positions, voir ``timecode_frame_field_width`` --
470
+ etait refuse alors qu'il est juste. Ce qui est refuse ne l'est pas moins
471
+ pour autant: la borne ``ff < ceil(fps)`` est inchangee (``00:00:00:13`` a
472
+ 12,5 im/s reste illegal, et ffmpeg le normalise en ``00:00:01:00``), les
473
+ champs ``hh``, ``mm`` et ``ss`` restent sur deux chiffres, et un
474
+ remplissage arbitraire est refuse -- seules sont admises les largeurs
475
+ plausibles a cette cadence, entre 1 et ``max(2, largeur d'ffmpeg)``: la
476
+ forme canonique SMPTE sur deux chiffres, et celle qu'ffmpeg ecrit
477
+ reellement. ``00:00:00:004`` est donc accepte a 120 im/s et refuse a 25.
478
+ """
479
+ if not isinstance(timecode, str):
480
+ raise TypeError(f"timecode doit etre une chaine: {timecode!r}")
481
+ match = _TIMECODE_RE.match(timecode)
482
+ if match is None:
483
+ raise ValueError(
484
+ f"Timecode invalide: {timecode!r} (attendu hh:mm:ss:ff, ou hh:mm:ss;ff en drop-frame)"
485
+ )
486
+ position = _position_from_match(match)
487
+ rate = Fraction(exact_frame_rate(fps))
488
+ if position.hours > 23 or position.minutes > 59 or position.seconds > 59:
489
+ raise ValueError(f"Timecode hors bornes: {timecode!r}")
490
+ # ff must be < fps: ff == fps is the classic frame->TC off-by-one, and
491
+ # ffmpeg absorbs it into the next second without a word.
492
+ limit = _frame_limit(rate)
493
+ if position.frames >= limit:
494
+ raise ValueError(
495
+ f"Timecode {timecode!r}: champ frames {position.frames} >= {limit} a {rate} fps"
496
+ )
497
+ # Largeur d'ecriture: la forme canonique SMPTE (deux chiffres) et celle
498
+ # qu'ffmpeg ecrit a cette cadence sont les deux seules qui circulent dans la
499
+ # chaine. Tolerer plus large ferait passer un `00:00:00:0000004`, qui ne
500
+ # vient d'aucun producteur reel et signale une chaine de formatage cassee.
501
+ field_width = len(match.group(5))
502
+ max_width = max(2, len(str(limit - 1)))
503
+ if field_width > max_width:
504
+ raise ValueError(
505
+ f"Timecode {timecode!r}: champ frames ecrit sur {field_width} chiffres, "
506
+ f"au-dela des {max_width} chiffres plausibles a {rate} fps"
507
+ )
508
+ if position.drop_frame and rate not in NTSC_RATES:
509
+ raise ValueError(
510
+ f"Drop-frame ({timecode!r}) reserve aux cadences NTSC, pas {rate} fps"
511
+ )
512
+ return timecode
513
+
514
+
515
+ # ---------------------------------------------------------------------------
516
+ # Story 6.0 - fiabilisation de la fabrique de commande d'encodage.
517
+ #
518
+ # Tout ce qui suit est commente en francais (regle du CLAUDE.md du 2026-08-08)
519
+ # et les mesures citees ont ete rejouees le 2026-08-10 avec ffmpeg/ffprobe
520
+ # 6.1.1-3ubuntu5, par le banc `scripts/research/encode_robustness_bench.py`.
521
+ # ---------------------------------------------------------------------------
522
+
523
+
524
+ class EncodeError(Exception):
525
+ """Racine des erreurs de la fabrique d'encodage (story 6.0)."""
526
+
527
+
528
+ class EncodeConfigurationError(EncodeError, ValueError):
529
+ """Refus **a la construction**, avant qu'aucun octet ne soit ecrit.
530
+
531
+ Herite de ``ValueError`` parce que c'est le contrat historique de
532
+ ``build_encode_command`` (tag conteneur vide) et que six modules amont
533
+ attrapent deja ``ValueError`` sur les entrees de cette famille.
534
+ """
535
+
536
+
537
+ class EncoderUnavailableError(EncodeError):
538
+ """L'encodeur du profil n'est pas compile dans le ffmpeg installe."""
539
+
540
+
541
+ class ProbeUnavailableError(EncodeError):
542
+ """Le binaire ffprobe est introuvable ou inexecutable.
543
+
544
+ Existe pour la meme raison que ``video_metadata.FfprobeNotFoundError``, dont
545
+ elle est le pendant dans cette hierarchie: sans elle, ``probe_frame_size``
546
+ -- atteinte **avant** ``video_metadata`` dans ``run_encode`` -- laissait
547
+ remonter un ``FileNotFoundError`` nu, que tout appelant ecrit en
548
+ ``except EncodeError`` manquait. Le cas symetrique (ffmpeg absent) etait
549
+ deja type, celui-ci ne l'etait pas.
550
+ """
551
+
552
+
553
+ class MasterAlreadyExistsError(EncodeError, FileExistsError):
554
+ """Le chemin de sortie existe deja et l'ecrasement n'a pas ete demande."""
555
+
556
+
557
+ class EncodeRunError(EncodeError):
558
+ """ffmpeg a rendu un code de retour non nul."""
559
+
560
+
561
+ class EncodeVerificationError(EncodeError):
562
+ """Le fichier produit ne correspond pas a ce qui a ete demande.
563
+
564
+ C'est le seul mode de panne de l'etape que rien en aval ne rattrape: une
565
+ troncature silencieuse (`concat` rend `rc=0` avec `nb_frames=1` sur une
566
+ liste de 2 dont un fichier a disparu, mesure) produit un master qui ment
567
+ sur son propre contenu.
568
+ """
569
+
570
+
571
+ #: Muxer ffmpeg associe a chaque conteneur du catalogue. Il sert deux fois:
572
+ #: pour refuser une extension qui contredit le profil (AC 6) et pour poser un
573
+ #: `-f` explicite sur le temporaire (AC 5a) -- ffmpeg deduit sinon le muxer de
574
+ #: l'extension et un nom de temporaire sans extension echoue a 100 %
575
+ #: (mesure: `Unable to choose an output format ... .out.mov.tmp-1234`, rc=234).
576
+ CONTAINER_MUXERS: dict[str, str] = {"mov": "mov", "mp4": "mp4"}
577
+
578
+ #: Valeurs de `EncodeProfile.colorspace` reellement utilisables **de bout en
579
+ #: bout**, remesurees le 2026-08-10.
580
+ #:
581
+ #: Correction d'une liste precedente qui portait huit valeurs et laissait passer
582
+ #: `bt2020nc`, c'est-a-dire le cas fondateur qu'elle etait censee fermer. Le
583
+ #: motif de l'erreur: la mesure d'origine ne portait que sur le filtre `scale`,
584
+ #: alors que le meme champ alimente **quatre** consommateurs au vocabulaire
585
+ #: different -- `out_color_matrix` d'une part, et les trois options de tag
586
+ #: `-colorspace` / `-color_primaries` / `-color_trc` d'autre part. Mesure des
587
+ #: quatre vocabulaires, sur 15 candidats:
588
+ #:
589
+ #: * `scale=out_color_matrix=<v>`: **les 15 rendent rc=0**, y compris `linear`
590
+ #: ou `iec61966-2-1` qui ne sont pas des matrices. Le filtre ignore en silence
591
+ #: ce qu'il ne comprend pas: il ne peut donc **rien** valider a lui seul;
592
+ #: * `-colorspace`: bt709, smpte170m, smpte240m, bt2020nc, bt2020_ncl, fcc,
593
+ #: bt470bg, ycgco, smpte2085;
594
+ #: * `-color_primaries`: bt709, smpte170m, smpte240m, bt2020, bt470bg, bt470m;
595
+ #: * `-color_trc`: bt709, smpte170m, smpte240m, linear, iec61966-2-1.
596
+ #:
597
+ #: L'intersection -- et c'est elle qui compte, puisque les trois tags sont poses
598
+ #: **identiquement** depuis le meme champ -- est `{bt709, smpte170m, smpte240m}`.
599
+ #: Encodage complet de controle sur les 15 candidats: ces trois-la rendent
600
+ #: `rc=0` et se relisent tagues; les douze autres sortent en `rc=234`
601
+ #: (`Conversion failed!`), `bt2020nc` et `bt601` compris.
602
+ #:
603
+ #: Elargir cette liste demande donc de remesurer les **quatre** vocabulaires, ou
604
+ #: de dissocier la valeur de matrice de la valeur de tag dans `EncodeProfile` --
605
+ #: ce qui est une decision de catalogue, pas de fabrique.
606
+ #:
607
+ #: **CINQUIEME VOCABULAIRE depuis le 2026-09-06: `setparams`**, ajoute a la
608
+ #: chaine de filtres par `build_setparams_link`. Remesure faite avant de
609
+ #: toucher a cet ensemble, et elle ne le retrecit pas -- mais elle ne pouvait
610
+ #: pas etre supposee, les cinq vocabulaires ne coincidant pas:
611
+ #:
612
+ #: * `setparams=color_primaries`: bt709, bt470m, bt470bg, smpte170m, smpte240m,
613
+ #: film, bt2020, smpte428, smpte431, smpte432, jedec-p22, ebu3213;
614
+ #: * `setparams=color_trc`: bt709, bt470m, bt470bg, smpte170m, smpte240m,
615
+ #: linear, log100, log316, iec61966-2-4, bt1361e, iec61966-2-1, bt2020-10,
616
+ #: bt2020-12, smpte2084, smpte428, arib-std-b67;
617
+ #: * `setparams=colorspace`: gbr, bt709, fcc, bt470bg, smpte170m, smpte240m,
618
+ #: ycgco, bt2020nc, bt2020c, smpte2085, chroma-derived-nc, chroma-derived-c,
619
+ #: ictcp.
620
+ #:
621
+ #: Leur intersection est `{bt709, bt470bg, smpte170m, smpte240m}`, qui
622
+ #: **contient** strictement l'ensemble ci-dessous: aucune des trois valeurs
623
+ #: retenues n'est perdue, et rien n'est elargi.
624
+ #:
625
+ #: Difference de nature avec `scale`, et elle est a l'avantage de `setparams`:
626
+ #: la ou `out_color_matrix` ignore en silence ce qu'il ne comprend pas -- donc
627
+ #: ne valide rien --, `setparams` **echoue franchement**. Mesure sous 6.1.1:
628
+ #: `bt2020nc`, `bt601`, `linear` et `iec61966-2-1` sortent en `rc=234`
629
+ #: (`Undefined constant or missing '(' in ...`), `bt470bg` rend `rc=0`. Le
630
+ #: cinquieme vocabulaire est donc une garde reelle, pas une declaration.
631
+ #:
632
+ #: Ecart 6.1.1 -> 8.1.2 releve le meme jour: `setparams` n'a **rien retire**, il
633
+ #: a seulement ajoute des valeurs (`vgamut`, `vlog`, `ycgco-re/ro`, `ipt-c2`) et
634
+ #: deux options (`chroma_location`, `alpha_mode`). Les trois valeurs retenues
635
+ #: sont donc acceptees sur les deux versions.
636
+ USABLE_COLOR_MATRICES: frozenset[str] = frozenset({"bt709", "smpte170m", "smpte240m"})
637
+
638
+ #: Compte rendu de la mesure du 2026-09-06 qui a motive `build_setparams_link`.
639
+ #: Il vit dans le code plutot que dans un document parce que c'est ici qu'on le
640
+ #: cherchera le jour ou une sixieme version renversera encore l'arbitrage.
641
+ #:
642
+ #: Point de depart: un master ProRes/DNxHD/H.264 produit chez Egan sortait en
643
+ #: `color_space=bt709` mais `color_primaries` et `color_transfer` **non
644
+ #: renseignes**, et la garde `verify_technical_metadata` le refusait -- a
645
+ #: raison, le fichier etait reellement mal tague.
646
+ #:
647
+ #: Deux binaires, meme argv, meme source de synthese, trois encodeurs
648
+ #: (`prores_ks` -profile:v 3, `dnxhd` -profile:v dnxhr_hqx, `libx264`). Les
649
+ #: colonnes donnent `color_space` / `color_primaries` / `color_transfer`, et le
650
+ #: verdict est identique sur les trois encodeurs:
651
+ #:
652
+ #: * `scale` + 3 options CLI (**etat du depot avant ce correctif**):
653
+ #: 6.1.1 -> bt709/bt709/bt709 ; 8.1.2 -> bt709/**unknown**/**unknown**;
654
+ #: * idem **sans** `-vf`:
655
+ #: 6.1.1 -> bt709/bt709/bt709 ; 8.1.2 -> bt709/**unknown**/**unknown**;
656
+ #: * `scale` + `setparams` + 3 options CLI (**apres correctif**):
657
+ #: 6.1.1 -> bt709/bt709/bt709 ; 8.1.2 -> bt709/bt709/bt709.
658
+ #:
659
+ #: **La deuxieme ligne renverse l'explication naturelle.** On a d'abord cru le
660
+ #: filtergraph responsable -- l'idee etant que `enc_open` derive desormais les
661
+ #: proprietes de l'encodeur depuis le buffersink et **ecrase** les options CLI.
662
+ #: La contre-epreuve la refute: retirer `-vf` ne ramene pas les primaires. Un
663
+ #: filtergraph implicite subsiste de toute facon (conversion de format), si bien
664
+ #: que ce regime ne prouvait rien a lui seul.
665
+ #:
666
+ #: Ce qu'un troisieme regime etablit, et qui donne la vraie cause: en posant
667
+ #: `setparams=smpte170m` **contre** des options CLI a `bt709`, 8.1.2 rend
668
+ #: `color_space=bt709` mais `color_primaries=smpte170m` et
669
+ #: `color_transfer=smpte170m`. Donc, sous FFmpeg 8:
670
+ #:
671
+ #: * `-colorspace` atteint encore l'encodeur et **gagne**;
672
+ #: * `-color_primaries` et `-color_trc` ne l'atteignent **plus du tout** -- ils
673
+ #: ne sont pas arbitres, ils sont ignores. Seule la propriete portee par la
674
+ #: frame compte.
675
+ #:
676
+ #: Sous 6.1.1 le meme regime rend `bt709` sur les trois champs: les options CLI
677
+ #: l'emportent partout. Les deux versions ne se contredisent donc jamais **tant
678
+ #: que les deux gestes portent la meme valeur**, ce que la fabrique garantit en
679
+ #: les derivant tous deux de `EncodeProfile.colorspace`.
680
+ #:
681
+ #: `color_range` reste absent en ProRes sur les deux versions: c'est l'ecart
682
+ #: connu de `video_metadata`, que ce correctif ne touche pas.
683
+ #:
684
+ #: NEUTRALITE SOUS 6.1.1, MESUREE AVANT DE LIVRER
685
+ #: ----------------------------------------------
686
+ #: Le risque de ce correctif est asymetrique: il repare un export impossible
687
+ #: sous FFmpeg 8, mais il ajoute un maillon de filtre sous 6.1.1 -- la version
688
+ #: de ce conteneur et de la CI -- ou il ne servait a rien.
689
+ #:
690
+ #: **Cette ligne disait « la version epinglee par `requirements.txt` », et
691
+ #: c'etait faux** (corrige le 2026-09-07, finding `C5` de la couche 2).
692
+ #: `requirements.txt` ne porte que `ffmpeg-python`, le paquet Python : **rien
693
+ #: dans le depot n'epingle le binaire ffmpeg**, ce que l'entree de
694
+ #: `deferred-work.md` du meme lot ecrivait correctement pendant que ce
695
+ #: commentaire ecrivait l'inverse. Un lecteur en concluait que FFmpeg 8 est
696
+ #: hors contrat -- c'est-a-dire exactement la conclusion que le lot laisse
697
+ #: ouverte comme arbitrage. Meme famille que les trois relectures fausses de
698
+ #: la borne d'identifiant : une valeur se lit a sa source, jamais dans une
699
+ #: prose voisine. Un maillon qui y
700
+ #: forcerait une conversion, une plage ou un refus echangerait un blocage
701
+ #: contre un autre. Mesure sur les **sept** profils, memes frames, chaine
702
+ #: d'avant contre chaine d'apres:
703
+ #:
704
+ #: * code de retour: identique (0) sur les sept;
705
+ #: * pixels decodes (condensat du flux `rgb48le` integral): **identiques** sur
706
+ #: les sept -- le maillon ne convertit rien, il annote;
707
+ #: * poids du fichier: identique a l'octet pres sur les sept;
708
+ #: * `color_range`, `pix_fmt`, geometrie, cardinal: identiques sur les sept.
709
+ #: ProRes reste sans `color_range`, comme avant.
710
+ #:
711
+ #: **Quatre profils rendent un fichier bit a bit identique** (les deux DNxHR,
712
+ #: h264, hevc). Les **trois ProRes** different, et il faut dire de quoi:
713
+ #: exactement **9 octets**, soit 3 frames x 3 champs, tous de `2`
714
+ #: (`UNSPECIFIED`) a `1` (`BT709`), aux offsets `icpf+18/19/20` de l'en-tete de
715
+ #: frame ProRes -- `color_primaries`, `transfer_characteristic`,
716
+ #: `matrix_coefficients`.
717
+ #:
718
+ #: Autrement dit, **avant ce correctif et deja sous 6.1.1, le bitstream ProRes
719
+ #: se declarait non tague** pendant que l'atome `colr` du conteneur, lui, etait
720
+ #: juste. `ffprobe` lisant l'atome, aucune garde du depot ne pouvait le voir;
721
+ #: un logiciel de montage qui lit l'en-tete de frame recevait « non specifie ».
722
+ #: C'est un defaut anterieur a la regression de FFmpeg 8, que le maillon ferme
723
+ #: au passage -- une amelioration stricte, pas une degradation, et la seule
724
+ #: difference locale que `tests/unit/test_tagage_colorimetrique.py` sache
725
+ #: attraper sous 6.1.1.
726
+ FFMPEG_COLOR_TAGGING_MEASUREMENT = (
727
+ "2026-09-06, ffmpeg 6.1.1-3ubuntu5 et 8.1.2 (Lavf62.12.102/Lavc62.28.102): "
728
+ "sous FFmpeg 8, -color_primaries et -color_trc n'atteignent plus l'encodeur; "
729
+ "seul le maillon setparams de la chaine de filtres les pose encore."
730
+ )
731
+
732
+ #: Plage de sortie posee par la chaine de filtres (question ouverte 1 de la
733
+ #: story 6.0, tranchee `tv`). Motif mesure, et il est plus fort que la
734
+ #: convention: `prores_ks` **n'ecrit aucun `color_range`** dans le conteneur.
735
+ #: Avec `out_range=full`, le master est donc en pleine plage sans le dire, tout
736
+ #: decodeur le relit comme une plage legale, et le gris 50 % derive
737
+ #: (127,59 -> 129,91 mesure) pendant que les primaires saturees, elles,
738
+ #: ecretent et paraissent parfaites. `tv` rend l'aller-retour coherent avec ce
739
+ #: que le fichier declare (ecart max 1,06/255 sur la mire du banc).
740
+ OUTPUT_COLOR_RANGE = "tv"
741
+
742
+ #: Extension du fichier de liste `concat` temporaire.
743
+ _CONCAT_LIST_SUFFIX = ".mmuconcat"
744
+
745
+ #: Empreinte du nom de temporaire pose par ``_unique_sibling``: `<pid>-<12 hex>`
746
+ #: juste avant l'extension finale. Assez specifique pour qu'un balayage ne puisse
747
+ #: pas emporter un fichier cache qui ne vient pas de ce module.
748
+ _TEMP_NAME_RE = re.compile(r"\.\d+-[0-9a-f]{12}(\.[^.]+)?$")
749
+
750
+ #: Options que la fabrique pose elle-meme du cote **sortie** de la ligne de
751
+ #: commande. Un profil qui en emet une dans ses `extra_args` gagnerait l'arbitrage
752
+ #: (ffmpeg retient la derniere valeur) et remplacerait donc une decision de la
753
+ #: fabrique en silence. Le cas cher est `-vf`: un doublon de chaine de filtres
754
+ #: produit un master **tague `bt709` sans avoir subi la conversion**, c'est-a-dire
755
+ #: exactement le fichier a 40,33/255 d'ecart que cette story existe pour
756
+ #: supprimer, et rien en aval ne le rattrape.
757
+ _FACTORY_OWNED_OPTIONS: frozenset[str] = frozenset(
758
+ {"-vf", "-filter:v", "-filter_complex", "-r", "-f", "-i", "-y", "-n",
759
+ "-timecode", "-movflags", "-safe", "-metadata"}
760
+ )
761
+
762
+ #: Timecode porte par le **nom** des frames de la chaine v2: `extract` ecrit
763
+ #: `<lot_id>_<hh-mm-ss-ff>.tiff`, `scan` ecrit `scan_<lot_id>_<hh-mm-ss-ff>.tiff`
764
+ #: (verifie sur `projects/projet_demo/frames/TEST_FILE_5/`). Les quatre champs
765
+ #: sont zero-remplis, donc leur comparaison lexicographique **coincide** avec
766
+ #: l'ordre chronologique -- c'est ce qui rend la garde d'ordre calculable sans
767
+ #: relire les fichiers.
768
+ _FRAME_TIMECODE_RE = re.compile(r"(\d{2}-\d{2}-\d{2}-\d{2})$")
769
+
770
+
771
+ @dataclass(frozen=True)
772
+ class DimensionRule:
773
+ """Contraintes dimensionnelles d'un encodeur, **mesurees** encodeur par
774
+ encodeur -- jamais deduites d'une famille de sous-echantillonnage.
775
+
776
+ La deduction par famille est fausse: `prores_ks` (4:2:2) et `dnxhd`
777
+ (4:2:2) acceptent 321x181, et `dnxhd` refuse 240x120 dont les deux
778
+ dimensions sont paires. Les valeurs ci-dessous viennent d'une matrice
779
+ d'encodages reels du 2026-08-10.
780
+ """
781
+
782
+ even_width: bool
783
+ even_height: bool
784
+ min_width: int
785
+ min_height: int
786
+
787
+ def __post_init__(self) -> None:
788
+ """Refuse une regle dont le plancher contredit la parite exigee.
789
+
790
+ Invariant pose ici parce que ``_nearest_dimension`` en depend: une
791
+ dimension impaire est ramenee vers le **bas**, et cette descente ne peut
792
+ rester conforme que si le plancher est lui-meme pair. Une regle
793
+ `min_width=3, even_width=True` rendrait 2, c'est-a-dire une valeur sous
794
+ le plancher, presentee a l'operateur comme "la geometrie conforme la plus
795
+ proche". La branche defensive qui traitait ce cas etait du **code mort**
796
+ -- aucune des quatre regles du catalogue ne combine plancher impair et
797
+ parite exigee, balayage exhaustif de 0 a 20000 sur les deux axes -- donc
798
+ elle a ete retiree (un mutant equivalent se retire, il ne se teste pas)
799
+ et remplacee par cet invariant, qui lui est verifiable.
800
+ """
801
+ if self.even_width and self.min_width % 2:
802
+ raise ValueError(
803
+ f"Regle dimensionnelle incoherente: min_width={self.min_width} impair "
804
+ "avec parite de largeur exigee"
805
+ )
806
+ if self.even_height and self.min_height % 2:
807
+ raise ValueError(
808
+ f"Regle dimensionnelle incoherente: min_height={self.min_height} impair "
809
+ "avec parite de hauteur exigee"
810
+ )
811
+
812
+ def violations(self, width: int, height: int) -> list[str]:
813
+ """Rend la liste des contraintes violees, en clair et en ASCII."""
814
+ problems: list[str] = []
815
+ if width < self.min_width:
816
+ problems.append(f"largeur {width} < minimum {self.min_width}")
817
+ if height < self.min_height:
818
+ problems.append(f"hauteur {height} < minimum {self.min_height}")
819
+ if self.even_width and width % 2:
820
+ problems.append(f"largeur {width} impaire (parite exigee)")
821
+ if self.even_height and height % 2:
822
+ problems.append(f"hauteur {height} impaire (parite exigee)")
823
+ return problems
824
+
825
+ def nearest(self, width: int, height: int) -> tuple[int, int]:
826
+ """Rend la geometrie conforme la plus proche.
827
+
828
+ Question ouverte 2 de la story, tranchee: la valeur conforme est
829
+ **calculee ici et nulle part ailleurs**. La calculer aussi chez
830
+ l'appelant est le motif de divergence que le depot a deja ferme trois
831
+ fois. Pour une dimension impaire on **descend** d'un pixel: reduire ne
832
+ fabrique aucun contenu, alors qu'agrandir inventerait une colonne. Pour
833
+ un seuil (DNxHR) la valeur rendue est le seuil, pas un arrondi.
834
+ """
835
+ return (
836
+ _nearest_dimension(width, self.min_width, self.even_width),
837
+ _nearest_dimension(height, self.min_height, self.even_height),
838
+ )
839
+
840
+
841
+ def _nearest_dimension(value: int, minimum: int, even: bool) -> int:
842
+ """Ramene ``value`` sur la valeur conforme la plus proche.
843
+
844
+ ``value - 1`` reste toujours ``>= minimum``: ``DimensionRule.__post_init__``
845
+ garantit qu'un plancher assorti d'une parite est pair, et une ``value``
846
+ impaire strictement superieure a un plancher pair vaut au moins
847
+ ``minimum + 1``.
848
+ """
849
+ if value < minimum:
850
+ return minimum
851
+ if even and value % 2:
852
+ # On descend d'un pixel: reduire ne fabrique aucun contenu, agrandir
853
+ # inventerait une colonne, et les deux sont a distance 1.
854
+ return value - 1
855
+ return value
856
+
857
+
858
+ #: Contraintes reelles par encodeur. Matrice d'encodages du 2026-08-10, entree
859
+ #: TIFF 16 bits, deux frames distinctes:
860
+ #:
861
+ #: | dimensions | prores_ks | dnxhd | libx264 | libx265 |
862
+ #: | 320x180 | rc=0 | rc=0 | rc=0 | rc=0 |
863
+ #: | 321x181 | rc=0 | rc=0 | rc=187 | rc=183 |
864
+ #: | 240x120 | rc=0 | rc=234| rc=0 | rc=0 |
865
+ #: | 256x110 | rc=0 | rc=234| rc=0 | rc=0 |
866
+ #: | 3307x1860 | rc=0 | rc=0 | rc=187 | rc=183 |
867
+ #: | 16x16 | rc=0 | rc=234| rc=0 | rc=0 |
868
+ #: | 14x14 | rc=0 | rc=234| rc=0 | rc=234 |
869
+ #:
870
+ #: Deux plancher-minimums que la story n'avait pas mesures et qu'une regle de
871
+ #: seule parite laisserait passer: `dnxhd` exige **aussi** une hauteur >= 120
872
+ #: (256x110 sort en rc=234), et `libx265` exige les deux dimensions >= 16
873
+ #: (16x8, 8x16, 14x14 sortent en rc=234).
874
+ ENCODER_DIMENSION_RULES: dict[str, DimensionRule] = {
875
+ "prores_ks": DimensionRule(even_width=False, even_height=False, min_width=1, min_height=1),
876
+ "dnxhd": DimensionRule(even_width=False, even_height=False, min_width=256, min_height=120),
877
+ "libx264": DimensionRule(even_width=True, even_height=True, min_width=2, min_height=2),
878
+ "libx265": DimensionRule(even_width=True, even_height=True, min_width=16, min_height=16),
879
+ }
880
+
881
+
882
+ def dimension_rule(vcodec: str) -> DimensionRule:
883
+ """Rend la regle dimensionnelle de ``vcodec``, ou leve si elle est inconnue.
884
+
885
+ Refuser explicitement vaut mieux que retomber sur une regle permissive:
886
+ un encodeur ajoute au catalogue sans mesure sortirait sinon en echec
887
+ d'encodage apres avoir lu toutes les frames.
888
+ """
889
+ try:
890
+ return ENCODER_DIMENSION_RULES[vcodec]
891
+ except KeyError as exc:
892
+ raise EncodeConfigurationError(
893
+ f"Aucune contrainte dimensionnelle mesuree pour l'encodeur {vcodec!r}. "
894
+ f"Encodeurs mesures: {sorted(ENCODER_DIMENSION_RULES)}"
895
+ ) from exc
896
+
897
+
898
+ def _as_dimension(value: object, axis: str, profile: EncodeProfile) -> int:
899
+ """Refuse une dimension non entiere, et rend l'entier.
900
+
901
+ Le module garde les types partout ailleurs -- ``exact_frame_rate`` rejette
902
+ explicitement ``bool``, ``validate_timecode`` exige une ``str`` -- et la
903
+ geometrie etait la seule porte sans garde de type. Elle avait **trois**
904
+ modes de panne mesures, dont le premier est le plus vicieux parce qu'il
905
+ reussit:
906
+
907
+ * ``(320.0, 180.0)`` passe, encode, et persiste
908
+ ``target_resolution = "320.0x180.0"`` au manifest: aucune confrontation
909
+ aval ne matchera jamais ``"320x180"``;
910
+ * ``(320.6, 180.4)`` leve une ``EncodeVerificationError`` **apres**
911
+ l'encodage complet, c'est-a-dire apres lecture de toutes les frames;
912
+ * ``(True, True)`` produit ``scale=True:True`` et un ``rc=234`` opaque.
913
+ """
914
+ if not is_strict_int(value):
915
+ raise EncodeConfigurationError(
916
+ f"Geometrie cible non entiere pour le profil {profile.profile_id!r}: "
917
+ f"{axis}={value!r} ({type(value).__name__}). La geometrie est une "
918
+ "decision de la story 6.1, elle arrive ici en pixels entiers."
919
+ )
920
+ return value
921
+
922
+
923
+ def check_target_dimensions(profile: EncodeProfile, width: int, height: int) -> None:
924
+ """Refuse **en amont** une geometrie que l'encodeur du profil rejette.
925
+
926
+ Nomme la dimension fautive, le profil, la contrainte violee et la
927
+ geometrie conforme la plus proche. Cette fabrique ne corrige pas: recadrer
928
+ ou completer une image est une decision de cadrage, elle appartient a la
929
+ story 6.1.
930
+ """
931
+ width = _as_dimension(width, "largeur", profile)
932
+ height = _as_dimension(height, "hauteur", profile)
933
+ if width <= 0 or height <= 0:
934
+ raise EncodeConfigurationError(
935
+ f"Geometrie cible invalide pour le profil {profile.profile_id!r}: {width}x{height}"
936
+ )
937
+ rule = dimension_rule(profile.vcodec)
938
+ problems = rule.violations(width, height)
939
+ if not problems:
940
+ return
941
+ near_w, near_h = rule.nearest(width, height)
942
+ raise EncodeConfigurationError(
943
+ f"Geometrie {width}x{height} refusee par l'encodeur {profile.vcodec!r} "
944
+ f"du profil {profile.profile_id!r}: {'; '.join(problems)}. "
945
+ f"Geometrie conforme la plus proche: {near_w}x{near_h}"
946
+ )
947
+
948
+
949
+ def build_filter_chain(profile: EncodeProfile, target_size: tuple[int, int] | None = None) -> str:
950
+ """Rend la chaine de filtres qui **applique** la matrice du profil.
951
+
952
+ Taguer n'est pas convertir: `-colorspace bt709` decrit le flux, il ne
953
+ pilote pas swscale, dont la matrice par defaut reste BT.601. Mesure du
954
+ 2026-08-10, aller-retour sur mire saturee: **ecart max 40,33/255** avec les
955
+ tags seuls (vert 40,33, magenta 39,35), **1,06/255** avec cette chaine.
956
+
957
+ Contre-epreuve, et elle justifie que les deux gestes coexistent: la meme
958
+ chaine **sans** les trois tags donne `color_space=unknown` et un ecart de
959
+ **23,00/255**. Les tags restent donc necessaires, ils sont emis par
960
+ ``EncodeProfile.ffmpeg_video_args``.
961
+
962
+ ``target_size`` est le **seul** point qui autorise un redimensionnement, et
963
+ son absence signifie "pas de redimensionnement": la valeur est decidee
964
+ par la story 6.1, appliquee ici.
965
+
966
+ **Le maillon ``setparams`` n'est pas un doublon des trois options CLI: sous
967
+ FFmpeg 8 il est le SEUL des deux gestes qui atteigne encore l'encodeur**
968
+ (mesure du 2026-09-06, voir ``FFMPEG_COLOR_TAGGING_MEASUREMENT``). Les deux
969
+ coexistent parce qu'ils portent la **meme** valeur -- celle du profil -- et
970
+ qu'aucune version ne les voit donc se contredire.
971
+ """
972
+ if profile.colorspace not in USABLE_COLOR_MATRICES:
973
+ raise EncodeConfigurationError(
974
+ f"Colorspace {profile.colorspace!r} du profil {profile.profile_id!r} inutilisable: "
975
+ "le filtre scale l'accepte peut-etre, mais les trois options de tag posees depuis "
976
+ f"le meme champ ne l'acceptent pas toutes. Valeurs mesurees utilisables de bout en "
977
+ f"bout: {sorted(USABLE_COLOR_MATRICES)}"
978
+ )
979
+ options = [f"out_color_matrix={profile.colorspace}", f"out_range={OUTPUT_COLOR_RANGE}"]
980
+ if target_size is not None:
981
+ width, height = _unpack_target_size(target_size)
982
+ # Garde de **type** seulement: la conformite a l'encodeur est verifiee par
983
+ # `check_target_dimensions`, appelee depuis `build_encode_command`. La poser
984
+ # ici aussi rendrait cet appel-la invisible a toute mesure.
985
+ width = _as_dimension(width, "largeur", profile)
986
+ height = _as_dimension(height, "hauteur", profile)
987
+ options = [str(width), str(height), *options]
988
+ return "scale=" + ":".join(options) + "," + build_setparams_link(profile.colorspace)
989
+
990
+
991
+ def build_setparams_link(colorspace: str) -> str:
992
+ """Rend le maillon ``setparams`` qui **marque les frames elles-memes**.
993
+
994
+ Ce maillon existe pour une regression de terrain, et il faut garder la
995
+ mesure sous la main parce qu'elle est contre-intuitive: sous FFmpeg 8, les
996
+ options ``-color_primaries`` et ``-color_trc`` **n'atteignent plus
997
+ l'encodeur du tout**. Elles ne sont ni ecrasees ni arbitrees: elles sont
998
+ ignorees, et le master sort en ``bt709/unknown/unknown``. Seul
999
+ ``-colorspace`` survit, ce qui explique la forme exacte du symptome.
1000
+
1001
+ ``setparams`` pose la propriete sur la **frame**, c'est-a-dire du cote que
1002
+ FFmpeg 8 lit encore. Il existe depuis FFmpeg 4, donc il ne coute rien sous
1003
+ 6.1.1, ou il est simplement redondant avec les options CLI.
1004
+
1005
+ Voir ``FFMPEG_COLOR_TAGGING_MEASUREMENT`` pour le tableau des trois regimes
1006
+ mesures et les deux versions sous lesquelles ils l'ont ete.
1007
+ """
1008
+ return (
1009
+ f"setparams=color_primaries={colorspace}"
1010
+ f":color_trc={colorspace}"
1011
+ f":colorspace={colorspace}"
1012
+ f":range={OUTPUT_COLOR_RANGE}"
1013
+ )
1014
+
1015
+
1016
+ def _unpack_target_size(target_size: object) -> tuple[int, int]:
1017
+ """Rend le couple ``(largeur, hauteur)``, en refusant toute autre forme."""
1018
+ try:
1019
+ width, height = target_size # type: ignore[misc]
1020
+ except (TypeError, ValueError) as exc:
1021
+ raise EncodeConfigurationError(
1022
+ f"Geometrie cible mal formee: {target_size!r}, attendu un couple (largeur, hauteur)"
1023
+ ) from exc
1024
+ return width, height
1025
+
1026
+
1027
+ @functools.lru_cache(maxsize=8)
1028
+ def available_encoders(ffmpeg_bin: str = "ffmpeg") -> frozenset[str]:
1029
+ """Rend l'ensemble des encodeurs reellement compiles dans ``ffmpeg_bin``.
1030
+
1031
+ Deux pieges mesures, et ils excluent les deux constatations les plus
1032
+ naturelles:
1033
+
1034
+ * ``shutil.which("ffmpeg")`` rend un chemin **quels que soient** les
1035
+ encodeurs compiles: le motif de ``FfprobeNotFoundError`` ne se transpose
1036
+ pas;
1037
+ * ``ffmpeg -h encoder=libx265`` rend ``rc=0``, mais
1038
+ ``ffmpeg -h encoder=libnonexistent`` rend **aussi** ``rc=0``, avec le
1039
+ texte ``Codec 'libnonexistent' is not recognized by FFmpeg`` sur la
1040
+ sortie standard. Une garde branchee sur le code de retour declarerait
1041
+ disponible **tout** encodeur, y compris inexistant.
1042
+
1043
+ D'ou l'analyse de la **sortie** de ``-encoders``, dont le corps commence
1044
+ apres une ligne de tirets et dont chaque ligne est
1045
+ ``<drapeaux> <nom> <description>``.
1046
+
1047
+ **Le code de retour est lu, lui.** Le raisonnement ci-dessus vaut pour
1048
+ ``-h encoder=``, ou un ``rc=0`` ne prouve rien; il ne vaut pas pour
1049
+ ``-encoders``, ou un ``rc`` non nul prouve que le listing est incomplet. Un
1050
+ ``ffmpeg -encoders`` tue (OOM, SIGPIPE, quota) qui a eu le temps d'ecrire sa
1051
+ ligne de separation rendait sinon un ensemble **partiel**, accepte puis
1052
+ memorise par ``lru_cache`` pour toute la vie du processus -- et le
1053
+ diagnostic sorti de la, "encodeur absent du binaire", est faux **et
1054
+ definitif** alors que le binaire est complet. Lever plutot que rendre est
1055
+ aussi ce qui protege le cache: ``lru_cache`` ne memorise pas les exceptions,
1056
+ donc un appel suivant remesure.
1057
+ """
1058
+ if shutil.which(ffmpeg_bin) is None:
1059
+ raise EncoderUnavailableError(f"Binaire ffmpeg introuvable dans le PATH: {ffmpeg_bin!r}")
1060
+ try:
1061
+ result = subprocess.run(
1062
+ [ffmpeg_bin, "-hide_banner", "-encoders"],
1063
+ capture_output=True,
1064
+ encoding="utf-8",
1065
+ errors="replace",
1066
+ )
1067
+ except OSError as exc:
1068
+ raise EncoderUnavailableError(
1069
+ f"Binaire ffmpeg inexecutable: {ffmpeg_bin!r} ({exc})"
1070
+ ) from exc
1071
+ if result.returncode != 0:
1072
+ raise EncoderUnavailableError(
1073
+ f"'{ffmpeg_bin} -encoders' a rendu le code {result.returncode}: le listing est "
1074
+ "incomplet et l'ensemble qu'on en tirerait accuserait a tort une compilation "
1075
+ f"partielle. {(result.stderr or '').strip()[-200:]}"
1076
+ )
1077
+ lines = (result.stdout or "").splitlines()
1078
+ separator = None
1079
+ for index, line in enumerate(lines):
1080
+ stripped = line.strip()
1081
+ if stripped and set(stripped) == {"-"}:
1082
+ separator = index
1083
+ break
1084
+ if separator is None:
1085
+ # Sans la ligne de separation on ne sait pas ou commence le corps: une
1086
+ # tolerance ici rendrait un ensemble vide, donc "aucun encodeur
1087
+ # disponible", donc un refus generalise difficile a diagnostiquer.
1088
+ raise EncoderUnavailableError(
1089
+ f"Sortie de '{ffmpeg_bin} -encoders' illisible: aucune ligne de separation"
1090
+ )
1091
+ names = set()
1092
+ for line in lines[separator + 1:]:
1093
+ parts = line.split()
1094
+ if len(parts) >= 2:
1095
+ names.add(parts[1])
1096
+ return frozenset(names)
1097
+
1098
+
1099
+ def ensure_encoder_available(profile: EncodeProfile, ffmpeg_bin: str = "ffmpeg") -> None:
1100
+ """Refuse avant lecture des frames si l'encodeur du profil est absent.
1101
+
1102
+ Frontiere a garder en tete: "disponible" n'est pas "utilisable".
1103
+ ``dnxhd`` figure dans ``-encoders`` et refuse pourtant 240x136
1104
+ (``check_target_dimensions``). Les deux gardes sont independantes.
1105
+ """
1106
+ if profile.vcodec not in available_encoders(ffmpeg_bin):
1107
+ raise EncoderUnavailableError(
1108
+ f"Encodeur {profile.vcodec!r} du profil {profile.profile_id!r} absent de "
1109
+ f"{ffmpeg_bin!r}. Un ffmpeg systeme sans libx265 est le cas ordinaire."
1110
+ )
1111
+
1112
+
1113
+ #: Ce que rend `probe_tool_version` quand la version n'a pas pu etre relevee.
1114
+ #: **Une chaine, jamais une exception**: ce releve est consomme dans des chemins
1115
+ #: d'erreur (le rapport de refus technique), et une sonde qui leve y remplacerait
1116
+ #: l'erreur en cours par la sienne -- exactement le defaut que le `finally` de
1117
+ #: `run_encode` documente deja pour `unlink`.
1118
+ TOOL_VERSION_UNKNOWN = "version indeterminee"
1119
+
1120
+ #: Bibliotheques relevees en plus du numero de version. Ce sont celles
1121
+ #: qu'affiche `ffmpeg -version` et celles qu'Egan a citees en rapportant la
1122
+ #: regression du 2026-09-06 (`Lavf62.6.103` / `Lavc62.19.100`): c'est le
1123
+ #: vocabulaire dans lequel une version de ffmpeg se nomme sur le terrain.
1124
+ _TOOL_VERSION_LIBRARIES = ("libavcodec", "libavformat", "libavutil")
1125
+
1126
+ #: `ffmpeg version 6.1.1-3ubuntu5 Copyright ...`, `ffprobe version n8.1.2-50-g...`.
1127
+ _TOOL_VERSION_RE = re.compile(r"^\w+ version (\S+)")
1128
+
1129
+ #: `libavutil 58. 29.100 / 58. 29.100` -- le nombre d'espaces apres les
1130
+ #: points varie avec la largeur des champs (`61. 7.100` sur une version master),
1131
+ #: donc il est absorbe plutot que compte.
1132
+ _TOOL_LIBRARY_RE = re.compile(r"^(lib\w+)\s+(\d+)\.\s*(\d+)\.\s*(\d+)")
1133
+
1134
+
1135
+ #: Versions deja relevees, par binaire. **Seuls les SUCCES y entrent**, et
1136
+ #: c'est la difference qui compte -- voir le docstring de
1137
+ #: `probe_tool_version`.
1138
+ _VERSIONS_RELEVEES: dict[str, str] = {}
1139
+
1140
+
1141
+ def probe_tool_version(binary: str) -> str:
1142
+ """Rend la version de `binary` (ffmpeg ou ffprobe), en une ligne lisible.
1143
+
1144
+ **Pourquoi ce releve existe.** Le 2026-09-06, un master mal tague est
1145
+ remonte du terrain et le diagnostic a coute une soiree entiere, uniquement
1146
+ parce que rien -- ni le manifeste d'encodage, ni le rapport de refus -- ne
1147
+ disait sous **quelle version de ffmpeg** le fichier avait ete produit. La
1148
+ cause etait un renversement de comportement entre FFmpeg 6 et 8
1149
+ (`FFMPEG_COLOR_TAGGING_MEASUREMENT`), c'est-a-dire precisement le genre de
1150
+ fait qu'un numero de version rend immediat. Les deux en-tetes de module qui
1151
+ declarent « mesure sous 6.1.1 » n'etaient adosses a aucun releve
1152
+ d'execution: la version supposee et la version reelle pouvaient diverger
1153
+ sans que rien ne le dise.
1154
+
1155
+ **Ce releve ne refuse rien, et c'est delibere.** Poser un plancher ou un
1156
+ plafond de version serait un arbitrage produit -- lequel, et pour qui? --
1157
+ qu'Egan n'a pas tranche et qui bloquerait son terrain sur-le-champ. La
1158
+ fonction releve, nomme et journalise; la question du plancher est consignee
1159
+ dans `deferred-work.md`.
1160
+
1161
+ **Seuls les SUCCES sont memorises, et le module avait deja paye la lecon
1162
+ trente lignes plus haut** (corrige le 2026-09-07, finding `C2` de la couche
1163
+ 1). Le premier jet posait un `lru_cache` sur une fonction qui **rend** un
1164
+ temoin d'echec au lieu de lever -- or `available_encoders` explique, sur
1165
+ mesure d'un incident reel, que « lever plutot que rendre est aussi ce qui
1166
+ protege le cache : `lru_cache` ne memorise pas les exceptions, donc un
1167
+ appel suivant remesure ». Cette sonde faisait exactement l'inverse des
1168
+ deux cotes.
1169
+
1170
+ Le regime ou ca mord, et il est celui de l'Epic 11 : la TUI est un
1171
+ processus **long**. Une premiere sonde ratee -- `PATH` incomplet au
1172
+ demarrage, `ffmpeg` installe ensuite, binaire remplace -- estampillait
1173
+ « version indeterminee » sur **tous** les masters de la session,
1174
+ c'est-a-dire eteignait en silence le temoin que ce releve existe pour
1175
+ poser. Mesure : deuxieme appel avec le binaire desormais installe, toujours
1176
+ « version indeterminee », `hits=1`.
1177
+
1178
+ Un echec n'est donc pas retenu : il coute une relance de `ffmpeg -version`
1179
+ au prochain appel, ce qui est le prix a payer pour qu'une panne transitoire
1180
+ ne devienne pas definitive.
1181
+
1182
+ Rend `TOOL_VERSION_UNKNOWN` plutot que de lever sur binaire absent,
1183
+ inexecutable, en echec, **suspendu** ou a la sortie illisible: ce releve est
1184
+ un **temoin**, jamais une garde, et il est lu depuis des chemins d'erreur ou
1185
+ lever masquerait la panne qu'on cherche a decrire. La disponibilite reelle
1186
+ de ffmpeg, elle, est repondue par `available_encoders`, qui leve.
1187
+ """
1188
+ deja = _VERSIONS_RELEVEES.get(binary)
1189
+ if deja is not None:
1190
+ return deja
1191
+ if shutil.which(binary) is None:
1192
+ return TOOL_VERSION_UNKNOWN
1193
+ try:
1194
+ result = subprocess.run(
1195
+ [binary, "-version"],
1196
+ capture_output=True,
1197
+ encoding="utf-8",
1198
+ errors="replace",
1199
+ # **Le plafond est ce qui empeche le temoin de devenir la panne**
1200
+ # (pose le 2026-09-07, sur finding T6 de la couche 3). Sans lui, un
1201
+ # binaire qui suspend sur `-version` suspend l'encodage entier --
1202
+ # et, pire, il suspend le CHEMIN DE REFUS, c'est-a-dire le message
1203
+ # qui devait justement decrire la panne. Un releve qui se declare
1204
+ # « temoin, jamais garde » ne peut pas se permettre d'etre le seul
1205
+ # point du chemin capable de tout arreter.
1206
+ #
1207
+ # Cinq secondes: `ffmpeg -version` ne lit aucun media, n'ouvre
1208
+ # aucun peripherique et n'interroge aucun reseau -- il imprime des
1209
+ # constantes de compilation. Mesure dans ce conteneur: 40 ms. Le
1210
+ # plafond n'est donc pas une estimation de la duree normale, c'est
1211
+ # un ordre de grandeur au-dela duquel la commande ne travaille plus
1212
+ # mais attend quelque chose.
1213
+ timeout=5,
1214
+ )
1215
+ except (OSError, subprocess.TimeoutExpired):
1216
+ return TOOL_VERSION_UNKNOWN
1217
+ if result.returncode != 0:
1218
+ return TOOL_VERSION_UNKNOWN
1219
+ version = _format_tool_version(result.stdout or "")
1220
+ if version != TOOL_VERSION_UNKNOWN:
1221
+ _VERSIONS_RELEVEES[binary] = version
1222
+ return version
1223
+
1224
+
1225
+ def format_tool_provenance(encoder_version: str, prober_bin: str) -> str:
1226
+ """Rend `ffmpeg <version>, ffprobe <version>`, prete a citer dans un refus.
1227
+
1228
+ **Cette fonction existe pour une frontiere du depot, pas par gout du
1229
+ factorisage.** `encode.py` est le module de DECISION: un banc negatif
1230
+ (`test_the_decision_module_builds_no_command_and_spawns_no_process`) lui
1231
+ interdit tout `subprocess` et **tout litteral contenant un nom de binaire
1232
+ d'encodage**, parce que l'un ou l'autre signerait le debordement sur la
1233
+ story 6.0. Ecrire `f"ffmpeg {...}"` la-bas fait rougir ce banc -- il l'a
1234
+ fait, le 2026-09-06.
1235
+
1236
+ Le vocabulaire des binaires appartient donc a la fabrique, qui les lance
1237
+ deja. L'appelant recoit une chaine opaque et n'a rien a nommer.
1238
+
1239
+ Les deux versions sont citees parce qu'elles peuvent differer et que
1240
+ l'ecart est lui-meme un diagnostic: ffmpeg **ecrit** le fichier, ffprobe le
1241
+ **relit**, et un refus technique met precisement les deux en cause.
1242
+ """
1243
+ return f"ffmpeg {encoder_version}, ffprobe {probe_tool_version(prober_bin)}"
1244
+
1245
+
1246
+ def _format_tool_version(output: str) -> str:
1247
+ """Met en forme la sortie de `<outil> -version`.
1248
+
1249
+ Rend par exemple
1250
+ `6.1.1-3ubuntu5 (libavcodec 60.31.102, libavformat 60.16.100, libavutil 58.29.100)`.
1251
+
1252
+ Les bibliotheques sont rendues **dans l'ordre de `_TOOL_VERSION_LIBRARIES`**
1253
+ et non dans celui de la sortie: deux releves pris sur deux machines doivent
1254
+ se comparer a l'oeil, ce qu'un ordre dependant du binaire empecherait.
1255
+ """
1256
+ release = ""
1257
+ seen: dict[str, str] = {}
1258
+ for line in output.splitlines():
1259
+ stripped = line.strip()
1260
+ if not release:
1261
+ match = _TOOL_VERSION_RE.match(stripped)
1262
+ if match:
1263
+ release = match.group(1)
1264
+ continue
1265
+ library = _TOOL_LIBRARY_RE.match(stripped)
1266
+ if library and library.group(1) not in seen:
1267
+ seen[library.group(1)] = (
1268
+ f"{library.group(2)}.{library.group(3)}.{library.group(4)}"
1269
+ )
1270
+ if not release:
1271
+ # Un binaire qui rend rc=0 sans ligne de version n'est pas celui qu'on
1272
+ # croit: nommer une version inventee serait pire que d'avouer le trou.
1273
+ return TOOL_VERSION_UNKNOWN
1274
+ detail = ", ".join(
1275
+ f"{name} {seen[name]}" for name in _TOOL_VERSION_LIBRARIES if name in seen
1276
+ )
1277
+ return f"{release} ({detail})" if detail else release
1278
+
1279
+
1280
+ def format_concat_entry(path: Path | str) -> str:
1281
+ """Rend la ligne ``file '...'`` d'un fichier de liste ``concat``.
1282
+
1283
+ Le chemin est **absolu** (les chemins relatifs d'une liste `concat` se
1284
+ resolvent par rapport au **fichier de liste**, pas au repertoire courant:
1285
+ mesure, une liste ecrite ailleurs que dans le dossier des frames sort en
1286
+ ``Impossible to open 'rel/rel/sub/frame.tiff'``, rc=254) et **echappe**
1287
+ (``'`` -> ``'\\''``): sans echappement une apostrophe fait lire un chemin
1288
+ faux et l'encodage porte sur autre chose, en rendant rc=0.
1289
+
1290
+ Passent, mesures: apostrophe, espace, ``%``, ``#``, crochets, guillemet,
1291
+ virgule, deux-points, contre-oblique. Seul un saut de ligne echoue
1292
+ franchement (rc=183), donc il est refuse ici.
1293
+ """
1294
+ text = str(path)
1295
+ if "\n" in text or "\r" in text:
1296
+ raise EncodeConfigurationError(
1297
+ f"Chemin de frame contenant un saut de ligne, inexprimable en liste concat: {text!r}"
1298
+ )
1299
+ escaped = text.replace("'", "'\\''")
1300
+ return f"file '{escaped}'\n"
1301
+
1302
+
1303
+ def _unique_sibling(target: Path, suffix: str) -> Path:
1304
+ """Rend un nom temporaire, **dans le dossier de destination**.
1305
+
1306
+ Trois exigences mesurees tiennent dans ce nom:
1307
+
1308
+ * il vit a cote de la cible, parce que ``os.replace`` d'un ``/dev/shm``
1309
+ vers le depot rend ``OSError [Errno 18] Invalid cross-device link``;
1310
+ * il discrimine plus que le PID: ``os.getpid()`` seul ne separe pas deux
1311
+ encodages simultanes d'un **meme** processus vers la meme cible;
1312
+ * ``suffix`` est place en **dernier**, de sorte que le temporaire d'un
1313
+ master conserve l'extension du conteneur.
1314
+
1315
+ **La double extension est retiree ici aussi** (2026-09-07, finding ``C4``
1316
+ de la couche 2 de la revue du lot de tagage). ``_staged_output_path`` avait
1317
+ ete corrige la veille, mais **pas ce producteur-ci** -- et c'est lui qui
1318
+ fuit reellement : ``concat_list_file`` documente qu'un ``SIGTERM`` ou un
1319
+ ``SIGKILL`` laisse un temporaire derriere lui (``rc=143`` et ``rc=137``
1320
+ mesures, un residu a chaque fois), la ou le fichier d'attente de
1321
+ ``execute_plan`` est conserve **volontairement** et nomme dans le refus.
1322
+ Autrement dit la moitie corrigee etait la moitie visible, et la moitie
1323
+ oubliee etait la moitie qui traine.
1324
+
1325
+ Mesure : ``_unique_sibling(Path("m.mov"), ".mov")`` rendait
1326
+ ``.m.mov.10848-fcaa89f54c88.mov`` -- **la forme exacte** qu'Egan a
1327
+ signalee. Elle rend desormais ``.m.10848-fcaa89f54c88.mov``.
1328
+
1329
+ Le tronc n'est raccourci que si le nom porte **deja** ce suffixe-la : le
1330
+ second appelant vise ``<dossier>/frames`` avec le suffixe de liste
1331
+ ``concat``, ou rien ne se repete et ou le nom ne bouge pas. Et
1332
+ ``_TEMP_NAME_RE`` reconnait les deux formes -- le balayage ne perd donc ni
1333
+ les residus d'aujourd'hui ni ceux qu'Egan a deja sur son disque, ce qui
1334
+ serait le prix cache d'un motif rétréci.
1335
+ """
1336
+ unique = f"{os.getpid()}-{uuid.uuid4().hex[:12]}"
1337
+ tronc = target.stem if suffix and target.suffix == suffix else target.name
1338
+ return target.parent / f".{tronc}.{unique}{suffix}"
1339
+
1340
+
1341
+ @contextlib.contextmanager
1342
+ def concat_list_file(frame_paths: Sequence[Path | str], directory: Path | str) -> Iterator[Path]:
1343
+ """Ecrit un fichier de liste ``concat`` et le supprime **sur tout chemin**.
1344
+
1345
+ Portee exacte, et la formulation precedente ("reussite, echec ou
1346
+ interruption: aucun residu") etait trop large: le ``finally`` couvre la
1347
+ reussite, l'echec, toute exception et ``SIGINT`` (mesure: ``rc=130``, zero
1348
+ residu). Il ne couvre **ni ``SIGTERM`` ni ``SIGKILL``** -- mesure ``rc=143``
1349
+ et ``rc=137``, un fichier de liste **cache** subsiste a chaque fois -- parce
1350
+ qu'aucun ``finally`` de Python ne se deroule sur ces deux-la. C'est
1351
+ ``sweep_encode_residues`` qui repond de ce cas, et elle explique pourquoi il
1352
+ n'est pas benin.
1353
+
1354
+ ``directory`` est le dossier de **destination** et non celui des frames:
1355
+ ce dernier est une sortie d'`extract`, inventoriee et potentiellement
1356
+ balayee par la chaine, ou un fichier etranger n'a rien a faire.
1357
+
1358
+ Une liste **vide** est refusee ici plutot que laissee a `concat`, dont la
1359
+ reponse (``No files to concat``, rc=183) est opaque.
1360
+ """
1361
+ frames = list(frame_paths)
1362
+ if not frames:
1363
+ raise EncodeConfigurationError(
1364
+ "Sequence d'entree vide: rien a encoder (concat repondrait 'No files to concat')"
1365
+ )
1366
+ directory = Path(directory)
1367
+ list_path = _unique_sibling(directory / "frames", _CONCAT_LIST_SUFFIX)
1368
+ try:
1369
+ list_path.write_text(
1370
+ "".join(format_concat_entry(Path(frame).resolve()) for frame in frames),
1371
+ encoding="utf-8",
1372
+ )
1373
+ yield list_path
1374
+ finally:
1375
+ # Un `unlink` en echec ne doit pas remplacer l'erreur en cours: c'est le
1376
+ # meme motif que le `finally` de `run_encode`.
1377
+ with contextlib.suppress(OSError):
1378
+ list_path.unlink(missing_ok=True)
1379
+
1380
+
1381
+ def probe_frame_size(frame_path: Path | str, *, ffprobe_bin: str = "ffprobe") -> tuple[int, int]:
1382
+ """Rend ``(largeur, hauteur)`` d'une frame d'entree, via ffprobe.
1383
+
1384
+ L'absence du binaire est une ``ProbeUnavailableError``, jamais un
1385
+ ``FileNotFoundError`` nu: cette fonction est atteinte **avant**
1386
+ ``video_metadata`` dans ``run_encode``, et le cas symetrique (ffmpeg absent)
1387
+ est deja type. Sans cela, l'operateur lisait "toutes vos frames sont
1388
+ corrompues" pour un ffprobe manquant.
1389
+ """
1390
+ try:
1391
+ result = subprocess.run(
1392
+ [
1393
+ ffprobe_bin, "-v", "error", "-select_streams", "v:0",
1394
+ "-show_entries", "stream=width,height", "-of", "csv=p=0", "--", str(frame_path),
1395
+ ],
1396
+ capture_output=True,
1397
+ encoding="utf-8",
1398
+ errors="replace",
1399
+ )
1400
+ except OSError as exc:
1401
+ raise ProbeUnavailableError(
1402
+ f"Binaire ffprobe introuvable ou inexecutable: {ffprobe_bin!r} ({exc})"
1403
+ ) from exc
1404
+ fields = (result.stdout or "").strip().split(",")
1405
+ if result.returncode != 0 or len(fields) < 2:
1406
+ raise EncodeConfigurationError(
1407
+ f"Frame illisible: {str(frame_path)!r} ({(result.stderr or '').strip()[-200:]})"
1408
+ )
1409
+ try:
1410
+ largeur, hauteur = int(fields[0]), int(fields[1])
1411
+ except ValueError as exc:
1412
+ raise EncodeConfigurationError(
1413
+ f"Dimensions illisibles pour {str(frame_path)!r}: {result.stdout!r}"
1414
+ ) from exc
1415
+ # **Une dimension nulle est une frame illisible, pas une geometrie**, et
1416
+ # ffprobe ne le dit PAS par son code de retour : mesure le 2026-09-06 sur
1417
+ # un TIFF a l'en-tete tronque, il ecrit `Invalid TIFF header` sur la sortie
1418
+ # d'erreur, rend `0,0` sur la sortie standard et sort en **rc=0**. La garde
1419
+ # ci-dessus ne mord donc pas, et le `(0, 0)` traversait
1420
+ # `ensure_uniform_frame_shapes` jusqu'a `encode._same_aspect_ratio`, ou
1421
+ # `Fraction(0, 0)` levait un `ZeroDivisionError` -- exception que
1422
+ # `plan_encode` ne convertit pas en `EncodeDecisionError`. En TUI, cela ne
1423
+ # produisait ni un refus ni une issue mais une **chute** de l'application,
1424
+ # et `E4-3b` (« ce master existe deja ») devenait inatteignable des qu'une
1425
+ # seule frame du lot etait tronquee. Le refus rejoint donc celui du rc!=0,
1426
+ # qui existe deja et que `plan_encode` sait rendre a l'operateur.
1427
+ if largeur <= 0 or hauteur <= 0:
1428
+ raise EncodeConfigurationError(
1429
+ f"Frame illisible: {str(frame_path)!r} -- ffprobe rend "
1430
+ f"{largeur}x{hauteur}, ce qui n'est pas une geometrie "
1431
+ f"({(result.stderr or '').strip()[-200:]})"
1432
+ )
1433
+ return largeur, hauteur
1434
+
1435
+
1436
+ def ensure_uniform_frame_shapes(
1437
+ frame_paths: Sequence[Path | str],
1438
+ *,
1439
+ ffprobe_bin: str = "ffprobe",
1440
+ rappel_progression=None,
1441
+ ) -> tuple[int, int]:
1442
+ """Sonde **toutes** les entrees et refuse des formes heterogenes.
1443
+
1444
+ Une garde qui ne sonderait que la premiere frame ne verrait jamais rien:
1445
+ mesure, une liste ``[320x180, 321x181]`` sort en rc=0, master en 320x180,
1446
+ ``nb_frames=2``; la liste inversee sort en 321x181. La geometrie est figee
1447
+ par la **premiere** entree, les suivantes sont redimensionnees en silence,
1448
+ le rapport d'aspect change, et le controle de cardinal passe (2 = 2).
1449
+
1450
+ `rappel_progression` est **optionnel** (`AR3`, story 6.7): sans lui, rien
1451
+ ne change. Avec lui, il recoit un jalon `(faites, total)` **par frame
1452
+ reellement sondee**, ou `total` vaut `len(frames)` -- un cardinal de
1453
+ liste, exact par construction.
1454
+
1455
+ **Pourquoi cette phase-ci et pas une autre.** Elle sonde chaque frame par
1456
+ un `subprocess.run` de ffprobe, soit ~76 ms par frame: sur un lot de
1457
+ plusieurs centaines d'images, c'est la phase la plus longue AVANT
1458
+ l'encodage lui-meme, et la seule dont le cardinal soit connu d'avance.
1459
+
1460
+ Le canal est observationnel (`EPIC7-ARB-79`): un rappel qui leve est
1461
+ absorbe et journalise une seule fois, tandis que les pannes de sonde
1462
+ (`ProbeUnavailableError`, `EncodeConfigurationError`) traversent intactes,
1463
+ texte compris.
1464
+ """
1465
+ frames = list(frame_paths)
1466
+ if not frames:
1467
+ raise EncodeConfigurationError("Sequence d'entree vide: aucune forme a verifier")
1468
+ # Le canal ne s'ouvre qu'ICI, une fois le refus de sequence vide passe:
1469
+ # une sequence refusee ne doit produire aucun jalon, sans quoi l'ecran
1470
+ # afficherait une tache qui a commence alors que rien n'a ete sonde.
1471
+ emetteur = progression.EmetteurProgression(rappel_progression, len(frames))
1472
+ shapes: dict[tuple[int, int], list[str]] = {}
1473
+ for frame in frames:
1474
+ shape = probe_frame_size(frame, ffprobe_bin=ffprobe_bin)
1475
+ shapes.setdefault(shape, []).append(Path(frame).name)
1476
+ # Un jalon **apres** la sonde, et le numerateur est le cardinal REEL
1477
+ # de ce qui a ete sonde -- la somme des noms ranges par forme, jamais
1478
+ # un compteur de boucle qui divergerait sur une sonde en echec.
1479
+ emetteur.emettre(sum(len(noms) for noms in shapes.values()))
1480
+ if len(shapes) > 1:
1481
+ detail = "; ".join(
1482
+ f"{w}x{h}: {len(names)} frame(s), dont {names[0]}"
1483
+ for (w, h), names in sorted(shapes.items())
1484
+ )
1485
+ raise EncodeConfigurationError(
1486
+ f"Formes heterogenes dans la sequence d'entree ({detail}). "
1487
+ "ffmpeg fige la geometrie sur la premiere frame et redimensionne "
1488
+ "les suivantes en silence, sans que le cardinal ne bouge."
1489
+ )
1490
+ return next(iter(shapes))
1491
+
1492
+
1493
+ def frame_timecodes(frame_paths: Sequence[Path | str]) -> list[str] | None:
1494
+ """Rend les timecodes portes par les **noms** des frames, ou ``None``.
1495
+
1496
+ ``None`` signifie "la garde d'ordre n'est pas calculable sur cette
1497
+ sequence": il suffit qu'un seul nom ne porte pas de timecode. C'est le cas
1498
+ des fixtures de synthese et des artefacts du POC (`frame_0001.png`), jamais
1499
+ celui d'une sortie de `extract` ou de `scan`.
1500
+ """
1501
+ timecodes: list[str] = []
1502
+ for frame in frame_paths:
1503
+ match = _FRAME_TIMECODE_RE.search(Path(frame).stem)
1504
+ if match is None:
1505
+ return None
1506
+ timecodes.append(match.group(1))
1507
+ return timecodes
1508
+
1509
+
1510
+ def ensure_frame_order(frame_paths: Sequence[Path | str]) -> list[str] | None:
1511
+ """Refuse une sequence dont les timecodes ne progressent pas.
1512
+
1513
+ L'ancienne API prenait un motif ``frame_%04d.png``: **l'ordre etait impose
1514
+ par le demuxeur `image2`**. La nouvelle prend une ``Sequence`` et n'imposait
1515
+ plus rien, alors qu'aucune des quatre confrontations de
1516
+ ``verify_encoded_output`` -- cardinal, ``pix_fmt``, geometrie, timecode --
1517
+ ne depend de l'ordre. Mesure du 2026-08-10 sur les **memes** six frames:
1518
+ sequence nominale, melangee, inversee et six fois la meme frame sortent
1519
+ toutes en ``rc=0`` avec ``frame_count=6`` et un ``EncodeOutcome`` de
1520
+ succes. C'est le seul membre de la famille "un master qui ment sur son
1521
+ propre contenu" qui n'avait pas de garde, et la migration naturelle --
1522
+ ``glob.glob(dossier + "/*.tiff")`` -- rend l'ordre du **systeme de
1523
+ fichiers**, mesure ici comme `05,03,04,06,01,02`.
1524
+
1525
+ La comparaison est **non stricte**, et c'est delibere: deux frames
1526
+ consecutives de meme timecode sont une **frame tenue** -- le "shoot on
1527
+ twos" du stop-motion, ou chaque dessin est expose deux fois -- et c'est une
1528
+ sequence parfaitement valide. Une garde stricte, comme un cardinal
1529
+ deduplique, refuserait un master legitime. Ce qui est refuse, c'est une
1530
+ **regression** de timecode: melange, inversion, concatenation de deux lots.
1531
+
1532
+ Limite connue et assumee: une sequence qui franchirait `23:59:59:xx`
1533
+ reculerait legitimement et serait refusee. Le cas n'existe pas dans ce
1534
+ depot -- les lots sont des rushes, pas des journees continues -- et le refus
1535
+ serait bruyant, donc corrigible, la ou l'inversion silencieuse ne l'est pas.
1536
+ """
1537
+ timecodes = frame_timecodes(frame_paths)
1538
+ if timecodes is None:
1539
+ return None
1540
+ for index in range(1, len(timecodes)):
1541
+ if timecodes[index] < timecodes[index - 1]:
1542
+ previous, current = frame_paths[index - 1], frame_paths[index]
1543
+ raise EncodeConfigurationError(
1544
+ f"Ordre des frames non monotone entre {Path(previous).name!r} "
1545
+ f"({timecodes[index - 1]}) et {Path(current).name!r} ({timecodes[index]}): "
1546
+ "la sequence recule dans le temps. Un master melange ou inverse "
1547
+ "sort en rc=0 avec le bon cardinal, et rien en aval ne le rattrape."
1548
+ )
1549
+ return timecodes
1550
+
1551
+
1552
+ def _normalize_frame_paths(frame_paths: Sequence[Path | str]) -> list[Path]:
1553
+ """Rend la sequence en ``Path``, en refusant une chaine passee pour une liste.
1554
+
1555
+ Une ``str`` est une ``Sequence`` de caracteres: ``frames='frames/f01.tiff'``
1556
+ etait iteree **caractere par caractere**, et l'operateur lisait
1557
+ "Frame illisible: 'f'".
1558
+ """
1559
+ if isinstance(frame_paths, (str, bytes, Path)):
1560
+ raise EncodeConfigurationError(
1561
+ f"Sequence d'entree attendue (liste de chemins), recu {type(frame_paths).__name__}: "
1562
+ f"{frame_paths!r}. Un chemin unique s'ecrit [chemin]."
1563
+ )
1564
+ return [Path(frame) for frame in frame_paths]
1565
+
1566
+
1567
+ def _reject_duplicate_options(args: Sequence[str], where: str) -> None:
1568
+ """Refuse un doublon d'option dans un bloc d'arguments.
1569
+
1570
+ ffmpeg retient le **dernier** (mesure: ``-pix_fmt yuv420p -pix_fmt
1571
+ yuv444p`` sort en ``yuv444p``, ``-crf 51 -crf 10`` sort au poids de
1572
+ ``-crf 10``), donc l'ordre actuel -- ``extra_args`` en dernier -- est bien
1573
+ celui qui fait gagner l'override. Un doublon n'en reste pas moins le
1574
+ symptome d'un profil mal ecrit: on le dit au lieu de l'arbitrer.
1575
+ """
1576
+ seen: set[str] = set()
1577
+ for token in args:
1578
+ if not token.startswith("-") or token == "-":
1579
+ continue
1580
+ if token in seen:
1581
+ raise EncodeConfigurationError(
1582
+ f"Option {token!r} presente deux fois dans {where}: ffmpeg retiendrait "
1583
+ "la derniere valeur en silence. Corriger le profil plutot que l'arbitrer."
1584
+ )
1585
+ seen.add(token)
1586
+
1587
+
1588
+ def _reject_factory_owned_options(args: Sequence[str], where: str) -> None:
1589
+ """Refuse une option que la fabrique pose elle-meme du cote sortie.
1590
+
1591
+ ``_reject_duplicate_options`` ne voit qu'un bloc a la fois: un `-vf` place
1592
+ dans les ``extra_args`` d'un profil n'y est pas un doublon, et il en devient
1593
+ un dans la commande assemblee -- ou ffmpeg retient **le dernier**, donc
1594
+ celui du profil. Mesure: `-vf` en doublon sort en `rc=0`, `nb_frames=2`,
1595
+ `color_space=bt709`, c'est-a-dire un master **tague bt709 sans avoir subi
1596
+ la conversion**. `-r` et `-f` sont repris en main par le module, `-vf` ne
1597
+ l'etait pas: c'etait la seule option de sortie qu'un profil pouvait ecraser,
1598
+ et la seule dont l'ecrasement soit silencieux.
1599
+ """
1600
+ for token in args:
1601
+ if token in _FACTORY_OWNED_OPTIONS:
1602
+ raise EncodeConfigurationError(
1603
+ f"Option {token!r} posee par {where}: elle appartient a la fabrique, qui la "
1604
+ "pose elle-meme du cote sortie. ffmpeg retiendrait la derniere valeur, donc "
1605
+ "celle du profil, et un doublon de -vf produit un master tague sans conversion."
1606
+ )
1607
+
1608
+
1609
+ def check_container_tags(container_tags: dict | None) -> dict[str, str]:
1610
+ """Valide les tags conteneur, **cle comprise**, et rend le dictionnaire.
1611
+
1612
+ La valeur etait controlee, la cle partait telle quelle. Mesures du
1613
+ 2026-08-10, toutes en ``rc=0`` et sans un mot:
1614
+
1615
+ ``{'': 'valeur'}`` produit un tag de cle vide; ``{'a=b': 'valeur'}`` est
1616
+ **scinde sur le premier `=`** et relu ``{'a': 'b=valeur'}``; une valeur
1617
+ portant un saut de ligne forge un **second tag** pour tout consommateur
1618
+ lisant la sortie plate de ffprobe (``lot_id=A`` puis ``lot_id=FAUX``); et
1619
+ une cle reservee du muxer (``encoder``) est **silencieusement ignoree**, le
1620
+ muxer reecrivant la sienne.
1621
+
1622
+ Le dernier cas n'est pas fermable a la construction -- la liste des cles que
1623
+ chaque muxer se reserve n'est pas publiee -- il l'est **apres** encodage par
1624
+ la confrontation de ``verify_encoded_output``, qui relit les tags du master.
1625
+ """
1626
+ tags = dict(container_tags or {})
1627
+ for key, value in tags.items():
1628
+ if not isinstance(key, str) or key == "":
1629
+ raise EncodeConfigurationError(
1630
+ f"Cle de tag conteneur vide ou non textuelle: {key!r}"
1631
+ )
1632
+ if "=" in key:
1633
+ raise EncodeConfigurationError(
1634
+ f"Cle de tag conteneur contenant '=': {key!r}. ffmpeg scinde "
1635
+ "'-metadata cle=valeur' sur le premier '=', le tag serait tronque en silence."
1636
+ )
1637
+ if key != key.strip() or any(character in key for character in "\n\r"):
1638
+ raise EncodeConfigurationError(
1639
+ f"Cle de tag conteneur mal formee (blancs de bord ou saut de ligne): {key!r}"
1640
+ )
1641
+ if not isinstance(value, str) or value == "":
1642
+ # Une valeur vide produit un fichier ou le tag est simplement absent,
1643
+ # rc=0, sans avertissement: indistinguable d'une reinjection ratee.
1644
+ raise EncodeConfigurationError(f"Tag conteneur {key!r} vide ou non textuel: {value!r}")
1645
+ if any(character in value for character in "\n\r"):
1646
+ raise EncodeConfigurationError(
1647
+ f"Valeur du tag conteneur {key!r} contenant un saut de ligne: {value!r}. "
1648
+ "Elle forgerait un second tag pour tout lecteur de la sortie plate de ffprobe."
1649
+ )
1650
+ return tags
1651
+
1652
+
1653
+ def check_output_extension(profile: EncodeProfile, output_path: Path | str) -> str:
1654
+ """Refuse une extension qui contredit le conteneur du profil, et rend le muxer.
1655
+
1656
+ ``profile.container`` n'etait consulte que pour decider de ``-movflags``:
1657
+ mesure, ``h264_delivery -> deliv.mov`` rend rc=0 et ``prores_hq -> m.mkv``
1658
+ rend rc=0. Un master ProRes dans un ``.mkv`` est un fichier que la
1659
+ post-production refusera sans que rien ne l'ait signale.
1660
+ """
1661
+ try:
1662
+ muxer = CONTAINER_MUXERS[profile.container]
1663
+ except KeyError as exc:
1664
+ raise EncodeConfigurationError(
1665
+ f"Conteneur {profile.container!r} du profil {profile.profile_id!r} sans muxer "
1666
+ f"declare. Conteneurs connus: {sorted(CONTAINER_MUXERS)}"
1667
+ ) from exc
1668
+ suffix = Path(output_path).suffix.lower().lstrip(".")
1669
+ if suffix != profile.container:
1670
+ raise EncodeConfigurationError(
1671
+ f"Extension {'.' + suffix if suffix else '(aucune)'} du chemin de sortie "
1672
+ f"{str(output_path)!r} en contradiction avec le conteneur "
1673
+ f"{profile.container!r} du profil {profile.profile_id!r}"
1674
+ )
1675
+ return muxer
1676
+
1677
+
1678
+ def build_encode_command(
1679
+ profile_id: str,
1680
+ list_path: str,
1681
+ fps: float,
1682
+ output_path: str,
1683
+ *,
1684
+ target_size: tuple[int, int] | None = None,
1685
+ timecode: str | None = None,
1686
+ timecode_base_fps: float | int | str | Fraction | None = None,
1687
+ container_tags: dict[str, str] | None = None,
1688
+ overwrite: bool = False,
1689
+ ffmpeg_bin: str = "ffmpeg",
1690
+ progress_target: str | None = None,
1691
+ ) -> list[str]:
1692
+ """Rend l'argv ffmpeg complet encodant la sequence decrite par ``list_path``.
1693
+
1694
+ ``list_path`` designe un fichier de liste ``concat`` (voir
1695
+ ``concat_list_file``), et non plus un motif ``frame_%04d.png``: aucun
1696
+ fichier produit par la chaine v2 n'est atteignable par un ``%0Nd``
1697
+ -- ``extract`` ecrit ``<lot_id>_<hh-mm-ss-ff>.tiff``, ``scan`` ecrit
1698
+ ``scan_<lot_id>_<hh-mm-ss-ff>.tiff``, timecodes en base source a pas non
1699
+ unitaire (mesure sur le dossier reel: ``Could find no file with path ...``).
1700
+
1701
+ Trois points de la commande produite meritent d'etre lus:
1702
+
1703
+ * la cadence d'entree est posee en ``-r`` et non en ``-framerate``:
1704
+ l'option ``framerate`` **n'existe pas** sur le demuxeur ``concat``
1705
+ (mesure: ``Option framerate not found. Error opening input file``);
1706
+ * la **meme** valeur est posee des deux cotes. Le controle de cardinal ne
1707
+ vaut que si elles sont egales: mesure, ``-r 25`` en entree et ``-r 50``
1708
+ en sortie donnent ``nb_frames=8`` pour une liste de 4;
1709
+ * ``timecode_base_fps`` separe la base de validation du timecode de la
1710
+ cadence d'encodage. Les lots de ce depot portent un timecode en base
1711
+ **source** (``frame_selection.py``), si bien qu'un timecode parfaitement
1712
+ legal -- ``00:00:00:14`` sur un lot ``fps_target 12.5`` /
1713
+ ``timecode_base_fps 25/1`` -- etait rejete par la fabrique.
1714
+ ``validate_timecode`` n'est pas modifiee: elle est canonique et partagee,
1715
+ et son comportement est correct -- c'est son appelant qui lui passait la
1716
+ mauvaise cadence. (Le compte de modules qui figurait ici a ete retire au
1717
+ finding ``P22`` de la revue de la story 6.7: il annoncait six, la mesure
1718
+ du 2026-09-03 en trouve deux qui l'importent par son nom. Un nombre
1719
+ CORRIGE se reperime; c'est la formulation perissable qui part, pas
1720
+ seulement sa valeur -- politique de revue, section 7.)
1721
+
1722
+ Le chemin de sortie est confronte au conteneur du profil, et le muxer qui
1723
+ en decoule est pose explicitement en ``-f``: sans lui, un temporaire sans
1724
+ extension echoue a 100 %.
1725
+
1726
+ **``overwrite`` est un mecanisme, pas un commentaire.** Cette fonction est
1727
+ publique, la story la cite partout, et un script du depot lui passait deja un
1728
+ **vrai** chemin de sortie (`scripts/research/codec_metadata_experiment.py`):
1729
+ l'hypothese "l'appelant vient de forger ce chemin, ce n'est jamais le
1730
+ master" etait donc **deja violee**, dans une story dont le sujet est
1731
+ precisement qu'une hypothese non verifiee detruit un master. Deux gestes,
1732
+ et non un: le chemin de sortie deja present est **refuse a la construction**
1733
+ (``MasterAlreadyExistsError``), et la commande porte ``-n`` -- ffmpeg refuse
1734
+ alors d'ecraser, code de retour non nul -- au lieu du ``-y`` inconditionnel.
1735
+ ``overwrite=True`` retablit ``-y`` et n'est plus une decision de l'argv mais
1736
+ de l'appelant.
1737
+ """
1738
+ profile = get_profile(profile_id)
1739
+ rate = exact_frame_rate(fps)
1740
+ muxer = check_output_extension(profile, output_path)
1741
+ if target_size is not None:
1742
+ check_target_dimensions(profile, *_unpack_target_size(target_size))
1743
+ filter_chain = build_filter_chain(profile, target_size)
1744
+ tags = check_container_tags(container_tags)
1745
+
1746
+ video_args = profile.ffmpeg_video_args()
1747
+ _reject_duplicate_options(video_args, f"les arguments video du profil {profile_id!r}")
1748
+ _reject_factory_owned_options(video_args, f"le profil {profile_id!r}")
1749
+
1750
+ if not overwrite and Path(output_path).exists():
1751
+ raise MasterAlreadyExistsError(
1752
+ f"Le chemin de sortie {str(output_path)!r} existe deja. La fabrique n'ecrase pas "
1753
+ "sans qu'on le lui demande: passer overwrite=True, ou viser un chemin libre."
1754
+ )
1755
+ command = [ffmpeg_bin, "-y" if overwrite else "-n"]
1756
+ if progress_target is not None:
1757
+ # `-progress` est une option **globale**: elle se pose ici, apres le
1758
+ # `-n`/`-y` et avant toute option d'entree, ce qui ne deplace aucun
1759
+ # des quatre elements dont la position est mesuree ailleurs -- `-r`
1760
+ # d'entree avant `-i`, `-f concat -safe 0` colles a `-i`, `-r` de
1761
+ # sortie egal a celui d'entree, `-f <muxer>` juste avant le chemin.
1762
+ #
1763
+ # `None` laisse l'argv rigoureusement identique a celui d'avant la
1764
+ # story 6.7: c'est le repli `AR3`, et il se mesure par egalite de
1765
+ # listes, pas par l'absence du seul mot `-progress`.
1766
+ command.extend(["-progress", str(progress_target)])
1767
+ command.extend([
1768
+ "-r", rate,
1769
+ "-f", "concat", "-safe", "0",
1770
+ "-i", str(list_path),
1771
+ "-vf", filter_chain,
1772
+ ])
1773
+ command.extend(video_args)
1774
+ command.extend(["-r", rate])
1775
+ if timecode is not None:
1776
+ base = fps if timecode_base_fps is None else timecode_base_fps
1777
+ command.extend(["-timecode", validate_timecode(timecode, base)])
1778
+ for key, value in tags.items():
1779
+ command.extend(["-metadata", f"{key}={value}"])
1780
+ if tags and profile.container in ("mov", "mp4"):
1781
+ # Non-standard keys (rush_id, lot_id, ...) are silently dropped by the
1782
+ # mov/mp4 muxers unless this flag is set. Measured: with it, they survive.
1783
+ command.append("-movflags")
1784
+ command.append("use_metadata_tags")
1785
+ command.extend(["-f", muxer, str(output_path)])
1786
+ return command
1787
+
1788
+
1789
+ @dataclass(frozen=True)
1790
+ class EncodeOutcome:
1791
+ """Contexte d'un encodage **effectue**, distinct de la description du profil.
1792
+
1793
+ ``EncodeProfile.manifest_fields()`` decrit un profil et ne peut pas
1794
+ distinguer deux cadences: le dataclass est gele, il ne porte aucun champ de
1795
+ cadence et la methode ne prend aucun argument. Ce qui decrit un *encodage*
1796
+ -- cadence effective, geometrie cible, chaine de filtres reellement emise,
1797
+ chemin de sortie -- vit donc ici, et c'est ``encode_manifest_fields`` qui le
1798
+ rend. C'est cette fonction que la story 6.5 persistera.
1799
+ """
1800
+
1801
+ profile_id: str
1802
+ output_path: str
1803
+ frame_count: int
1804
+ frame_rate: str
1805
+ source_size: tuple[int, int]
1806
+ target_size: tuple[int, int] | None
1807
+ encoded_size: tuple[int, int]
1808
+ filter_chain: str
1809
+ pix_fmt: str
1810
+ timecode: str | None = None
1811
+ timecode_base: str | None = None
1812
+ #: Version du ffmpeg qui a **reellement** produit ce fichier, relevee a
1813
+ #: l'encodage par `probe_tool_version`. Portee jusqu'au **rapport de
1814
+ #: refus** -- et deliberement PAS au manifeste, voir le commentaire
1815
+ #: d'`encode_manifest_fields` (corrige le 2026-09-07, finding `C5` : ce
1816
+ #: commentaire disait « jusqu'au manifeste » a dix lignes de celui qui dit
1817
+ #: l'inverse, dans le meme fichier) -- parce que
1818
+ #: le comportement de tagage colorimetrique differe entre FFmpeg 6 et 8
1819
+ #: (`FFMPEG_COLOR_TAGGING_MEASUREMENT`): sans elle, un master mal tague ne
1820
+ #: dit pas sous quoi il a ete fabrique, et le diagnostic recommence a zero.
1821
+ #: Vaut `TOOL_VERSION_UNKNOWN` si le releve a echoue -- jamais une exception.
1822
+ ffmpeg_version: str = TOOL_VERSION_UNKNOWN
1823
+
1824
+
1825
+ def encode_manifest_fields(outcome: EncodeOutcome) -> dict:
1826
+ """Rend la description manifest d'un **encodage**, profil compris."""
1827
+ profile = get_profile(outcome.profile_id)
1828
+ fields = dict(profile.manifest_fields())
1829
+ fields.update(
1830
+ {
1831
+ "frame_rate": outcome.frame_rate,
1832
+ "frame_count": outcome.frame_count,
1833
+ "source_resolution": f"{outcome.source_size[0]}x{outcome.source_size[1]}",
1834
+ "target_resolution": (
1835
+ None if outcome.target_size is None
1836
+ else f"{outcome.target_size[0]}x{outcome.target_size[1]}"
1837
+ ),
1838
+ "encoded_resolution": f"{outcome.encoded_size[0]}x{outcome.encoded_size[1]}",
1839
+ "filter_chain": outcome.filter_chain,
1840
+ "pix_fmt": outcome.pix_fmt,
1841
+ "output_path": outcome.output_path,
1842
+ "timecode": outcome.timecode,
1843
+ "timecode_base": outcome.timecode_base,
1844
+ }
1845
+ )
1846
+ # **La version de ffmpeg n'entre PAS dans cette description, et c'est
1847
+ # mesure plutot que suppose** (2026-09-06). L'y mettre etait le geste
1848
+ # naturel -- un master est une mesure de comportement produit, donc il
1849
+ # devrait nommer l'outil qui l'a prise. Mais cette description est rendue
1850
+ # verbatim par `mmu encode`, et le dossier d'identite de
1851
+ # `tests/unit/test_encode_noyau.py` la gele a l'octet sur 25 invocations:
1852
+ # y poser une valeur qui **change d'une machine a l'autre** rendrait ce
1853
+ # temoin de reproductibilite faux partout ailleurs que sur la machine qui
1854
+ # l'a engendre. Le releve vit donc sur `EncodeOutcome.ffmpeg_version`, ou
1855
+ # tout appelant peut le lire, et il est nomme la ou il sert reellement au
1856
+ # diagnostic: le rapport de `VERIFICATION_TECHNIQUE_EN_ECHEC`.
1857
+ return fields
1858
+
1859
+
1860
+ def verify_encoded_output(
1861
+ video_path: Path | str,
1862
+ *,
1863
+ profile: EncodeProfile,
1864
+ expected_frames: int,
1865
+ expected_size: tuple[int, int] | None = None,
1866
+ expected_timecode: str | None = None,
1867
+ expected_tags: dict[str, str] | None = None,
1868
+ ffprobe_bin: str = "ffprobe",
1869
+ ) -> tuple[int, int, str]:
1870
+ """Confronte le fichier produit a ce qui a ete demande.
1871
+
1872
+ Rend ``(largeur, hauteur, pix_fmt)`` et leve ``EncodeVerificationError``
1873
+ sur toute divergence. Deux modes de panne silencieux sont vises:
1874
+
1875
+ * **la troncature**. Changer de demuxeur regle l'adressage des noms, pas la
1876
+ troncature: mesure, une liste de 2 entrees dont la seconde a disparu du
1877
+ disque sort en ``rc=0`` avec ``nb_frames=1``, exactement comme
1878
+ ``image2``. Ce controle est la **seule** protection;
1879
+ * **l'auto-negociation du format de pixel**. Un ``pix_fmt`` incompatible
1880
+ est absorbe en silence: mesure, ``prores_ks`` avec ``-pix_fmt yuv420p``
1881
+ sort en ``rc=0``, ``yuv422p10le``, avec un simple ``Incompatible pixel
1882
+ format ... auto-selecting format`` sur stderr;
1883
+ * **la reinterpretation du timecode**. Mesure du 2026-08-10, non prevue par
1884
+ la story: ffmpeg ne recopie pas la chaine de ``-timecode``, il la
1885
+ convertit en numero de frame puis la re-rend **a la cadence de sortie**.
1886
+ ``-timecode 00:00:00:14`` a ``-r 25/2`` produit donc un master dont le
1887
+ ``tmcd`` vaut ``00:00:01:01``, avec ``rc=0`` et sans un mot. Separer la
1888
+ base de validation de la cadence d'encodage (AC 14) rend le timecode de
1889
+ base source *acceptable* a la construction, mais ne le rend pas *juste*
1890
+ dans le conteneur: la conversion de base est une decision qui appartient
1891
+ a la story 6.1. Ici, on refuse simplement de livrer un master qui ment.
1892
+ Correction du 2026-08-10: la confrontation porte sur l'**image designee**
1893
+ (``timecodes_equivalent``) et non sur la chaine, parce que ffmpeg ecrit
1894
+ ``ff`` sur ``len(str(ceil(cadence) - 1))`` chiffres et non sur deux --
1895
+ ``00:00:00:4`` a 5 im/s est le **meme** timecode que le ``00:00:00:04``
1896
+ demande. Comparer les chaines refusait un master juste a 1, 5, 10 et 120
1897
+ im/s, c'est-a-dire sur trois des cinq cadences reelles du projet.
1898
+ * **la disparition d'un tag conteneur**. Les cles non standard sont
1899
+ "silently dropped" par les muxers mov/mp4 sans ``-movflags
1900
+ use_metadata_tags``, et une cle que le muxer se reserve (mesure:
1901
+ ``encoder``) est ignoree **meme avec** le drapeau: relu ``{}``, ``rc=0``,
1902
+ aucun signal. Le drapeau etait pose sans que rien ne verifie qu'il serve:
1903
+ s'il disparaissait, personne ne le verrait.
1904
+
1905
+ Une divergence est une **erreur typee**, jamais un avertissement.
1906
+ """
1907
+ # Import differe: `video_metadata` importe `codec_profiles` au chargement,
1908
+ # un import de module a module serait circulaire.
1909
+ from . import video_metadata
1910
+
1911
+ probe = video_metadata.probe_media(str(video_path), ffprobe_bin=ffprobe_bin)
1912
+ stream = None
1913
+ for candidate in probe.get("streams", []):
1914
+ if candidate.get("codec_type") == "video":
1915
+ stream = candidate
1916
+ break
1917
+ if stream is None:
1918
+ raise EncodeVerificationError(f"Aucun flux video dans {str(video_path)!r}")
1919
+
1920
+ # Un seul chemin de refus pour "absent" et "illisible": `int(None)` leve
1921
+ # deja `TypeError`, donc la branche `raw_count is None` qui precedait etait
1922
+ # **redondante** avec l'`except` qui la suivait -- seul le libelle changeait,
1923
+ # et aucun test ne pouvait les distinguer. Un mutant equivalent se retire.
1924
+ raw_count = stream.get("nb_frames")
1925
+ try:
1926
+ frame_count = int(raw_count)
1927
+ except (TypeError, ValueError) as exc:
1928
+ raise EncodeVerificationError(
1929
+ f"Cardinal absent ou illisible dans {str(video_path)!r}: {raw_count!r}. "
1930
+ "La troncature ne peut pas etre exclue."
1931
+ ) from exc
1932
+ if frame_count != expected_frames:
1933
+ raise EncodeVerificationError(
1934
+ f"Cardinal encode {frame_count} != cardinal attendu {expected_frames} pour "
1935
+ f"{str(video_path)!r}. ffmpeg a rendu un succes sur une sequence tronquee."
1936
+ )
1937
+
1938
+ pix_fmt = stream.get("pix_fmt")
1939
+ if pix_fmt != profile.pix_fmt:
1940
+ raise EncodeVerificationError(
1941
+ f"Format de pixel obtenu {pix_fmt!r} != format demande {profile.pix_fmt!r} "
1942
+ f"pour le profil {profile.profile_id!r}: ffmpeg a auto-negocie en silence."
1943
+ )
1944
+
1945
+ width, height = int(stream.get("width", 0)), int(stream.get("height", 0))
1946
+ if expected_size is not None and (width, height) != tuple(expected_size):
1947
+ raise EncodeVerificationError(
1948
+ f"Geometrie obtenue {width}x{height} != geometrie attendue "
1949
+ f"{expected_size[0]}x{expected_size[1]} pour {str(video_path)!r}"
1950
+ )
1951
+
1952
+ if expected_timecode is not None:
1953
+ # On reprend la recherche canonique de `video_metadata` plutot que d'en
1954
+ # ecrire une seconde: mov/mp4 posent le timecode sur les tags d'un
1955
+ # flux, MXF au niveau format, Matroska en majuscules.
1956
+ actual_timecode = video_metadata._find_timecode(probe)
1957
+ # Confrontation sur l'**image designee**, pas sur la chaine. Correction
1958
+ # du 2026-08-10: ffmpeg n'ecrit pas `ff` sur une largeur fixe (voir
1959
+ # `timecode_frame_field_width`), si bien que comparer les chaines
1960
+ # declarait non conforme un master parfaitement juste des que
1961
+ # `ceil(cadence)` sortait de la fenetre 11..100 -- soit trois des cinq
1962
+ # cadences reelles de ce depot. Un vrai desaccord reste detecte: seule
1963
+ # la largeur d'ecriture est absorbee, les cinq champs sont compares.
1964
+ if not timecodes_equivalent(actual_timecode, expected_timecode):
1965
+ raise EncodeVerificationError(
1966
+ f"Timecode obtenu {actual_timecode!r} != timecode demande "
1967
+ f"{expected_timecode!r} pour {str(video_path)!r}: ffmpeg re-rend "
1968
+ "la valeur de -timecode a la cadence de sortie. Convertir le "
1969
+ "timecode dans la base du master est une decision de cadrage "
1970
+ "(story 6.1), pas une correction a faire ici. La comparaison "
1971
+ "porte sur l'image designee et non sur la chaine: une simple "
1972
+ "difference de largeur du champ frames n'est pas ce refus."
1973
+ )
1974
+
1975
+ if expected_tags:
1976
+ written = (probe.get("format") or {}).get("tags") or {}
1977
+ divergent = {
1978
+ key: written.get(key)
1979
+ for key, value in expected_tags.items()
1980
+ if written.get(key) != value
1981
+ }
1982
+ if divergent:
1983
+ raise EncodeVerificationError(
1984
+ f"Tags conteneur absents ou divergents dans {str(video_path)!r}: "
1985
+ f"{divergent!r} au lieu de "
1986
+ f"{ {key: expected_tags[key] for key in divergent} !r}. "
1987
+ "Le muxer se reserve certaines cles et les ignore en silence."
1988
+ )
1989
+ return width, height, pix_fmt
1990
+
1991
+
1992
+ def _reserve_output_path(output_path: Path) -> None:
1993
+ """Reserve **atomiquement** le chemin de sortie, ou refuse.
1994
+
1995
+ ``Path.exists()`` suivi d'un ``os.replace`` est un TOCTOU, et il n'etait pas
1996
+ theorique: mesure du 2026-08-10, quatre ``run_encode`` concurrents en
1997
+ ``overwrite=False`` vers la meme cible rendent **quatre** ``EncodeOutcome``
1998
+ de succes, un seul master survit, et **aucun** appelant ne recoit
1999
+ ``MasterAlreadyExistsError``. Trois masters disparaissent avec un rapport de
2000
+ succes -- deux jobs sur la meme sortie, c'est la relance manuelle pendant un
2001
+ batch, situation que le `CLAUDE.md` decrit comme ordinaire.
2002
+
2003
+ ``O_CREAT | O_EXCL`` ferme la fenetre: la creation et le test d'existence
2004
+ sont le meme appel systeme. Le fichier vide ainsi pose est ensuite ecrase par
2005
+ l'``os.replace`` final, et retire par le ``finally`` si l'encodage echoue.
2006
+
2007
+ Ce qu'elle ne ferme pas, et il faut le dire: elle protege les appelants en
2008
+ ``overwrite=False`` les uns des autres. Un appelant en ``overwrite=True``
2009
+ ecrase par definition, et deux d'entre eux sur la meme cible restent une
2010
+ course -- gagnee par le dernier, ce qui est le contrat demande.
2011
+ """
2012
+ try:
2013
+ handle = os.open(output_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
2014
+ except FileExistsError as exc:
2015
+ raise MasterAlreadyExistsError(
2016
+ f"Le master {str(output_path)!r} existe deja. Relancer avec --overwrite "
2017
+ "pour l'ecraser explicitement."
2018
+ ) from exc
2019
+ except OSError as exc:
2020
+ raise EncodeConfigurationError(
2021
+ f"Chemin de sortie inutilisable: {str(output_path)!r} ({exc})"
2022
+ ) from exc
2023
+ os.close(handle)
2024
+
2025
+
2026
+ def sweep_encode_residues(
2027
+ directory: Path | str, *, max_age_seconds: float = 86400.0
2028
+ ) -> list[Path]:
2029
+ """Retire les residus laisses par un encodage **tue**, et rend leur liste.
2030
+
2031
+ Precision qui corrige deux docstrings de ce module: "reussite, echec ou
2032
+ interruption, aucun residu" est vrai pour une exception et pour ``SIGINT``
2033
+ (mesure: ``rc=130``, zero residu), et **faux pour ``SIGTERM``** -- le signal
2034
+ par defaut d'un ``kill``, d'un ``docker stop`` ou d'un ordonnanceur -- comme
2035
+ pour ``SIGKILL``: mesure ``rc=143`` et ``rc=137``, deux residus a chaque
2036
+ fois, un temporaire de master et un fichier de liste.
2037
+
2038
+ Le point qui rend le balayage necessaire plutot que cosmetique: ces residus
2039
+ sont des fichiers **caches** portant l'extension du conteneur, invisibles a
2040
+ ``ls`` dans le dossier des masters, de la taille d'un master partiel -- et
2041
+ ``Path.glob('*.mov')`` **les matche** (mesure), la ou ``glob.glob`` ne les
2042
+ matche pas. Or le depot emploie ``Path.glob`` et ``iterdir`` partout
2043
+ (`ffmpeg_utils.py`, `extraction.py`, `detection/aruco.py`). Un temporaire
2044
+ perime est donc, pour tout inventaire du depot, un master.
2045
+
2046
+ Le balayage n'est **pas** automatique: un temporaire vivant appartient a un
2047
+ autre encodage en cours. D'ou le seuil d'age, large par defaut (24 h), que
2048
+ l'appelant resserre s'il sait qu'aucun encodage ne tourne.
2049
+ """
2050
+ directory = Path(directory)
2051
+ if not directory.is_dir():
2052
+ return []
2053
+ cutoff = time.time() - max_age_seconds
2054
+ removed: list[Path] = []
2055
+ for candidate in directory.iterdir():
2056
+ if not candidate.name.startswith(".") or not candidate.is_file():
2057
+ continue
2058
+ if _TEMP_NAME_RE.search(candidate.name) is None:
2059
+ continue
2060
+ try:
2061
+ if candidate.stat().st_mtime > cutoff:
2062
+ continue
2063
+ candidate.unlink()
2064
+ except OSError:
2065
+ continue
2066
+ removed.append(candidate)
2067
+ return removed
2068
+
2069
+
2070
+ def _compter_sur_le_flux(flux, emetteur) -> None:
2071
+ """Emettre un jalon par ligne ``frame=<n>`` du flux ``-progress``.
2072
+
2073
+ **La lecture va toujours jusqu'au bout du flux**, meme quand elle cesse de
2074
+ comprendre ce qu'elle lit. Cesser de lire un tube qu'un sous-processus
2075
+ remplit encore le bloquerait des que le tampon est plein (64 Kio) -- c'est
2076
+ le defaut exact que le depot a deja paye sur ``stderr``, et il serait ici
2077
+ d'autant plus vicieux que le flux ``-progress`` d'un encodage de trente
2078
+ minutes pese quelque 700 Kio, soit dix fois la borne.
2079
+
2080
+ **La tolerance au format n'est pas ici, elle est dans l'emetteur.**
2081
+ ``EmetteurProgression.emettre`` fait lui-meme ``int(faites)`` sous garde:
2082
+ ``N/A``, une valeur vide ou un flottant ne produisent aucun jalon et ne
2083
+ levent rien. Ce module ne reimplemente donc aucune conversion -- il passe
2084
+ la valeur telle qu'elle a ete lue.
2085
+
2086
+ **Ce que l'absorption NE couvre PAS, et le mecanisme n'est pas ici.** Un
2087
+ arret demande -- ``SIGTERM`` par ``TerminaisonDemandee``, ``Ctrl-C`` par
2088
+ ``KeyboardInterrupt`` -- traverse intact, parce que les deux derivent de
2089
+ ``BaseException``: aucun ``except Exception``, ni celui-ci, ni celui du
2090
+ vidage, ni celui d'``EmetteurProgression.emettre`` en amont, ne les voit
2091
+ passer. C'est la seconde moitie d'``EPIC7-ARB-79``, et elle est tenue par
2092
+ une CLASSE DE BASE plutot que par une garde par site.
2093
+
2094
+ La difference n'est pas theorique et elle a ete mesuree: une garde posee
2095
+ ici seule fermait ce site et laissait ouverte la fenetre du **rappel**, ou
2096
+ l'arret restait avale. Le motif complet, avec ses deux mesures, vit sur
2097
+ ``encode_master.TerminaisonDemandee``.
2098
+ """
2099
+ if flux is None:
2100
+ return
2101
+ try:
2102
+ for ligne in flux:
2103
+ cle, separateur, valeur = ligne.partition("=")
2104
+ if separateur and cle.strip() == PROGRESS_FRAME_KEY:
2105
+ emetteur.emettre(valeur.strip())
2106
+ except Exception: # noqa: BLE001 -- l'observation ne casse jamais l'observe
2107
+ _JOURNAL.warning(
2108
+ "La lecture du flux de progression de ffmpeg a leve; les jalons "
2109
+ "sont abandonnes et l'encodage se poursuit.",
2110
+ exc_info=True,
2111
+ )
2112
+ # Vider le tube sans plus l'analyser: voir la docstring. L'absorption
2113
+ # est large -- si meme le vidage leve, le tube est rompu et il n'y a
2114
+ # plus rien a vider -- et elle s'arrete a la meme frontiere que
2115
+ # ci-dessus, sans avoir a la redire: un `SIGTERM` recu PENDANT le
2116
+ # vidage derive de `BaseException`, donc ce `suppress` ne le voit pas
2117
+ # plus que le `except` du dessus.
2118
+ with contextlib.suppress(Exception):
2119
+ for _ligne in flux:
2120
+ pass
2121
+
2122
+
2123
+ def _executer_ffmpeg_en_comptant(command, *, emetteur=None) -> tuple[int, str]:
2124
+ """Executer ``command`` en comptant les frames que ffmpeg declare encodees.
2125
+
2126
+ Rend ``(returncode, stderr)``. C'est la **couture** de la story 6.7: elle
2127
+ est appelable seule, donc mesurable avec un faux binaire lance par
2128
+ ``sys.executable``, sans vrai ffmpeg ni ``ensure_encoder_available``.
2129
+ Le modele litteral est ``ffmpeg_utils.run_with_written_file_progress``.
2130
+
2131
+ **Deux branches.** Sans emetteur actif -- c'est-a-dire la ligne de
2132
+ commande, seul chemin en production aujourd'hui -- la couture reprend
2133
+ **exactement** le geste d'avant la story: ``subprocess.run(command,
2134
+ capture_output=True, encoding="utf-8", errors="replace")``. Le repli
2135
+ ``AR3`` n'acquiert ainsi aucune dependance neuve, en particulier pas au
2136
+ repertoire temporaire systeme, et l'argv qu'il recoit ne porte meme pas
2137
+ ``-progress``.
2138
+
2139
+ **``stderr`` va dans un fichier, jamais dans un tube.** Deux tubes lus l'un
2140
+ apres l'autre se bloquent mutuellement: le processus attend qu'on lise
2141
+ celui qu'on ne lit pas. Le fichier supprime le tube, donc le blocage, et
2142
+ le texte relu est identique au caractere pres a celui que
2143
+ ``capture_output=True`` rend sur l'autre branche -- c'est ce qui permet aux
2144
+ deux ``EncodeRunError`` d'etre litteralement les memes des deux cotes.
2145
+
2146
+ **Aucun plafond de duree, et c'est une AC.** Un master 4K prend
2147
+ legitimement des dizaines de minutes; un ``timeout`` introduit ici
2148
+ romprait un encodage valide. La sortie de boucle est l'EOF du flux de
2149
+ progression, que ffmpeg ferme en terminant.
2150
+
2151
+ Aucun fil n'est cree: la lecture du flux **est** l'attente, et le
2152
+ ``finally`` garantit qu'aucun processus ne survit a l'appel, y compris
2153
+ apres une interruption clavier.
2154
+ """
2155
+ if emetteur is None or not emetteur.actif:
2156
+ resultat = subprocess.run(
2157
+ command, capture_output=True, encoding="utf-8", errors="replace"
2158
+ )
2159
+ return resultat.returncode, resultat.stderr or ""
2160
+
2161
+ descripteur, stderr_path = tempfile.mkstemp(prefix="mmu_encode_", suffix=".stderr")
2162
+ os.close(descripteur)
2163
+ try:
2164
+ with open(stderr_path, "wb") as flux_erreur:
2165
+ processus = subprocess.Popen(
2166
+ command,
2167
+ stdout=subprocess.PIPE,
2168
+ stderr=flux_erreur,
2169
+ encoding="utf-8",
2170
+ errors="replace",
2171
+ )
2172
+ try:
2173
+ _compter_sur_le_flux(processus.stdout, emetteur)
2174
+ processus.wait()
2175
+ finally:
2176
+ # Jamais de processus laisse vivant, meme si la lecture a ete
2177
+ # quittee par une exception (interruption clavier comprise).
2178
+ if processus.stdout is not None:
2179
+ with contextlib.suppress(OSError, ValueError):
2180
+ processus.stdout.close()
2181
+ if processus.poll() is None:
2182
+ processus.kill()
2183
+ processus.wait()
2184
+ return processus.returncode, Path(stderr_path).read_text(
2185
+ encoding="utf-8", errors="replace")
2186
+ finally:
2187
+ with contextlib.suppress(OSError):
2188
+ os.unlink(stderr_path)
2189
+
2190
+
2191
+ def run_encode(
2192
+ profile_id: str,
2193
+ frame_paths: Sequence[Path | str],
2194
+ fps: float,
2195
+ output_path: Path | str,
2196
+ *,
2197
+ target_size: tuple[int, int] | None = None,
2198
+ timecode: str | None = None,
2199
+ timecode_base_fps: float | int | str | Fraction | None = None,
2200
+ container_tags: dict[str, str] | None = None,
2201
+ overwrite: bool = False,
2202
+ ffmpeg_bin: str = "ffmpeg",
2203
+ ffprobe_bin: str = "ffprobe",
2204
+ rappel_progression=None,
2205
+ ) -> EncodeOutcome:
2206
+ """Encode ``frame_paths`` vers ``output_path`` sans jamais detruire l'existant.
2207
+
2208
+ ffmpeg tronque sa sortie **avant** d'ouvrir l'encodeur: mesure, un master
2209
+ valide de 11340 octets re-encode par une commande invalide vers le meme
2210
+ chemin sort en ``rc=187`` et laisse un master a **0 octet**. Le geste repris
2211
+ est celui du depot (``pdf_render``, ``io/extraction_manifest._atomic_write``):
2212
+ temporaire puis ``os.replace``, avec les corrections mesurees de la story --
2213
+ extension du conteneur conservee **et** ``-f`` explicite, temporaire dans le
2214
+ dossier de destination, nom plus discriminant que le PID, suppression en
2215
+ ``finally``.
2216
+
2217
+ Le controle de cardinal porte sur le **temporaire, avant bascule**: sur le
2218
+ chemin final il leverait son erreur alors que le master tronque a deja
2219
+ ecrase le precedent, ce qui annulerait la protection dans le seul cas ou
2220
+ elle sert.
2221
+
2222
+ Le cardinal attendu est ``len(frames)`` et **jamais** un cardinal deduplique:
2223
+ deux frames consecutives identiques sont une **frame tenue**, le "shoot on
2224
+ twos" du stop-motion, et une sequence parfaitement valide.
2225
+
2226
+ Deux refus protegent la destination, et ils ne font pas double emploi: un
2227
+ ``exists()`` precoce, qui est une **courtoisie** -- il evite de sonder mille
2228
+ frames pour rien -- et une **reservation atomique** juste avant l'encodage,
2229
+ qui est la garantie. Seule la seconde ferme la course entre deux encodages
2230
+ concurrents.
2231
+ """
2232
+ profile = get_profile(profile_id)
2233
+ frames = _normalize_frame_paths(frame_paths)
2234
+ output_path = Path(output_path)
2235
+ check_output_extension(profile, output_path)
2236
+ if output_path.is_dir():
2237
+ raise EncodeConfigurationError(
2238
+ f"Le chemin de sortie {str(output_path)!r} est un repertoire: la bascule finale "
2239
+ "leverait un IsADirectoryError nu, hors de la hierarchie d'erreurs du module."
2240
+ )
2241
+ if output_path.exists() and not overwrite:
2242
+ # Refus courtois et **non atomique**: il epargne la sonde de toutes les
2243
+ # frames. La garantie, elle, est la reservation posee plus bas.
2244
+ #
2245
+ # UNE SEULE ISSUE, ET C'EST DELIBERE (arbitrage d'Egan, 2026-08-31).
2246
+ # Ce refus n'est pas un dialogue avec l'operateur: la commande a deja
2247
+ # verifie la destination a son entree et lui a propose TROIS issues
2248
+ # (`encode.check_output_destination`). Celui-ci ne se declenche que si
2249
+ # le fichier est apparu DEPUIS -- c'est-a-dire si un autre encodage
2250
+ # s'est glisse dans l'intervalle, parfois plusieurs minutes d'analyse.
2251
+ #
2252
+ # Dans ce cas la bonne reponse est « recommencez », et non un menu: le
2253
+ # rang de version resolu au debut de la commande n'est plus fiable,
2254
+ # puisque quelqu'un vient de le consommer. Lui donner trois issues
2255
+ # ferait proposer un rang qui pourrait etre pris a son tour.
2256
+ #
2257
+ # Ce commentaire existe pour qu'une revue future ne rouvre pas ce site
2258
+ # comme un oubli: il est recense dans `DETTES_CONNUES` de
2259
+ # `test_conformite_sorties_nommees.py`, et le motif de sa presence y
2260
+ # est desormais nomme.
2261
+ raise MasterAlreadyExistsError(
2262
+ f"Le master {str(output_path)!r} existe deja. Relancer avec --overwrite "
2263
+ "pour l'ecraser explicitement."
2264
+ )
2265
+ ensure_encoder_available(profile, ffmpeg_bin)
2266
+
2267
+ # Refuse aussi la sequence vide: la garde vivait a trois endroits, dont deux
2268
+ # ne pouvaient jamais etre atteints en second. Un mutant equivalent se retire.
2269
+ # Le canal descend a la garde de forme, qui est la phase la plus longue
2270
+ # AVANT l'encodage lui-meme (~76 ms par frame, un ffprobe chacune) et la
2271
+ # seule dont le cardinal soit connu d'avance (story 6.7).
2272
+ source_size = ensure_uniform_frame_shapes(
2273
+ frames, ffprobe_bin=ffprobe_bin, rappel_progression=rappel_progression)
2274
+ ensure_frame_order(frames)
2275
+ encoded_size = (
2276
+ _unpack_target_size(target_size) if target_size is not None else source_size
2277
+ )
2278
+ check_target_dimensions(profile, *encoded_size)
2279
+ expected_tags = check_container_tags(container_tags)
2280
+
2281
+ output_path.parent.mkdir(parents=True, exist_ok=True)
2282
+ reserved = not overwrite
2283
+ if reserved:
2284
+ _reserve_output_path(output_path)
2285
+ replaced = False
2286
+ temp_output = _unique_sibling(output_path, f".{profile.container}")
2287
+ # UN SECOND emetteur, et non celui de la sonde de formes. Les deux phases
2288
+ # comptent chacune `len(frames)`: un emetteur partage ferait tomber en
2289
+ # silence tous les jalons de l'encodage -- ils seraient `<= dernier` --
2290
+ # et la barre se figerait pendant la phase longue. La barre est PAR ETAPE
2291
+ # dans le dessin de `E4-4`, donc le compteur repart, et c'est voulu.
2292
+ emetteur_encodage = progression.EmetteurProgression(
2293
+ rappel_progression, len(frames))
2294
+ try:
2295
+ with concat_list_file(frames, output_path.parent) as list_path:
2296
+ command = build_encode_command(
2297
+ profile_id,
2298
+ str(list_path),
2299
+ fps,
2300
+ str(temp_output),
2301
+ target_size=target_size,
2302
+ timecode=timecode,
2303
+ timecode_base_fps=timecode_base_fps,
2304
+ container_tags=container_tags,
2305
+ ffmpeg_bin=ffmpeg_bin,
2306
+ # L'argv du repli `AR3` reste celui d'avant la story, au
2307
+ # jeton pres: l'option n'apparait que si quelqu'un ecoute.
2308
+ progress_target=(PROGRESS_TARGET_STDOUT
2309
+ if emetteur_encodage.actif else None),
2310
+ )
2311
+ # Le processus est attendu DANS le `with`: le fichier de liste
2312
+ # `concat` doit vivre pendant toute la duree de l'encodage, et non
2313
+ # jusqu'au lancement seulement.
2314
+ returncode, stderr = _executer_ffmpeg_en_comptant(
2315
+ command, emetteur=emetteur_encodage)
2316
+ if returncode != 0:
2317
+ raise EncodeRunError(
2318
+ f"ffmpeg a echoue (code {returncode}) sur le profil {profile_id!r}: "
2319
+ f"{(stderr or '').strip()[-600:]}"
2320
+ )
2321
+ if not temp_output.exists():
2322
+ # Mesure du 2026-08-10, non prevue: `-n` devant un fichier deja
2323
+ # present rend **rc=0** ("File ... already exists. Exiting."), sans
2324
+ # rien encoder. Un code de retour nul ne prouve donc pas qu'un
2325
+ # fichier a ete produit, et sans ce controle la suite partirait
2326
+ # sonder un fichier absent -- en levant une erreur de `ffprobe`,
2327
+ # hors de la hierarchie de ce module.
2328
+ raise EncodeRunError(
2329
+ f"ffmpeg a rendu 0 sans produire {str(temp_output)!r} sur le profil "
2330
+ f"{profile_id!r}: {(stderr or '').strip()[-600:]}"
2331
+ )
2332
+ width, height, pix_fmt = verify_encoded_output(
2333
+ temp_output,
2334
+ profile=profile,
2335
+ expected_frames=len(frames),
2336
+ expected_size=encoded_size,
2337
+ expected_timecode=timecode,
2338
+ expected_tags=expected_tags,
2339
+ ffprobe_bin=ffprobe_bin,
2340
+ )
2341
+ os.replace(temp_output, output_path)
2342
+ replaced = True
2343
+ finally:
2344
+ # Le nettoyage ne doit **jamais** remplacer l'erreur typee en cours: un
2345
+ # `unlink` peut lever (ENAMETOOLONG mesure a partir de 233 caracteres de
2346
+ # nom, EPERM, systeme de fichiers en lecture seule), et l'OSError qui
2347
+ # remontait alors n'etait pas un `EncodeError` -- tout appelant ecrit en
2348
+ # `except EncodeError` la manquait.
2349
+ with contextlib.suppress(OSError):
2350
+ Path(temp_output).unlink(missing_ok=True)
2351
+ if reserved and not replaced:
2352
+ # La reservation est un fichier vide pose la ou il n'y avait rien:
2353
+ # la retirer ne peut donc pas detruire un master preexistant.
2354
+ with contextlib.suppress(OSError):
2355
+ output_path.unlink(missing_ok=True)
2356
+
2357
+ base = fps if timecode_base_fps is None else timecode_base_fps
2358
+ return EncodeOutcome(
2359
+ profile_id=profile_id,
2360
+ output_path=str(output_path),
2361
+ frame_count=len(frames),
2362
+ frame_rate=exact_frame_rate(fps),
2363
+ source_size=source_size,
2364
+ target_size=None if target_size is None else tuple(target_size),
2365
+ encoded_size=(width, height),
2366
+ filter_chain=build_filter_chain(profile, target_size),
2367
+ pix_fmt=pix_fmt,
2368
+ timecode=timecode,
2369
+ timecode_base=None if timecode is None else exact_frame_rate(base),
2370
+ # Releve du binaire **qui vient d'encoder**, pas d'un `ffmpeg` du PATH
2371
+ # suppose: `ffmpeg_bin` est celui que `build_encode_command` a employe.
2372
+ ffmpeg_version=probe_tool_version(ffmpeg_bin),
2373
+ )