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,1997 @@
1
+ """Previsualisation d'un rush a plusieurs cadences reduites (story 3.6).
2
+
3
+ Perimetre
4
+ ---------
5
+ Ce module est un **lecteur**, et rien d'autre: il decode un rush en memoire,
6
+ presente a leur echeance les frames que ``extract`` retiendrait, mesure
7
+ l'ecart au temps reel et rend un rapport chiffre. Il repond a une question
8
+ d'arbitrage precise -- « a quoi ressemble le mouvement de ce rush a 3, 5 ou
9
+ 12,5 im/s ? » -- et il ne produit rien.
10
+
11
+ **Une previz n'autorise rien.** Ce module ne cree ni lot, ni entree de
12
+ manifest, ni consentement, ni etat de confirmation reutilisable, et ne
13
+ declenche aucune extraction. Il n'importe ni ``io.extraction_manifest``, ni
14
+ ``io.project_layout``, ni la couche d'interaction de la story 3.3; il n'ecrit
15
+ `project.json` ni aucun autre fichier. Symetriquement il refuse une source
16
+ VFR et une cadence cible superieure a la cadence source **avec les memes
17
+ messages** que ``extract``, puisqu'il appelle les memes gardes: il est donc
18
+ aussi un pre-vol bon marche de l'extraction, jamais son autorisation.
19
+
20
+ **Rien n'est ecrit nulle part**, y compris sur interruption: aucun fichier
21
+ temporaire, aucune image materialisee, aucun encodage. Le chemin nominal
22
+ decode et affiche. ``Ctrl+C`` libere la capture et la fenetre dans un
23
+ ``finally``, emet un rapport **partiel** marque comme tel, et ne laisse aucune
24
+ trace Python.
25
+
26
+ Frontiere Epic 3 / Epic 7
27
+ -------------------------
28
+ Le choix de technologie d'interface de l'application finale appartient a
29
+ l'Epic 7 et n'est fixe nulle part a ce jour. Ce module ouvre une fenetre par
30
+ ``cv2.imshow``, c'est-a-dire par une bibliotheque **deja presente** et deja
31
+ importee par cinq fichiers du depot: ce n'est pas un choix d'architecture
32
+ d'interface, c'est l'affichage minimal disponible. Critere de faute,
33
+ verifiable en revue: **si cette story ajoute une dependance a
34
+ ``requirements.txt``, elle a preempte l'Epic 7.**
35
+
36
+ Corollaire assume: ``cv2.imshow`` ne fait pas une interface utilisateur. Pas
37
+ de menu, pas de barre de progression cliquable, pas de scrubbing. Un
38
+ affichage, une incrustation textuelle minimale, quelques touches. Le jour ou
39
+ l'Epic 7 choisit une technologie, il reprend le noyau de cadencement et de
40
+ mesure -- pur, sans affichage, teste -- et remplace le seul ``sink``.
41
+
42
+ Contrat de jonction avec 3.1 et 3.2
43
+ -----------------------------------
44
+ **La sequence presentee est exactement celle que ``extract`` retiendrait.**
45
+ Les indices viennent litteralement de ``frame_selection.select_source_frames``,
46
+ appele avec les memes entrees que ``extract``, obtenues par les memes
47
+ fonctions: ``video_metadata.probe_media`` -> ``extraction.qualify_source`` ->
48
+ ``extraction.resolve_source_frame_count``. Aucune regle d'echantillonnage,
49
+ aucun arrondi de cadence, aucun cardinal de frames n'est calcule ici.
50
+
51
+ Le cardinal source ne vient **jamais** de ``cv2.CAP_PROP_FRAME_COUNT``: cette
52
+ valeur est derivee, souvent estimee depuis la duree et la cadence, et diverge
53
+ du comptage exact selon le conteneur. L'utiliser produirait une selection
54
+ differente de celle de ``extract`` -- la faute exacte que cette story existe
55
+ pour empecher.
56
+
57
+ ``extraction.check_fps_form_consistency`` n'est **pas** appelee: c'est une
58
+ garde de nommage, or la previz ne nomme aucun fichier.
59
+
60
+ Decodage et cadencement
61
+ -----------------------
62
+ Decodage **sequentiel** par ``cv2.VideoCapture.read()``, rang compte a partir
63
+ de 0. ``cv2.CAP_PROP_POS_FRAMES`` est **proscrit**: le positionnement par
64
+ index sur un flux a images bidirectionnelles atterrit couramment sur une image
65
+ cle voisine, en silence. Pour rejouer ou revenir en arriere, la capture est
66
+ **rouverte**, jamais deplacee.
67
+
68
+ Chaque frame de rang ``r`` a un instant de presentation theorique
69
+ ``t_theo(r) = source_index(r) / fps_source``, exprime dans la base de temps
70
+ **source** (ARB-2): le rush previsualise garde donc la duree qu'il a a la
71
+ lecture normale, et la cadence cible apparait comme une **saccade**, pas comme
72
+ un ralenti. Les echeances sont **absolues** depuis l'origine de la lecture et
73
+ relevees sur ``time.monotonic()``; ``cv2.waitKey`` n'est pas un ordonnanceur,
74
+ son delai ne sert qu'a rendre la main a l'interface.
75
+
76
+ L'ecart au temps reel est **mesure et rapporte, jamais subi**: le rapport
77
+ final porte le retard maximal, median et p95, la derive finale, la cadence
78
+ effective et le taux de frames en retard. Une frame est en retard au-dela de
79
+ ``LATE_FRAME_TOLERANCE_S`` (20 ms, une image a 50 Hz), seuil **independant de
80
+ la cadence cible** pour que le critere ne se relache pas quand la cadence
81
+ baisse.
82
+
83
+ En cas de retard, **aucune frame de la selection n'est sautee en silence**.
84
+ Mode par defaut ``report``: toutes les frames retenues sont presentees, la
85
+ lecture dure plus longtemps que la duree nominale, l'ecart est chiffre.
86
+ Mode explicite ``skip``: les frames dont l'echeance est deja depassee peuvent
87
+ etre omises pour preserver le tempo percu, et le compte est chiffre dans le
88
+ rapport final, avec le code ``FRAMES_SKIPPED`` et la mention explicite que la
89
+ sequence vue n'etait pas la selection complete. Dans les deux cas, la session
90
+ reste un succes: un ecart mesure et rapporte n'est pas un echec.
91
+
92
+ Convention d'ecriture: messages en francais **sans accents**, y compris dans
93
+ l'incrustation a l'ecran, comme ``extraction.py`` et ``frame_selection.py``.
94
+ """
95
+
96
+ from __future__ import annotations
97
+
98
+ import logging
99
+ import os
100
+ import statistics
101
+ import sys
102
+ import time
103
+ from dataclasses import dataclass
104
+ from fractions import Fraction
105
+ from pathlib import Path
106
+ from typing import Any, Callable, Mapping, Sequence
107
+
108
+ import cv2
109
+
110
+ from . import extraction, video_metadata
111
+ from .codec_profiles import exact_frame_rate
112
+ from .extraction import ExtractionInputError
113
+ from .frame_selection import FrameSelection, select_source_frames
114
+ from .numeric_guards import is_strict_number
115
+
116
+ __all__ = [
117
+ "LATE_FRAME_TOLERANCE_S",
118
+ "LATE_FRAME_RATE_THRESHOLD",
119
+ "FINAL_DRIFT_RATIO_THRESHOLD",
120
+ "DEFAULT_PREVIEW_HEIGHT",
121
+ "DEFAULT_MEMORY_BUDGET_MB",
122
+ "ON_LATE_REPORT",
123
+ "ON_LATE_SKIP",
124
+ "ON_LATE_POLICIES",
125
+ "REALTIME_NOT_HELD",
126
+ "FRAMES_SKIPPED",
127
+ "DECODE_TRUNCATED",
128
+ "DISPLAY_UNAVAILABLE",
129
+ "CACHE_EVICTED",
130
+ "PLAYBACK_WARNING_CODES",
131
+ "KEY_NEXT",
132
+ "KEY_PREVIOUS",
133
+ "KEY_REPLAY",
134
+ "KEY_LOOP",
135
+ "KEY_QUIT",
136
+ "DisplayUnavailableError",
137
+ "ScheduledFrame",
138
+ "FrameObservation",
139
+ "PlaybackReport",
140
+ "PresentedFrame",
141
+ "PassOutcome",
142
+ "PrevizSession",
143
+ "PrevizResult",
144
+ "FrameCache",
145
+ "SequentialFrameReader",
146
+ "NullSink",
147
+ "RecordingSink",
148
+ "CvWindowSink",
149
+ "build_schedule",
150
+ "restrict_schedule",
151
+ "should_present",
152
+ "build_playback_report",
153
+ "format_report_lines",
154
+ "OVERLAY_RATE_TOLERANCE",
155
+ "OVERLAY_LOOP_ON",
156
+ "OVERLAY_LOOP_OFF",
157
+ "OverlayText",
158
+ "compute_current_rate",
159
+ "format_overlay",
160
+ "resize_to_height",
161
+ "ensure_display_available",
162
+ "prepare_previz",
163
+ "play_cadences",
164
+ ]
165
+
166
+
167
+ # --------------------------------------------------------------------------
168
+ # Constantes de contrat
169
+ # --------------------------------------------------------------------------
170
+
171
+ #: Tolerance de retard par frame presentee, en secondes. 20 ms, soit une image
172
+ #: a 50 Hz. Volontairement **independante de la cadence cible**: un seuil
173
+ #: proportionnel se relacherait mecaniquement a 3 im/s, or c'est precisement la
174
+ #: que la saccade est la plus lisible et le rendu le plus sensible.
175
+ LATE_FRAME_TOLERANCE_S = 0.020
176
+
177
+ #: Taux maximal de frames en retard au-dela duquel le temps reel n'est plus
178
+ #: declare tenu.
179
+ LATE_FRAME_RATE_THRESHOLD = 0.01
180
+
181
+ #: Derive finale maximale toleree, en fraction de la duree nominale.
182
+ FINAL_DRIFT_RATIO_THRESHOLD = 0.02
183
+
184
+ #: Hauteur d'affichage par defaut. Reglage **pose avant la lecture**, jamais
185
+ #: une degradation en vol: une resolution qui change en cours de lecture
186
+ #: modifierait la perception du mouvement au milieu de l'arbitrage.
187
+ #: Mesure du depot: une frame BGR pese 6,2 Mo en 1920x1080, 1,6 Mo en 960x540
188
+ #: et 0,4 Mo en 480x270.
189
+ DEFAULT_PREVIEW_HEIGHT = 540
190
+
191
+ #: Budget memoire par defaut du cache de frames decodees, en mebioctets.
192
+ #:
193
+ #: **Ce que le cache fait reellement**, mesure a la revue du 2026-08-05: il ne
194
+ #: fait economiser **aucun** decodage lorsqu'on passe d'une cadence a l'autre
195
+ #: (737 frames decodees avec cache, 737 sans), parce que deux cadences
196
+ #: differentes ne retiennent presque aucune frame source commune. Il ne sert
197
+ #: qu'au **rejeu a l'identique** de la meme cadence, ou il evite effectivement
198
+ #: de tout redecoder.
199
+ #:
200
+ #: Le defaut est donc ramene de 512 a 128 Mio (ARB, 2026-08-05, point 10):
201
+ #: reserver un demi-gigaoctet pour un gain qui n'existe pas sur l'usage annonce
202
+ #: -- comparer plusieurs cadences -- est un mauvais echange. A 960x540
203
+ #: (1,56 Mio par frame), 128 Mio retiennent environ 82 frames, ce qui couvre le
204
+ #: rejeu d'une passe courte. Le cache reste une **optimisation**, jamais une
205
+ #: condition de justesse: un defaut de cache redecode, il ne fausse rien.
206
+ DEFAULT_MEMORY_BUDGET_MB = 128
207
+
208
+ #: Politique de retard: presenter toutes les frames retenues et chiffrer
209
+ #: l'ecart (defaut), ou omettre les frames deja en retard pour preserver le
210
+ #: tempo percu (sur demande explicite seulement).
211
+ ON_LATE_REPORT = "report"
212
+ ON_LATE_SKIP = "skip"
213
+ ON_LATE_POLICIES = (ON_LATE_REPORT, ON_LATE_SKIP)
214
+
215
+ #: Vocabulaire **ferme** de codes d'avertissement, ASCII et stables.
216
+ REALTIME_NOT_HELD = "REALTIME_NOT_HELD"
217
+ FRAMES_SKIPPED = "FRAMES_SKIPPED"
218
+ DECODE_TRUNCATED = "DECODE_TRUNCATED"
219
+ DISPLAY_UNAVAILABLE = "DISPLAY_UNAVAILABLE"
220
+ CACHE_EVICTED = "CACHE_EVICTED"
221
+
222
+ #: Ordre canonique des codes: ``PlaybackReport.warnings`` en est toujours un
223
+ #: sous-ensemble, dans cet ordre, sans doublon.
224
+ PLAYBACK_WARNING_CODES = (
225
+ REALTIME_NOT_HELD,
226
+ FRAMES_SKIPPED,
227
+ DECODE_TRUNCATED,
228
+ DISPLAY_UNAVAILABLE,
229
+ CACHE_EVICTED,
230
+ )
231
+
232
+ #: Touches de navigation, en codes ASCII (ce que rend ``cv2.waitKey`` masque a
233
+ #: 8 bits). Les fleches ne sont pas portables d'un backend a l'autre: les
234
+ #: lettres le sont.
235
+ KEY_NEXT = ord("n")
236
+ KEY_PREVIOUS = ord("p")
237
+ KEY_REPLAY = ord("r")
238
+ #: Bascule de la **lecture en boucle** (story 11.4, lot K2). Elle est nee d'un
239
+ #: mensonge d'interface trouve par Egan en testant le produit a la main le
240
+ #: 2026-08-30: l'atelier Extraction annoncait `L boucle` dans sa legende de
241
+ #: fenetre alors qu'aucune touche de boucle n'existait ici, et qu'aucune notion
242
+ #: de boucle n'existait dans la previz. `L` ne faisait donc pas « rien » au
243
+ #: sens strict -- comme toute touche non liee, elle tombait dans la branche par
244
+ #: defaut de `play_cadences` et faisait avancer d'une cadence, exactement comme
245
+ #: `n`. Une touche qui fait autre chose que ce qu'elle promet est pire qu'une
246
+ #: touche morte.
247
+ #:
248
+ #: **La lettre est NUE, jamais `maj+lettre`** (consigne d'Egan du 2026-08-30):
249
+ #: l'affichage se fait en majuscule pour la lisibilite de la legende, la touche
250
+ #: liee est la minuscule. `ord("L")` (76) n'est deliberement PAS liee, et ce
251
+ #: n'est pas un oubli: lier les deux ferait de la seule touche de l'interface
252
+ #: sensible a `Maj` un cas particulier invisible, et surtout `cv2.waitKey`
253
+ #: masque a 8 bits sans normaliser la casse -- deux codes, deux entrees, deux
254
+ #: chemins a mesurer pour un gain nul. `L` majuscule conserve donc le
255
+ #: comportement qu'elle a toujours eu: elle passe a la cadence suivante, comme
256
+ #: toute touche non liee.
257
+ KEY_LOOP = ord("l")
258
+ KEY_QUIT = ord("q")
259
+ _KEY_ESCAPE = 27
260
+
261
+ _BYTES_PER_MB = 1024 * 1024
262
+
263
+
264
+ class DisplayUnavailableError(RuntimeError):
265
+ """Aucun affichage exploitable. Mappe vers le code de sortie 2.
266
+
267
+ Prerequis externe absent, au meme titre qu'un binaire manquant: ce n'est ni
268
+ une erreur d'entree (code 1) ni un echec de traitement. Detectee **en
269
+ amont**, jamais laissee remonter en ``cv2.error`` illisible.
270
+
271
+ Le refus se dit en **deux morceaux** (``EPIC11-ARB-73``, story 11.4):
272
+
273
+ * ``motif`` -- ce qui ne va pas, et rien d'autre. Il ne nomme **aucune
274
+ option de ligne de commande**;
275
+ * ``conseil`` -- ce qu'on peut faire, formule pour la CLI, donc en nommant
276
+ ``--no-display``.
277
+
278
+ ``str(exception)`` reste la concatenation des deux, inchangee au caractere
279
+ pres: la CLI imprime exactement ce qu'elle imprimait. La separation existe
280
+ pour l'autre appelant -- la TUI, qui **n'expose pas** ``--no-display``
281
+ (``EPIC11-ARB-41``) et ne peut donc ni rendre ce message verbatim, ni le
282
+ tronquer sans en produire une seconde redaction, qui divergerait.
283
+
284
+ Une construction a un seul argument reste valide: le message entier est
285
+ alors le motif, et le conseil est vide.
286
+ """
287
+
288
+ def __init__(self, motif: str, conseil: str = "") -> None:
289
+ super().__init__(f"{motif} {conseil}" if conseil else motif)
290
+ #: La phrase de refus, sans conseil d'option. Lue par la TUI.
291
+ self.motif = motif
292
+ #: Le conseil de la CLI, qui nomme l'option. Lu par personne d'autre.
293
+ self.conseil = conseil
294
+
295
+
296
+ # --------------------------------------------------------------------------
297
+ # Noyau pur: echeancier
298
+ # --------------------------------------------------------------------------
299
+
300
+
301
+ @dataclass(frozen=True)
302
+ class ScheduledFrame:
303
+ """Une frame retenue et son instant de presentation theorique.
304
+
305
+ ``source_index`` et ``output_rank`` viennent litteralement de la story 3.2
306
+ et ne sont jamais recalcules ici. ``t_theo_s`` est exprime dans la base de
307
+ temps **source**.
308
+ """
309
+
310
+ output_rank: int
311
+ source_index: int
312
+ frame_timecode: str
313
+ t_theo_s: float
314
+
315
+
316
+ def build_schedule(
317
+ selection: FrameSelection, fps_source: Fraction | None = None
318
+ ) -> tuple[ScheduledFrame, ...]:
319
+ """Rendre l'echeancier de presentation d'une selection. Fonction **pure**.
320
+
321
+ ``t_theo(r) = source_index(r) / fps_source``, calcule en ``Fraction`` puis
322
+ converti une seule fois en ``float`` a la frontiere: aucun flottant
323
+ n'arbitre, et l'accumulation d'erreur d'un cumul d'intervalles est exclue
324
+ par construction (chaque instant est calcule depuis l'origine).
325
+
326
+ La base de temps est celle de la **source**, pas celle de la cadence
327
+ cible: c'est ce qui donne au rush previsualise la meme duree qu'a la
328
+ lecture normale et fait apparaitre la cadence cible comme une saccade et
329
+ non comme un ralenti (ARB-2).
330
+ """
331
+ rate = Fraction(selection.fps_source if fps_source is None else fps_source)
332
+ if rate <= 0:
333
+ raise ExtractionInputError(
334
+ f"Cadence source invalide pour l'echeancier: {rate!r}. Attendu une "
335
+ "cadence strictement positive."
336
+ )
337
+ return tuple(
338
+ ScheduledFrame(
339
+ output_rank=frame.output_rank,
340
+ source_index=frame.source_index,
341
+ frame_timecode=frame.frame_timecode,
342
+ t_theo_s=float(Fraction(frame.source_index) / rate),
343
+ )
344
+ for frame in selection
345
+ )
346
+
347
+
348
+ def restrict_schedule(
349
+ schedule: Sequence[ScheduledFrame],
350
+ *,
351
+ start_seconds: float | None = None,
352
+ duration_seconds: float | None = None,
353
+ ) -> tuple[ScheduledFrame, ...]:
354
+ """Restreindre l'echeancier a une fenetre temporelle. Fonction **pure**.
355
+
356
+ Semantique retenue (question ouverte 1 de la story 3.6, recommandation
357
+ suivie): cette fonction ne touche **jamais** a la selection, elle ne fait
358
+ que restreindre ce qui en est **presente**. Les ``source_index`` restent
359
+ donc rigoureusement ceux que ``extract`` retiendrait -- toute autre
360
+ semantique (selectionner sur la plage affichee) montrerait a Egan des
361
+ frames qu'il n'obtiendra jamais et violerait l'AC 2 de la story 3.6.
362
+
363
+ Depuis la story 3.7, la selection qu'elle recoit peut elle-meme etre
364
+ bornee par ``--in`` / ``--out``, auquel cas elle ne porte plus sur le rush
365
+ entier. Cela ne change rien ici, et c'est tout l'interet de la separation:
366
+ ``--in`` / ``--out`` decident **ce qui est retenu** (dans le noyau 3.2),
367
+ ``--start`` / ``--duration`` decident **ce qui est joue** parmi les
368
+ retenues (ici). Cette docstring affirmait encore « la selection est
369
+ calculee sur le rush entier », ce que la story 3.7 avait rendu faux
370
+ (revue du 2026-08-06).
371
+
372
+ Les ``t_theo_s`` sont rebases sur la premiere frame retenue, pour que la
373
+ lecture demarre immediatement et non apres ``start_seconds`` d'ecran fixe.
374
+ Le rebasement ne touche **que** l'instant de presentation.
375
+ """
376
+ if start_seconds is not None and start_seconds < 0:
377
+ raise ExtractionInputError(
378
+ f"Parametre --start invalide: {start_seconds!r}. Attendu un nombre de "
379
+ "secondes positif ou nul."
380
+ )
381
+ if duration_seconds is not None and duration_seconds <= 0:
382
+ raise ExtractionInputError(
383
+ f"Parametre --duration invalide: {duration_seconds!r}. Attendu un nombre "
384
+ "de secondes strictement positif."
385
+ )
386
+
387
+ start = 0.0 if start_seconds is None else float(start_seconds)
388
+ end = None if duration_seconds is None else start + float(duration_seconds)
389
+
390
+ retained = [
391
+ frame
392
+ for frame in schedule
393
+ if frame.t_theo_s >= start and (end is None or frame.t_theo_s < end)
394
+ ]
395
+ if not retained:
396
+ return ()
397
+ offset = retained[0].t_theo_s
398
+ return tuple(
399
+ ScheduledFrame(
400
+ output_rank=frame.output_rank,
401
+ source_index=frame.source_index,
402
+ frame_timecode=frame.frame_timecode,
403
+ t_theo_s=frame.t_theo_s - offset,
404
+ )
405
+ for frame in retained
406
+ )
407
+
408
+
409
+ def should_present(
410
+ *,
411
+ t_theo_s: float,
412
+ elapsed_s: float,
413
+ on_late: str = ON_LATE_REPORT,
414
+ tolerance_s: float = LATE_FRAME_TOLERANCE_S,
415
+ ) -> bool:
416
+ """Decider si une frame doit etre presentee ou omise. Fonction **pure**.
417
+
418
+ En mode ``report`` (defaut) la reponse est **toujours** ``True``: omettre
419
+ une frame retenue sans le dire ferait voir une sequence differente de celle
420
+ qu'on croit arbitrer. En mode ``skip``, une frame dont l'echeance est
421
+ depassee de plus de ``tolerance_s`` est omise, et ce choix explicite est
422
+ compte, affiche pendant la lecture et rappele dans le rapport.
423
+ """
424
+ if on_late not in ON_LATE_POLICIES:
425
+ raise ExtractionInputError(
426
+ f"Politique de retard inconnue: {on_late!r}. Valeurs admises: "
427
+ f"{', '.join(ON_LATE_POLICIES)}."
428
+ )
429
+ if on_late == ON_LATE_REPORT:
430
+ return True
431
+ return (elapsed_s - t_theo_s) <= tolerance_s
432
+
433
+
434
+ # --------------------------------------------------------------------------
435
+ # Noyau pur: mesure du temps reel
436
+ # --------------------------------------------------------------------------
437
+
438
+
439
+ @dataclass(frozen=True)
440
+ class FrameObservation:
441
+ """Une frame effectivement remise au ``sink``, et quand elle l'a ete."""
442
+
443
+ output_rank: int
444
+ source_index: int
445
+ t_theo_s: float
446
+ t_real_s: float
447
+
448
+ @property
449
+ def lateness_s(self) -> float:
450
+ """Retard contre l'echeance **absolue**, jamais contre la frame precedente.
451
+
452
+ Un ecart mesure d'intervalle a intervalle masquerait une derive lente
453
+ qui, sur vingt secondes, decale la fin de plusieurs images.
454
+ """
455
+ return self.t_real_s - self.t_theo_s
456
+
457
+
458
+ @dataclass(frozen=True)
459
+ class PlaybackReport:
460
+ """Rapport chiffre d'une passe de lecture.
461
+
462
+ Ce rapport n'est **pas** persiste, ne devient pas un artefact du projet et
463
+ ne vaut aucune autorisation. Il est affiche, point.
464
+ """
465
+
466
+ fps_target: float
467
+ frames_presentees: int
468
+ frames_attendues: int
469
+ frames_omises: int
470
+ retard_max_s: float
471
+ retard_median_s: float
472
+ retard_p95_s: float
473
+ derive_finale_s: float
474
+ duree_nominale_s: float
475
+ duree_reelle_s: float
476
+ amorcage_s: float
477
+ cadence_effective: float | None
478
+ taux_frames_en_retard: float
479
+ temps_reel_tenu: bool
480
+ partiel: bool
481
+ warnings: tuple[str, ...]
482
+
483
+
484
+ def _percentile_95(values: Sequence[float]) -> float:
485
+ """p95 par rang le plus proche (``ceil(0.95 * n)``), sans interpolation.
486
+
487
+ Pas d'interpolation: sur une serie de retards, une valeur interpolee entre
488
+ deux mesures n'a jamais ete observee, et l'objet du rapport est de dire ce
489
+ qui s'est passe.
490
+ """
491
+ if not values:
492
+ return 0.0
493
+ ordered = sorted(values)
494
+ rank = max(1, -(-95 * len(ordered) // 100))
495
+ return ordered[rank - 1]
496
+
497
+
498
+ def build_playback_report(
499
+ observations: Sequence[FrameObservation],
500
+ *,
501
+ fps_target: float,
502
+ frames_attendues: int,
503
+ duree_nominale_s: float,
504
+ frames_omises: int = 0,
505
+ partiel: bool = False,
506
+ extra_warnings: Sequence[str] = (),
507
+ amorcage_s: float = 0.0,
508
+ late_tolerance_s: float = LATE_FRAME_TOLERANCE_S,
509
+ ) -> PlaybackReport:
510
+ """Agreger les instants observes en un rapport. Fonction **pure**.
511
+
512
+ Aucune horloge, aucun affichage, aucune I/O: la fonction ne voit que la
513
+ liste des instants deja releves, ce qui la rend testable sur des series
514
+ fabriquees a la main.
515
+
516
+ Verdict de session: le temps reel est declare **tenu** si et seulement si
517
+ ``taux_frames_en_retard <= LATE_FRAME_RATE_THRESHOLD`` **et**
518
+ ``abs(derive_finale_s) <= FINAL_DRIFT_RATIO_THRESHOLD * duree_nominale_s``.
519
+ Quand la duree nominale est nulle (une seule frame retenue), le second
520
+ critere se replie sur la tolerance par frame, faute de quoi le moindre
521
+ retard rendrait le verdict impossible a satisfaire.
522
+ """
523
+ lateness = [observation.lateness_s for observation in observations]
524
+ presentees = len(observations)
525
+
526
+ if lateness:
527
+ retard_max = max(lateness)
528
+ retard_median = float(statistics.median(lateness))
529
+ retard_p95 = _percentile_95(lateness)
530
+ late_count = sum(1 for value in lateness if value > late_tolerance_s)
531
+ # Une frame omise par `--on-late skip` l'a ete **parce que** son
532
+ # echeance etait deja depassee: c'est une preuve directe de non-tenue,
533
+ # pas une absence de mesure. Ne rapporter le retard qu'aux seules
534
+ # frames presentees laissait le mode `skip` effacer le constat qu'il
535
+ # est cense declencher: sur un rush 4K decode trop lentement, une frame
536
+ # presentee sur cent-cinquante et cent-quarante-neuf omises donnaient
537
+ # `temps reel : tenu` sans `REALTIME_NOT_HELD` (revue du 2026-08-05).
538
+ # En mode `report`, `frames_omises` vaut zero et rien ne change.
539
+ juges = presentees + int(frames_omises)
540
+ taux = (late_count + int(frames_omises)) / juges if juges else 0.0
541
+ derive = lateness[-1]
542
+ duree_reelle = observations[-1].t_real_s
543
+ else:
544
+ retard_max = retard_median = retard_p95 = 0.0
545
+ taux = 0.0
546
+ derive = 0.0
547
+ duree_reelle = 0.0
548
+
549
+ drift_budget = FINAL_DRIFT_RATIO_THRESHOLD * duree_nominale_s
550
+ if drift_budget <= 0:
551
+ drift_budget = late_tolerance_s
552
+ tenu = (
553
+ taux <= LATE_FRAME_RATE_THRESHOLD
554
+ and abs(derive) <= drift_budget
555
+ and (presentees > 0 or frames_attendues == 0)
556
+ )
557
+
558
+ emitted = set(extra_warnings)
559
+ if not tenu:
560
+ emitted.add(REALTIME_NOT_HELD)
561
+ if frames_omises > 0:
562
+ emitted.add(FRAMES_SKIPPED)
563
+
564
+ return PlaybackReport(
565
+ fps_target=float(fps_target),
566
+ frames_presentees=presentees,
567
+ frames_attendues=int(frames_attendues),
568
+ frames_omises=int(frames_omises),
569
+ retard_max_s=retard_max,
570
+ retard_median_s=retard_median,
571
+ retard_p95_s=retard_p95,
572
+ derive_finale_s=derive,
573
+ duree_nominale_s=float(duree_nominale_s),
574
+ duree_reelle_s=duree_reelle,
575
+ amorcage_s=float(amorcage_s),
576
+ # Cadence mesuree = nombre d'INTERVALLES sur la duree, pas nombre de
577
+ # frames sur la duree: n frames delimitent n-1 intervalles, et compter
578
+ # les frames surestime systematiquement la cadence (30 frames en 9,6 s
579
+ # donnent 3,01 im/s, pas 3,11). Indefinie sous deux frames.
580
+ # `None`, et non `0.0`: sous deux frames la grandeur n'est pas
581
+ # definie, et l'imprimer a zero la presenterait comme une mesure
582
+ # (revue du 2026-08-05).
583
+ cadence_effective=(
584
+ (presentees - 1) / duree_reelle
585
+ if presentees >= 2 and duree_reelle > 0
586
+ else None
587
+ ),
588
+ taux_frames_en_retard=taux,
589
+ temps_reel_tenu=tenu,
590
+ partiel=bool(partiel),
591
+ warnings=tuple(code for code in PLAYBACK_WARNING_CODES if code in emitted),
592
+ )
593
+
594
+
595
+ def format_report_lines(report: PlaybackReport) -> tuple[str, ...]:
596
+ """Rendre le rapport en lignes de texte ASCII, sans accents. **Pure**."""
597
+ lines = [
598
+ f"Cadence {report.fps_target:g} im/s"
599
+ + (" -- LECTURE PARTIELLE (session interrompue)" if report.partiel else ""),
600
+ f" frames presentees : {report.frames_presentees} / "
601
+ f"{report.frames_attendues} attendues",
602
+ f" frames omises : {report.frames_omises}",
603
+ f" retard max / median / p95 : {report.retard_max_s * 1000:.1f} / "
604
+ f"{report.retard_median_s * 1000:.1f} / {report.retard_p95_s * 1000:.1f} ms",
605
+ f" derive finale : {report.derive_finale_s * 1000:.1f} ms "
606
+ f"(duree nominale {report.duree_nominale_s:.3f} s, "
607
+ f"reelle {report.duree_reelle_s:.3f} s)",
608
+ f" amorcage : {report.amorcage_s * 1000:.1f} ms "
609
+ "(decodage jusqu'a la premiere frame presentee, hors cadencement)",
610
+ " cadence effective : "
611
+ + (
612
+ "indefinie (moins de deux frames presentees)"
613
+ if report.cadence_effective is None
614
+ else f"{report.cadence_effective:.3f} im/s"
615
+ ),
616
+ f" frames en retard : {report.taux_frames_en_retard * 100:.2f} % "
617
+ f"(seuil {LATE_FRAME_TOLERANCE_S * 1000:.0f} ms par frame)",
618
+ f" temps reel : {'tenu' if report.temps_reel_tenu else 'NON TENU'}",
619
+ ]
620
+ if report.warnings:
621
+ lines.append(f" codes : {', '.join(report.warnings)}")
622
+ if FRAMES_SKIPPED in report.warnings:
623
+ lines.append(
624
+ " ATTENTION: la sequence vue n'etait PAS la selection complete "
625
+ f"({report.frames_omises} frame(s) omise(s) par --on-late skip)."
626
+ )
627
+ return tuple(lines)
628
+
629
+
630
+ # --------------------------------------------------------------------------
631
+ # Presentation
632
+ # --------------------------------------------------------------------------
633
+
634
+
635
+ @dataclass(frozen=True)
636
+ class PresentedFrame:
637
+ """Ce qui accompagne une image remise au ``sink``."""
638
+
639
+ fps_target: float
640
+ output_rank: int
641
+ window_position: int
642
+ frames_attendues: int
643
+ source_index: int
644
+ source_frame_count: int
645
+ lot_total_frames: int
646
+ frame_timecode: str
647
+ t_theo_s: float
648
+ t_real_s: float
649
+ frames_omises: int
650
+ #: Vrai quand la lecture en boucle est armee AU MOMENT de cette image.
651
+ #: L'etat voyage avec l'image plutot que d'etre lu quelque part par le
652
+ #: rendu: l'incrustation reste une **fonction pure** de ce qu'on lui remet,
653
+ #: et le temoin ne peut donc pas se desynchroniser de la frame affichee.
654
+ #:
655
+ #: Le defaut `False` reproduit l'etat anterieur pour toute construction a
656
+ #: la main: une previz sans boucle est exactement la previz d'avant.
657
+ loop_active: bool = False
658
+
659
+ @property
660
+ def lateness_s(self) -> float:
661
+ return self.t_real_s - self.t_theo_s
662
+
663
+
664
+ #: Marge relative avant de declarer la cadence de lecture en dessous de la
665
+ #: cible. Sans elle, une cadence tenue a la limite exacte ferait clignoter
666
+ #: la couleur au gre du bruit de l'horloge sur quelques millisecondes.
667
+ OVERLAY_RATE_TOLERANCE = 0.02 # 2 %
668
+
669
+ #: Les deux etats du temoin de boucle, ASCII et sans accent comme tout le reste
670
+ #: de l'incrustation. **Les deux existent**, et c'est le point: une bascule sans
671
+ #: temoin est un etat cache, et c'est nommement ce qu'Egan a demande le
672
+ #: 2026-08-30 en meme temps que la fonction elle-meme.
673
+ #:
674
+ #: La casse et la longueur ne sont pas un gout. Les deux libelles sont mesures
675
+ #: a `getTextSize` avec la police de `CvWindowSink` (HERSHEY_SIMPLEX, echelle
676
+ #: 0,7): 90 px et 92 px. Le budget disponible en bout de ligne sur la source la
677
+ #: plus etroite du depot -- un portrait 9:16 rendu a `DEFAULT_PREVIEW_HEIGHT`,
678
+ #: soit 304 px de large -- est de 98 px. `boucle ON` (94) passait, `boucle OFF`
679
+ #: (102) sortait du champ, c'est-a-dire disparaissait EN SILENCE, exactement le
680
+ #: defaut que la revue du 2026-08-05 avait deja paye sur le groupe de droite.
681
+ #: Les deux formes retenues tiennent, et a 2 px l'une de l'autre: la fleche ne
682
+ #: se deplace pas quand on bascule.
683
+ OVERLAY_LOOP_ON = "boucle on"
684
+ OVERLAY_LOOP_OFF = "boucle off"
685
+
686
+
687
+ def compute_current_rate(window_position: int, t_real_s: float) -> float | None:
688
+ """Cadence de lecture moyenne depuis le debut de la passe. Fonction **pure**.
689
+
690
+ Moyenne **cumulee** sur la duree ecoulee, jamais instantanee frame a
691
+ frame: une mesure d'intervalle a intervalle serait dominee par le bruit
692
+ de l'horloge sur un pas de quelques millisecondes et clignoterait sans
693
+ rien dire de la tenue reelle de la cadence.
694
+
695
+ Compte des **intervalles**, jamais des frames: ``t_real_s`` est mesure
696
+ depuis la presentation de la **premiere** frame, donc ``n + 1`` frames
697
+ couvrent ``n`` intervalles. Diviser le nombre de frames par cette duree
698
+ surestime systematiquement la cadence, et massivement au demarrage: sur
699
+ une lecture parfaitement tenue a 3 im/s, la premiere version de cette
700
+ fonction affichait ``6.0/3`` a la deuxieme frame, ``4.5/3`` a la
701
+ troisieme, et ne s'approchait de 3 qu'asymptotiquement -- une lecture
702
+ saine paraissait donc constamment en avance, et la couleur de tenue
703
+ restait verte meme sur une passe reellement non tenue (revue du
704
+ 2026-08-05). C'est la faute que la decision 8 de cette story condamne
705
+ nommement, et que `build_playback_report` evite deja en raisonnant sur
706
+ les retards.
707
+
708
+ Avant toute mesure exploitable -- la toute premiere frame de la passe, ou
709
+ aucun intervalle n'est encore ecoule -- retourne ``None``: il n'y a rien a
710
+ juger, et surtout rien a afficher.
711
+ """
712
+ if window_position < 1 or t_real_s <= 0:
713
+ return None
714
+ return window_position / t_real_s
715
+
716
+
717
+ @dataclass(frozen=True)
718
+ class OverlayText:
719
+ """Contenu textuel de l'incrustation, en une seule ligne. Fonction **pure**,
720
+ sans aucune dependance a cv2.
721
+
722
+ Trois zones distinctes pour permettre au rendu de colorer la zone de
723
+ cadence independamment du reste de la ligne, sans reanalyser du texte.
724
+ """
725
+
726
+ left: str
727
+ right: str
728
+ rate: str
729
+ rate_on_target: bool
730
+ #: Le temoin de boucle, toujours renseigne -- jamais vide, jamais absent.
731
+ #: Un temoin qui disparait a l'etat « pas de boucle » ne distingue pas
732
+ #: « boucle coupee » de « le temoin n'a pas ete dessine ».
733
+ loop: str = OVERLAY_LOOP_OFF
734
+
735
+
736
+ def format_overlay(presentation: PresentedFrame) -> OverlayText:
737
+ """Composer l'incrustation d'une image. Fonction **pure**.
738
+
739
+ Le timecode affiche est ``frame_timecode`` tel que rendu par la story 3.2,
740
+ dans la base de temps **source**. Il n'est jamais revalide contre la
741
+ cadence cible: ``validate_timecode(tc, fps_target)`` leve des que
742
+ ``ff >= ceil(fps_target)``, ce qui arrive des la premiere seconde a
743
+ 3 im/s.
744
+
745
+ Deux echelles distinctes, jamais melangees: ``idx X/Y`` est la position
746
+ de l'image dans le rush source entier (``source_index`` sur
747
+ ``source_frame_count``, base zero -- convention ``n`` de ffmpeg, deja
748
+ etablie dans tout le module) ; le groupe a droite de la fleche est le
749
+ rang de cette image dans le lot que ``extract`` produirait (``output_rank``
750
+ sur ``lot_total_frames``, affiche en base un pour un lecteur humain). Avec
751
+ ``--start``, les deux different.
752
+ """
753
+ # Compte les images qui ont **reellement atteint l'ecran**, pas les
754
+ # echeances du programme: en mode `--on-late skip`, `window_position`
755
+ # avance aussi sur les images omises, si bien que l'ecran annoncait
756
+ # `5/5 im/s` pendant que quatre images sur dix seulement etaient montrees.
757
+ # L'incrustation mesurait le programme, pas la lecture (revue du
758
+ # 2026-08-05).
759
+ presentees = presentation.window_position - presentation.frames_omises
760
+ rate = compute_current_rate(presentees, presentation.t_real_s)
761
+ # Deux conditions, et non une seule. La cadence affichee est une moyenne
762
+ # **cumulee**: elle lisse les a-coups, donc elle pouvait rester au vert sur
763
+ # une passe que le rapport declarait NON TENUE (retard maximal de 113 ms
764
+ # sur un rush 4K). L'ecran est ce qui arbitre; il ne peut pas dire le
765
+ # contraire du rapport. Le retard courant est donc juge avec la **meme**
766
+ # tolerance que `build_playback_report`.
767
+ rate_on_target = (
768
+ True
769
+ if rate is None
770
+ else (
771
+ rate >= presentation.fps_target * (1 - OVERLAY_RATE_TOLERANCE)
772
+ and presentation.lateness_s <= LATE_FRAME_TOLERANCE_S
773
+ )
774
+ )
775
+ # Arrondie a 1 decimale: la cadence mesuree est une moyenne cumulee, sa
776
+ # sixieme decimale (rendue par `:g` sans arrondi prealable) n'apporte
777
+ # aucune information et allonge la ligne au point de la pousser hors
778
+ # champ sur une source etroite.
779
+ #
780
+ # Tant qu'aucun intervalle n'est ecoule, la place est tenue par un tiret et
781
+ # non par la cadence cible: afficher `3/3` sur la premiere image
782
+ # presenterait comme une mesure ce qui n'en est pas une, ce que ce projet
783
+ # s'interdit partout ailleurs (revue du 2026-08-05).
784
+ rate_display = "-" if rate is None else f"{round(rate, 1):g}"
785
+ return OverlayText(
786
+ left=(
787
+ f"{presentation.frame_timecode} idx {presentation.source_index}/"
788
+ f"{presentation.source_frame_count}"
789
+ ),
790
+ right=f"{presentation.output_rank + 1}/{presentation.lot_total_frames}",
791
+ rate=f"{rate_display}/{presentation.fps_target:g} im/s",
792
+ rate_on_target=rate_on_target,
793
+ # Le temoin est LU sur l'image remise, jamais devine: c'est ce qui
794
+ # rend l'incrustation incapable d'annoncer une boucle que la lecture
795
+ # ne fait pas, et reciproquement.
796
+ loop=OVERLAY_LOOP_ON if presentation.loop_active else OVERLAY_LOOP_OFF,
797
+ )
798
+
799
+
800
+ class NullSink:
801
+ """Sink sans affichage: le chemin de ``--no-display``.
802
+
803
+ Il cadence reellement (il dort jusqu'a l'echeance) mais ne montre rien:
804
+ c'est le chemin qui rend la mesure du temps reel observable en integration
805
+ continue, sans ecran.
806
+ """
807
+
808
+ interactive = False
809
+
810
+ def __init__(self, *, sleeper: Callable[[float], Any] = time.sleep) -> None:
811
+ self._sleeper = sleeper
812
+ self.presented: list[PresentedFrame] = []
813
+
814
+ def present(self, image: Any, presentation: PresentedFrame) -> None:
815
+ self.presented.append(presentation)
816
+
817
+ def wait(self, seconds: float) -> int | None:
818
+ if seconds > 0:
819
+ self._sleeper(seconds)
820
+ return None
821
+
822
+ def poll_key(self) -> int | None:
823
+ return None
824
+
825
+ def wait_for_key(self) -> int | None:
826
+ return None
827
+
828
+ def close(self) -> None:
829
+ return None
830
+
831
+
832
+ class RecordingSink:
833
+ """Sink de test: enregistre chaque frame presentee et rejoue des touches.
834
+
835
+ Il rend la totalite de l'echeancier, des metriques, des codes et de la
836
+ navigation verifiable de facon deterministe, sans le moindre pixel.
837
+ """
838
+
839
+ interactive = True
840
+
841
+ def __init__(
842
+ self,
843
+ *,
844
+ sleeper: Callable[[float], Any] = lambda seconds: None,
845
+ keys: Sequence[int] = (),
846
+ delays: Mapping[int, float] | None = None,
847
+ ) -> None:
848
+ self._sleeper = sleeper
849
+ self._keys = list(keys)
850
+ self._delays = dict(delays or {})
851
+ self.presented: list[PresentedFrame] = []
852
+ self.images: list[Any] = []
853
+ self.closed = False
854
+
855
+ def present(self, image: Any, presentation: PresentedFrame) -> None:
856
+ self.presented.append(presentation)
857
+ self.images.append(image)
858
+ delay = self._delays.get(presentation.output_rank)
859
+ if delay:
860
+ self._sleeper(delay)
861
+
862
+ def wait(self, seconds: float) -> int | None:
863
+ if seconds > 0:
864
+ self._sleeper(seconds)
865
+ return None
866
+
867
+ def poll_key(self) -> int | None:
868
+ return None
869
+
870
+ def wait_for_key(self) -> int | None:
871
+ if self._keys:
872
+ return self._keys.pop(0)
873
+ return KEY_QUIT
874
+
875
+ def close(self) -> None:
876
+ self.closed = True
877
+
878
+ @property
879
+ def source_indices(self) -> tuple[int, ...]:
880
+ return tuple(item.source_index for item in self.presented)
881
+
882
+
883
+ class _WindowClosedByUser(Exception):
884
+ """L'utilisateur a ferme la fenetre: la passe s'arrete comme sur `q`.
885
+
886
+ Interne au module. Elle ne remonte jamais a l'appelant: `_play_one_pass`
887
+ la traite comme une sortie volontaire et rend un rapport **partiel**, de
888
+ facon que fermer la fenetre ne fasse pas perdre le rapport, qui est le
889
+ seul livrable de la commande (revue du 2026-08-05).
890
+ """
891
+
892
+
893
+ class CvWindowSink:
894
+ """Fenetre de lecture reelle, ouverte par ``cv2.imshow``.
895
+
896
+ ``cv2.waitKey`` n'est **pas** un ordonnanceur: son delai n'est ni precis ni
897
+ garanti. Il ne sert ici qu'a pomper les evenements de la fenetre et a lire
898
+ une touche; l'attente est bornee par une echeance absolue relue sur
899
+ l'horloge a chaque tour.
900
+ """
901
+
902
+ interactive = True
903
+
904
+ _FONT = 0 # cv2.FONT_HERSHEY_SIMPLEX
905
+ _FONT_SCALE = 0.7
906
+ _LINE_HEIGHT = 34
907
+ _MARGIN = 14
908
+ _ARROW_HALF_LENGTH = 12
909
+ _GROUP_GAP = 10
910
+ # BGR: vert quand la cadence de lecture tient la cadence cible, rouge
911
+ # sinon. L'ecart du reste de la ligne (blanc) est volontaire: seule
912
+ # l'information qui peut se degrader merite d'attirer l'oeil.
913
+ _RATE_COLOR_ON_TARGET = (60, 200, 60)
914
+ _RATE_COLOR_LATE = (50, 50, 220)
915
+
916
+ def __init__(
917
+ self,
918
+ *,
919
+ window_name: str = "mixed-media-util previz",
920
+ clock: Callable[[], float] = time.monotonic,
921
+ pump_step_ms: int = 10,
922
+ ) -> None:
923
+ self._window_name = window_name
924
+ self._clock = clock
925
+ self._pump_step_ms = max(1, int(pump_step_ms))
926
+ self._opened = False
927
+ self._user_closed = False
928
+
929
+ def window_closed(self) -> bool:
930
+ """La fenetre a-t-elle ete fermee par l'utilisateur ?
931
+
932
+ Sans cette lecture, `imshow` recreait silencieusement une fenetre
933
+ detruite: fermer la fenetre ne fermait rien, et la boucle d'attente de
934
+ touche, qui n'avait aucune condition de sortie, ne rendait jamais la
935
+ main. L'utilisateur perdait le rapport, seul livrable de la commande
936
+ (revue du 2026-08-05).
937
+ """
938
+ if not self._opened:
939
+ return False
940
+ try:
941
+ return cv2.getWindowProperty(self._window_name, cv2.WND_PROP_VISIBLE) < 1
942
+ except cv2.error:
943
+ return True
944
+
945
+ def _ensure_window(self) -> None:
946
+ if self._opened:
947
+ return
948
+ if self._user_closed:
949
+ # Ne jamais rouvrir ce que l'utilisateur a ferme.
950
+ raise _WindowClosedByUser()
951
+ try:
952
+ # `WINDOW_GUI_NORMAL` retire la barre d'outils que le backend Qt
953
+ # ajoute par defaut (zoom, enregistrement d'image): un outil
954
+ # d'arbitrage de cadence n'a pas d'interface, et un bouton
955
+ # d'enregistrement irait contre la regle « rien n'est ecrit ».
956
+ cv2.namedWindow(
957
+ self._window_name, cv2.WINDOW_AUTOSIZE | cv2.WINDOW_GUI_NORMAL
958
+ )
959
+ except cv2.error as error:
960
+ raise DisplayUnavailableError(
961
+ "Impossible d'ouvrir une fenetre de lecture: OpenCV a refuse "
962
+ f"'namedWindow' ({error}). Verifier qu'un affichage est disponible "
963
+ "(variable DISPLAY sous Linux) et qu'OpenCV n'est pas compile sans "
964
+ "interface graphique (paquet 'headless').",
965
+ "Une lecture sans affichage reste possible avec --no-display.",
966
+ ) from error
967
+ self._opened = True
968
+
969
+ def _draw_text(self, canvas: Any, text: str, x: int, y: int, color) -> None:
970
+ # Contour noir epais puis remplissage colore fin: lisible sur
971
+ # n'importe quel fond, sans dependre d'un bandeau pour le texte lui
972
+ # meme (le bandeau reste pour uniformiser la zone derriere la ligne).
973
+ cv2.putText(
974
+ canvas, text, (x, y), self._FONT, self._FONT_SCALE,
975
+ (0, 0, 0), 4, cv2.LINE_AA,
976
+ )
977
+ cv2.putText(
978
+ canvas, text, (x, y), self._FONT, self._FONT_SCALE,
979
+ color, 1, cv2.LINE_AA,
980
+ )
981
+
982
+ def _text_width(self, text: str) -> int:
983
+ (width, _height), _baseline = cv2.getTextSize(
984
+ text, self._FONT, self._FONT_SCALE, 1
985
+ )
986
+ return width
987
+
988
+ def _largeur_du_groupe_de_droite(self, overlay: OverlayText) -> int:
989
+ """Largeur du groupe de droite ENTIER -- temoin de boucle compris. Pure.
990
+
991
+ Redaction **unique**: elle sert deux fois -- a faire reculer la fleche,
992
+ et a borner le point d'ancrage du groupe --, et deux copies
993
+ divergeraient. Oublier le temoin ici le ferait dessiner hors champ sur
994
+ une source etroite, c'est-a-dire disparaitre en silence: un temoin
995
+ invisible ne temoigne de rien.
996
+ """
997
+ return (
998
+ self._text_width(overlay.right)
999
+ + self._GROUP_GAP
1000
+ + self._text_width(overlay.rate)
1001
+ + self._GROUP_GAP
1002
+ + self._text_width(overlay.loop)
1003
+ )
1004
+
1005
+ def _positions_du_groupe_de_droite(
1006
+ self, overlay: OverlayText, width: int, left_end_x: int, center_x: int
1007
+ ) -> tuple[int, int, int]:
1008
+ """Abscisses du rang, de la cadence et du temoin. Fonction **pure**.
1009
+
1010
+ Extraite du trace pour une seule raison: la rendre MESURABLE -- une
1011
+ geometrie enterree dans une suite de `putText` ne se verifie qu'en
1012
+ relisant des pixels, et la revue du 2026-08-05 a deja paye ce que coute
1013
+ un element dessine hors champ que personne ne voit.
1014
+
1015
+ **Les trois elements se posent depuis un SEUL point d'ancrage**, borne
1016
+ par la largeur du groupe entier. Les poser au fil du texte et ne borner
1017
+ que le dernier ne suffit pas: le temoin, epingle au bord droit, venait
1018
+ alors se peindre PAR-DESSUS la cadence des que la ligne serrait
1019
+ (mesure a 520 px de large: la cadence finissait a 428 px et le temoin
1020
+ commencait a 414). Un temoin illisible ne vaut pas mieux qu'un temoin
1021
+ hors champ.
1022
+
1023
+ Ce que l'ancrage NE fait pas: mordre sur le texte de gauche. La borne
1024
+ basse est `left_end_x`, decision de la revue du 2026-08-05 -- sur une
1025
+ source si etroite que la ligne entiere ne rentre pas (un portrait 9:16
1026
+ a 304 px, ou le groupe de droite debordait deja avant ce temoin), c'est
1027
+ la fleche, simple separateur, qui est sacrifiee avant l'information.
1028
+ """
1029
+ largeur_du_groupe = self._largeur_du_groupe_de_droite(overlay)
1030
+ x_rang = center_x + self._ARROW_HALF_LENGTH + self._GROUP_GAP
1031
+ x_rang = max(
1032
+ left_end_x, min(x_rang, width - self._MARGIN - largeur_du_groupe)
1033
+ )
1034
+ x_cadence = x_rang + self._text_width(overlay.right) + self._GROUP_GAP
1035
+ x_temoin = x_cadence + self._text_width(overlay.rate) + self._GROUP_GAP
1036
+ # Dernier filet, pour les seules largeurs ou la ligne ENTIERE ne rentre
1037
+ # pas: le temoin reste DANS l'image, quitte a serrer. Un temoin d'etat
1038
+ # qui disparait est pire que pas de temoin -- il fait croire a
1039
+ # l'operateur qu'il lit un ecran complet.
1040
+ x_temoin = max(
1041
+ self._MARGIN,
1042
+ min(x_temoin, width - self._MARGIN - self._text_width(overlay.loop)),
1043
+ )
1044
+ return x_rang, x_cadence, x_temoin
1045
+
1046
+ def _draw_overlay(self, image: Any, presentation: PresentedFrame) -> Any:
1047
+ # Copie systematique: l'image d'origine peut etre celle du cache, et
1048
+ # une incrustation dessinee dessus se retrouverait dans toutes les
1049
+ # passes suivantes.
1050
+ canvas = image.copy()
1051
+ overlay = format_overlay(presentation)
1052
+ width = canvas.shape[1]
1053
+
1054
+ # Bandeau assombri derriere la ligne: sur un rush clair, une
1055
+ # incrustation blanche cerclee de noir reste illisible, et c'est
1056
+ # justement l'ecart de cadence qu'il faut pouvoir lire d'un coup
1057
+ # d'oeil pendant l'arbitrage.
1058
+ band_bottom = self._MARGIN + self._LINE_HEIGHT
1059
+ shade = canvas.copy()
1060
+ cv2.rectangle(shade, (0, 0), (width, band_bottom), (0, 0, 0), -1)
1061
+ cv2.addWeighted(shade, 0.55, canvas, 0.45, 0.0, dst=canvas)
1062
+
1063
+ baseline = self._MARGIN + int(self._LINE_HEIGHT * 0.72)
1064
+ white = (255, 255, 255)
1065
+
1066
+ # Zone gauche: timecode non legende, puis position dans le rush
1067
+ # source entier.
1068
+ self._draw_text(canvas, overlay.left, self._MARGIN, baseline, white)
1069
+
1070
+ # Fleche graphique, pas un caractere ASCII: elle marque visuellement
1071
+ # la bascule entre "ou on est dans le rush source" et "ce que cela
1072
+ # donnerait dans le lot". Centree sur la largeur de l'image quand la
1073
+ # place le permet, jamais au prix d'un recouvrement avec le texte de
1074
+ # gauche: sur une source etroite (portrait, ou vignette de test), le
1075
+ # texte peut depasser le centre geometrique.
1076
+ left_end_x = self._MARGIN + self._text_width(overlay.left) + self._GROUP_GAP
1077
+ # La ligne entiere doit tenir dans l'image. Sur une source portrait
1078
+ # (304 px de large), la fleche restait centree et le groupe de droite
1079
+ # -- rang dans le lot et indicateur de tenue -- etait dessine hors
1080
+ # champ, donc silencieusement invisible: l'information la plus utile
1081
+ # disparaissait sans que rien ne le dise (revue du 2026-08-05). On
1082
+ # recule donc la fleche jusqu'a ce que la fin de ligne rentre, sans
1083
+ # jamais la faire chevaucher le groupe de gauche.
1084
+ # Le temoin de boucle entre dans ce calcul comme le reste du groupe de
1085
+ # droite, par la redaction unique de `_largeur_du_groupe_de_droite`.
1086
+ right_width = self._largeur_du_groupe_de_droite(overlay)
1087
+ center_max = width - self._MARGIN - right_width - self._GROUP_GAP - self._ARROW_HALF_LENGTH
1088
+ center_x = max(width // 2, left_end_x + self._ARROW_HALF_LENGTH)
1089
+ center_x = max(left_end_x + self._ARROW_HALF_LENGTH, min(center_x, center_max))
1090
+ arrow_y = baseline - self._LINE_HEIGHT // 4
1091
+ arrow_from = (center_x - self._ARROW_HALF_LENGTH, arrow_y)
1092
+ arrow_to = (center_x + self._ARROW_HALF_LENGTH, arrow_y)
1093
+ cv2.arrowedLine(
1094
+ canvas, arrow_from, arrow_to, (0, 0, 0), 3, cv2.LINE_AA, tipLength=0.35
1095
+ )
1096
+ cv2.arrowedLine(
1097
+ canvas, arrow_from, arrow_to, white, 1, cv2.LINE_AA, tipLength=0.35
1098
+ )
1099
+
1100
+ # Zone droite: rang dans le lot, cadence de lecture / cadence cible
1101
+ # (coloree en fonction de la seule chose qui peut se degrader), et
1102
+ # temoin de boucle. La geometrie des trois est calculee a part, pour
1103
+ # etre mesurable sans relire de pixels.
1104
+ x_rang, x_cadence, x_temoin = self._positions_du_groupe_de_droite(
1105
+ overlay, width, left_end_x, center_x
1106
+ )
1107
+ self._draw_text(canvas, overlay.right, x_rang, baseline, white)
1108
+ rate_color = (
1109
+ self._RATE_COLOR_ON_TARGET
1110
+ if overlay.rate_on_target
1111
+ else self._RATE_COLOR_LATE
1112
+ )
1113
+ self._draw_text(canvas, overlay.rate, x_cadence, baseline, rate_color)
1114
+
1115
+ # Temoin de boucle, en BLANC comme le reste de la ligne. Le module
1116
+ # reserve deja la couleur a « la seule information qui peut se
1117
+ # degrader » (la tenue de la cadence); un second element colore
1118
+ # diluerait ce signal. L'etat se lit dans le mot, pas dans la teinte --
1119
+ # ce qui le rend aussi lisible sur une capture en niveaux de gris.
1120
+ self._draw_text(canvas, overlay.loop, x_temoin, baseline, white)
1121
+ return canvas
1122
+
1123
+ def present(self, image: Any, presentation: PresentedFrame) -> None:
1124
+ self._ensure_window()
1125
+ if self.window_closed():
1126
+ self._note_user_close()
1127
+ raise _WindowClosedByUser()
1128
+ try:
1129
+ cv2.imshow(self._window_name, self._draw_overlay(image, presentation))
1130
+ cv2.waitKey(1)
1131
+ except cv2.error as error:
1132
+ raise DisplayUnavailableError(
1133
+ f"Affichage interrompu par OpenCV ({error}).",
1134
+ "Relancer avec --no-display pour une lecture mesuree sans "
1135
+ "fenetre.",
1136
+ ) from error
1137
+
1138
+ def wait(self, seconds: float) -> int | None:
1139
+ deadline = self._clock() + seconds
1140
+ while True:
1141
+ remaining = deadline - self._clock()
1142
+ if remaining <= 0:
1143
+ return None
1144
+ step = max(1, min(self._pump_step_ms, int(remaining * 1000)))
1145
+ key = cv2.waitKey(step)
1146
+ if key != -1:
1147
+ return key & 0xFF
1148
+ if self.window_closed():
1149
+ self._note_user_close()
1150
+ return KEY_QUIT
1151
+
1152
+ def poll_key(self) -> int | None:
1153
+ if not self._opened:
1154
+ return None
1155
+ key = cv2.waitKey(1)
1156
+ return None if key == -1 else key & 0xFF
1157
+
1158
+ def wait_for_key(self) -> int | None:
1159
+ self._ensure_window()
1160
+ while True:
1161
+ key = cv2.waitKey(50)
1162
+ if key != -1:
1163
+ return key & 0xFF
1164
+ # Condition de sortie: sans elle, cette boucle ne rendait jamais
1165
+ # la main une fois la fenetre fermee.
1166
+ if self.window_closed():
1167
+ self._note_user_close()
1168
+ return KEY_QUIT
1169
+
1170
+ def _note_user_close(self) -> None:
1171
+ self._user_closed = True
1172
+ self._opened = False
1173
+
1174
+ def close(self) -> None:
1175
+ if self._opened:
1176
+ cv2.destroyWindow(self._window_name)
1177
+ cv2.waitKey(1)
1178
+ self._opened = False
1179
+
1180
+
1181
+ def _local_display_socket(display: str) -> Path | None:
1182
+ """Chemin de la socket X d'un affichage **local**, ou `None`.
1183
+
1184
+ Ne traite que les formes locales (`:0`, `:77.0`, `unix:1`), les seules
1185
+ verifiables sans ouvrir de connexion reseau. Une adresse distante
1186
+ (`machine:0`) rend `None`: on ne sait pas la sonder a bon compte, et
1187
+ inventer un verdict serait pire que de ne rien dire.
1188
+ """
1189
+ if not display or "/" in display:
1190
+ return None
1191
+ adresse = display[len("unix"):] if display.startswith("unix:") else display
1192
+ if not adresse.startswith(":"):
1193
+ return None
1194
+ numero = adresse[1:].split(".")[0]
1195
+ if not numero.isdigit():
1196
+ return None
1197
+ return Path("/tmp/.X11-unix") / f"X{numero}"
1198
+
1199
+
1200
+ #: Le conseil que la CLI ajoute a deux de ses trois refus d'affichage. Il est
1201
+ #: nomme parce qu'il est **partage**: le rediger deux fois ferait diverger
1202
+ #: deux messages que rien n'obligerait a rester identiques (``EPIC11-ARB-73``).
1203
+ #: Il nomme ``--no-display``, donc il n'est jamais rendu par la TUI.
1204
+ _CONSEIL_SESSION_GRAPHIQUE = (
1205
+ "Relancer dans une session graphique, ou utiliser --no-display pour une "
1206
+ "lecture mesuree sans fenetre."
1207
+ )
1208
+
1209
+
1210
+ #: Le conseil du refus << OpenCV sans interface >>. Il nomme DEUX issues, comme
1211
+ #: `EPIC11-ARB-89` l'exige de tout refus : mesurer sans fenetre, ou remplacer la
1212
+ #: roue. La commande est ecrite EN ENTIER parce qu'elle n'est pas devinable --
1213
+ #: `opencv-python` et `opencv-python-headless` fournissent le meme module `cv2`,
1214
+ #: donc l'un remplace l'autre au lieu de s'y ajouter, et le `--force` de `pipx
1215
+ #: inject` est ce qui autorise ce remplacement.
1216
+ _CONSEIL_SANS_INTERFACE_OPENCV = (
1217
+ "Utiliser --no-display pour une lecture mesuree sans fenetre, ou installer "
1218
+ "la roue OpenCV avec interface : pipx inject --force mmu-tui opencv-python"
1219
+ " -- elle exige en plus les bibliotheques systeme libGL et libX11."
1220
+ )
1221
+
1222
+
1223
+ def _opencv_sait_afficher() -> bool:
1224
+ """Dire si CETTE compilation d'OpenCV peut ouvrir une fenetre.
1225
+
1226
+ **La presence du symbole ne prouve rien, et c'est un piege paye.** Le garde
1227
+ d'origine testait ``hasattr(cv2, "imshow")`` : mesure du 2026-09-06 sur la
1228
+ roue headless 5.0.0, ``hasattr`` rend ``True`` et l'appel leve ensuite
1229
+ ``cv2.error: The function is not implemented``. Le garde ne s'est donc
1230
+ jamais declenche, et le refus lisible qu'il devait produire n'existait pas.
1231
+
1232
+ ``getBuildInformation()`` porte, lui, la CAPACITE : la ligne ``GUI:`` vaut
1233
+ ``NONE`` sur une roue headless et nomme la boite a outils (``GTK``,
1234
+ ``COCOA``, ``WIN32``, ``QT``) sinon. C'est la seule source qui distingue les
1235
+ deux, et elle ne coute qu'une lecture de chaine.
1236
+
1237
+ Le repli est PERMISSIF a dessein : si la ligne est absente d'une version
1238
+ future d'OpenCV, on laisse passer plutot que de refuser un affichage qui
1239
+ marcherait. Un faux refus retire une fonctionnalite qui existe ; un faux
1240
+ passage retombe sur le refus suivant, qui est deja ecrit.
1241
+ """
1242
+ try:
1243
+ information = cv2.getBuildInformation()
1244
+ except Exception: # pragma: no cover - roue improbable
1245
+ return True
1246
+ for ligne in information.splitlines():
1247
+ depouillee = ligne.strip()
1248
+ if depouillee.startswith("GUI:"):
1249
+ return depouillee.split(":", 1)[1].strip().upper() not in ("", "NONE")
1250
+ return True
1251
+
1252
+
1253
+ def ensure_display_available(
1254
+ *, environ: Mapping[str, str] | None = None, platform: str = ""
1255
+ ) -> None:
1256
+ """Refuser tot une session graphique sans affichage.
1257
+
1258
+ ``cv2.imshow`` sans affichage ne leve pas toujours proprement: selon la
1259
+ compilation, l'absence de ``DISPLAY`` produit une ``cv2.error`` peu lisible,
1260
+ voire un plantage du processus. La detection a donc lieu **en amont**, et
1261
+ le refus porte le code ``DISPLAY_UNAVAILABLE`` avec le code de sortie 2.
1262
+ """
1263
+ env = os.environ if environ is None else environ
1264
+ system = platform or sys.platform
1265
+ if system.startswith("linux") and not (
1266
+ env.get("DISPLAY") or env.get("WAYLAND_DISPLAY")
1267
+ ):
1268
+ raise DisplayUnavailableError(
1269
+ f"{DISPLAY_UNAVAILABLE}: aucun affichage disponible (ni DISPLAY ni "
1270
+ "WAYLAND_DISPLAY dans l'environnement).",
1271
+ _CONSEIL_SESSION_GRAPHIQUE,
1272
+ )
1273
+ # Une variable **definie** ne prouve pas qu'un serveur repond. Le cas est
1274
+ # courant: `ssh` sans redirection graphique, un `tmux` qui survit a la
1275
+ # session graphique, un conteneur qui herite `DISPLAY=:0`. Qt appelle
1276
+ # alors `qFatal()` dans le natif, et le processus meurt sur SIGABRT (code
1277
+ # 134): ni exception, ni code de sortie exploitable, ni rapport. C'est le
1278
+ # Piege 5 que cette story a nomme (revue du 2026-08-05).
1279
+ if system.startswith("linux") and not env.get("WAYLAND_DISPLAY"):
1280
+ socket_path = _local_display_socket(env.get("DISPLAY", ""))
1281
+ if socket_path is not None and not socket_path.exists():
1282
+ raise DisplayUnavailableError(
1283
+ f"{DISPLAY_UNAVAILABLE}: DISPLAY={env.get('DISPLAY')!r} est "
1284
+ "defini mais aucun serveur graphique ne repond a cette adresse "
1285
+ f"({socket_path} est absent). C'est le cas d'une connexion "
1286
+ "distante sans redirection graphique, ou d'une session detachee "
1287
+ "de son affichage.",
1288
+ _CONSEIL_SESSION_GRAPHIQUE,
1289
+ )
1290
+ if not _opencv_sait_afficher():
1291
+ raise DisplayUnavailableError(
1292
+ f"{DISPLAY_UNAVAILABLE}: cette compilation d'OpenCV n'expose aucune "
1293
+ "interface graphique (roue 'headless').",
1294
+ _CONSEIL_SANS_INTERFACE_OPENCV,
1295
+ )
1296
+
1297
+
1298
+ # --------------------------------------------------------------------------
1299
+ # Decodage sequentiel et cache memoire
1300
+ # --------------------------------------------------------------------------
1301
+
1302
+
1303
+ def resize_to_height(image: Any, height: int | None) -> Any:
1304
+ """Ramener une image a ``height`` pixels de haut, ratio conserve.
1305
+
1306
+ ``INTER_AREA`` est l'interpolation adaptee a la reduction. Le
1307
+ redimensionnement a lieu **avant** la mise en cache et jamais apres:
1308
+ cacher des frames pleine resolution saturerait le budget en quelques
1309
+ dizaines d'images (6,2 Mo par frame HD contre 1,6 Mo a 960x540).
1310
+ """
1311
+ if height is None or height <= 0:
1312
+ return image
1313
+ source_height, source_width = image.shape[0], image.shape[1]
1314
+ if source_height <= height:
1315
+ return image
1316
+ width = max(1, int(round(source_width * height / source_height)))
1317
+ return cv2.resize(image, (width, height), interpolation=cv2.INTER_AREA)
1318
+
1319
+
1320
+ class FrameCache:
1321
+ """Cache LRU borne en octets des frames deja decodees et redimensionnees.
1322
+
1323
+ **Optimisation, jamais condition de justesse**: un defaut de cache
1324
+ redecode, et une eviction est signalee par ``CACHE_EVICTED`` sans jamais
1325
+ alterer la sequence presentee.
1326
+ """
1327
+
1328
+ def __init__(self, budget_bytes: int) -> None:
1329
+ self.budget_bytes = int(budget_bytes)
1330
+ self._entries: dict[int, Any] = {}
1331
+ self._bytes = 0
1332
+ self.hits = 0
1333
+ self.misses = 0
1334
+ self.evictions = 0
1335
+
1336
+ def __contains__(self, source_index: int) -> bool:
1337
+ return source_index in self._entries
1338
+
1339
+ def __len__(self) -> int:
1340
+ return len(self._entries)
1341
+
1342
+ @property
1343
+ def used_bytes(self) -> int:
1344
+ return self._bytes
1345
+
1346
+ def get(self, source_index: int) -> Any | None:
1347
+ image = self._entries.get(source_index)
1348
+ if image is None:
1349
+ self.misses += 1
1350
+ return None
1351
+ # Reinsertion en queue: l'ordre d'insertion d'un dict est l'ordre LRU.
1352
+ del self._entries[source_index]
1353
+ self._entries[source_index] = image
1354
+ self.hits += 1
1355
+ return image
1356
+
1357
+ def put(self, source_index: int, image: Any) -> None:
1358
+ size = int(getattr(image, "nbytes", 0))
1359
+ if source_index in self._entries:
1360
+ self._bytes -= int(getattr(self._entries[source_index], "nbytes", 0))
1361
+ del self._entries[source_index]
1362
+ if self.budget_bytes <= 0 or size > self.budget_bytes:
1363
+ # Une frame plus lourde que le budget entier n'est jamais retenue:
1364
+ # la mettre en cache reviendrait a vider le cache a chaque image.
1365
+ self.evictions += 1
1366
+ return
1367
+ self._entries[source_index] = image
1368
+ self._bytes += size
1369
+ while self._bytes > self.budget_bytes and self._entries:
1370
+ oldest = next(iter(self._entries))
1371
+ self._bytes -= int(getattr(self._entries[oldest], "nbytes", 0))
1372
+ del self._entries[oldest]
1373
+ self.evictions += 1
1374
+
1375
+
1376
+ class SequentialFrameReader:
1377
+ """Lecture **sequentielle** d'un rush, rang compte a partir de 0.
1378
+
1379
+ ``cv2.CAP_PROP_POS_FRAMES`` n'est jamais employe: le positionnement par
1380
+ index sur un flux a images bidirectionnelles atterrit couramment sur une
1381
+ image cle voisine, en silence, et la previz montrerait alors autre chose
1382
+ que ce que ``extract`` retiendrait. Revenir en arriere **rouvre** la
1383
+ capture et relit depuis le debut: c'est plus lent, et c'est juste.
1384
+ """
1385
+
1386
+ def __init__(
1387
+ self,
1388
+ video_path: str | Path,
1389
+ *,
1390
+ height: int | None = DEFAULT_PREVIEW_HEIGHT,
1391
+ capture_factory: Callable[[], Any] | None = None,
1392
+ ) -> None:
1393
+ self._video_path = str(video_path)
1394
+ self._height = height
1395
+ self._capture_factory = capture_factory or (
1396
+ lambda: cv2.VideoCapture(self._video_path)
1397
+ )
1398
+ self._capture: Any = None
1399
+ self._next_index = 0
1400
+ self.read_count = 0
1401
+ self.open_count = 0
1402
+ self.truncated = False
1403
+
1404
+ def _open(self) -> None:
1405
+ self.close()
1406
+ capture = self._capture_factory()
1407
+ if hasattr(capture, "isOpened") and not capture.isOpened():
1408
+ raise ExtractionInputError(
1409
+ f"Impossible d'ouvrir le rush pour lecture: {self._video_path}. "
1410
+ "Le fichier existe mais aucun decodeur disponible ne sait le lire."
1411
+ )
1412
+ self._capture = capture
1413
+ self._next_index = 0
1414
+ self.open_count += 1
1415
+
1416
+ def frame_at(self, source_index: int) -> Any | None:
1417
+ """Rendre la frame de rang ``source_index``, ou ``None`` si le flux est court.
1418
+
1419
+ ``None`` signale une troncature du flux: le decodeur a rendu moins de
1420
+ frames que le cardinal etabli par ``resolve_source_frame_count``. Le
1421
+ cas est **rapporte** (``DECODE_TRUNCATED``), jamais masque.
1422
+ """
1423
+ if self._capture is None or source_index < self._next_index:
1424
+ self._open()
1425
+ image = None
1426
+ while self._next_index <= source_index:
1427
+ ok, decoded = self._capture.read()
1428
+ self.read_count += 1
1429
+ if not ok or decoded is None:
1430
+ self.truncated = True
1431
+ return None
1432
+ self._next_index += 1
1433
+ image = decoded
1434
+ return resize_to_height(image, self._height)
1435
+
1436
+ def close(self) -> None:
1437
+ if self._capture is not None:
1438
+ self._capture.release()
1439
+ self._capture = None
1440
+
1441
+
1442
+ # --------------------------------------------------------------------------
1443
+ # Boucle de lecture
1444
+ # --------------------------------------------------------------------------
1445
+
1446
+
1447
+ @dataclass(frozen=True)
1448
+ class PassOutcome:
1449
+ """Resultat d'une passe de lecture: son rapport et la touche qui l'a close."""
1450
+
1451
+ report: PlaybackReport
1452
+ key: int | None
1453
+ interrupted: bool
1454
+ #: Etat de la lecture en boucle **a la sortie** de la passe. La bascule se
1455
+ #: fait en vol, dans `_play_one_pass`; c'est par ce champ qu'elle survit a
1456
+ #: la passe et gouverne la suivante.
1457
+ loop_active: bool = False
1458
+
1459
+
1460
+ def _play_one_pass(
1461
+ schedule: Sequence[ScheduledFrame],
1462
+ *,
1463
+ fps_target: float,
1464
+ source_frame_count: int,
1465
+ lot_total_frames: int,
1466
+ reader: SequentialFrameReader,
1467
+ cache: FrameCache,
1468
+ sink: Any,
1469
+ clock: Callable[[], float],
1470
+ on_late: str,
1471
+ loop_active: bool = False,
1472
+ logger: logging.Logger | None = None,
1473
+ ) -> PassOutcome:
1474
+ """Presenter une passe complete, en tenant les echeances absolues.
1475
+
1476
+ ``loop_active`` entre et ressort: la passe le porte sur chaque image
1477
+ presentee (le temoin de l'incrustation) et le rend a l'appelant, qui decide
1478
+ de rejouer ou non. Le defaut ``False`` reproduit l'etat anterieur.
1479
+ """
1480
+ observations: list[FrameObservation] = []
1481
+ omises = 0
1482
+ interrupted = False
1483
+ exit_key: int | None = None
1484
+ warnings: set[str] = set()
1485
+ evictions_before = cache.evictions
1486
+
1487
+ duree_nominale = schedule[-1].t_theo_s if schedule else 0.0
1488
+
1489
+ def acquire(scheduled: ScheduledFrame) -> Any | None:
1490
+ image = cache.get(scheduled.source_index)
1491
+ if image is not None:
1492
+ return image
1493
+ image = reader.frame_at(scheduled.source_index)
1494
+ if image is None:
1495
+ warnings.add(DECODE_TRUNCATED)
1496
+ if logger is not None:
1497
+ logger.warning(
1498
+ "Flux tronque: la frame source %d n'a pas pu etre decodee "
1499
+ "alors que le cardinal source en annonce davantage. Lecture "
1500
+ "arretee a cette frame.",
1501
+ scheduled.source_index,
1502
+ )
1503
+ return None
1504
+ cache.put(scheduled.source_index, image)
1505
+ return image
1506
+
1507
+ # Amorcage: la premiere frame est mise a disposition **avant** que
1508
+ # l'horloge de lecture ne demarre. La lecture etant sequentielle et sans
1509
+ # positionnement par index (Piege 2), atteindre la frame 100 impose de
1510
+ # decoder les cent premieres: compter ce cout comme un retard de
1511
+ # cadencement rendrait toute fenetre `--start` structurellement en retard,
1512
+ # et masquerait le vrai objet de la mesure, qui est la capacite a TENIR la
1513
+ # cadence une fois la lecture lancee. Le cout est donc mesure a part et
1514
+ # rapporte (`amorcage_s`), jamais dissimule.
1515
+ priming_started = clock()
1516
+ primed_image = None
1517
+ if schedule:
1518
+ primed_image = acquire(schedule[0])
1519
+ origin = clock()
1520
+ amorcage_s = origin - priming_started
1521
+
1522
+ try:
1523
+ for position, scheduled in enumerate(schedule):
1524
+ if primed_image is not None:
1525
+ image, primed_image = primed_image, None
1526
+ else:
1527
+ image = acquire(scheduled)
1528
+ if image is None:
1529
+ break
1530
+
1531
+ deadline = origin + scheduled.t_theo_s
1532
+ key = sink.wait(deadline - clock())
1533
+ if key is None:
1534
+ key = sink.poll_key()
1535
+ # La touche de boucle **bascule un etat, elle ne clot pas la
1536
+ # passe**: c'est la seule facon de couper une boucle deja lancee
1537
+ # sans quitter la session. Elle n'apparait donc jamais dans
1538
+ # `exit_key`, et l'attente REPREND jusqu'a l'echeance de l'image en
1539
+ # cours -- `sink.wait` rend la main des la premiere touche, si bien
1540
+ # qu'une bascule non suivie d'une reprise ferait paraitre cette
1541
+ # image-la en avance et fausserait la mesure que la commande existe
1542
+ # pour rendre. L'echeance est absolue: reprendre l'attente ne
1543
+ # decale rien.
1544
+ while key == KEY_LOOP:
1545
+ loop_active = not loop_active
1546
+ key = sink.wait(deadline - clock())
1547
+ if key is None:
1548
+ key = sink.poll_key()
1549
+ if key is not None and key in (
1550
+ KEY_NEXT, KEY_PREVIOUS, KEY_REPLAY, KEY_QUIT, _KEY_ESCAPE
1551
+ ):
1552
+ exit_key = KEY_QUIT if key == _KEY_ESCAPE else key
1553
+ break
1554
+
1555
+ elapsed = clock() - origin
1556
+ if not should_present(
1557
+ t_theo_s=scheduled.t_theo_s, elapsed_s=elapsed, on_late=on_late
1558
+ ):
1559
+ omises += 1
1560
+ continue
1561
+
1562
+ sink.present(
1563
+ image,
1564
+ PresentedFrame(
1565
+ fps_target=float(fps_target),
1566
+ output_rank=scheduled.output_rank,
1567
+ window_position=position,
1568
+ frames_attendues=len(schedule),
1569
+ source_index=scheduled.source_index,
1570
+ source_frame_count=source_frame_count,
1571
+ lot_total_frames=lot_total_frames,
1572
+ frame_timecode=scheduled.frame_timecode,
1573
+ t_theo_s=scheduled.t_theo_s,
1574
+ t_real_s=elapsed,
1575
+ frames_omises=omises,
1576
+ loop_active=loop_active,
1577
+ ),
1578
+ )
1579
+ observations.append(
1580
+ FrameObservation(
1581
+ output_rank=scheduled.output_rank,
1582
+ source_index=scheduled.source_index,
1583
+ t_theo_s=scheduled.t_theo_s,
1584
+ t_real_s=elapsed,
1585
+ )
1586
+ )
1587
+ except KeyboardInterrupt:
1588
+ interrupted = True
1589
+ except _WindowClosedByUser:
1590
+ exit_key = KEY_QUIT
1591
+
1592
+ if cache.evictions > evictions_before:
1593
+ warnings.add(CACHE_EVICTED)
1594
+
1595
+ partiel = interrupted or exit_key is not None or DECODE_TRUNCATED in warnings
1596
+ report = build_playback_report(
1597
+ observations,
1598
+ fps_target=fps_target,
1599
+ frames_attendues=len(schedule),
1600
+ duree_nominale_s=duree_nominale,
1601
+ frames_omises=omises,
1602
+ partiel=partiel,
1603
+ extra_warnings=tuple(warnings),
1604
+ amorcage_s=amorcage_s,
1605
+ )
1606
+ return PassOutcome(
1607
+ report=report,
1608
+ key=exit_key,
1609
+ interrupted=interrupted,
1610
+ loop_active=loop_active,
1611
+ )
1612
+
1613
+
1614
+ # --------------------------------------------------------------------------
1615
+ # Session
1616
+ # --------------------------------------------------------------------------
1617
+
1618
+
1619
+ @dataclass(frozen=True)
1620
+ class PrevizSession:
1621
+ """Tout ce qu'une session connait de la source, calcule **une seule fois**.
1622
+
1623
+ Un probe, une qualification, un cardinal, une ``FrameSelection`` par
1624
+ cadence demandee, toutes calculees avant la premiere image affichee. La
1625
+ navigation entre cadences ne repaie donc ni le probe, ni l'eventuel
1626
+ ``ffprobe -count_frames``, qui est le poste le plus cher de la preparation
1627
+ sur un rush long.
1628
+ """
1629
+
1630
+ video_path: Path
1631
+ fps_source: Fraction
1632
+ source_frame_count: int
1633
+ source_frame_count_is_exact: bool
1634
+ start_timecode: str | None
1635
+ stream_duration_seconds: float | None
1636
+ fps_targets: tuple[float, ...]
1637
+ selections: tuple[FrameSelection, ...]
1638
+ schedules: tuple[tuple[ScheduledFrame, ...], ...]
1639
+ #: Verdict de ``SourceQualification.cadence_corroboree``, transporte tel
1640
+ #: quel (``EPIC11-ARB-76``, story 11.4). **La previz ne s'en sert pas**:
1641
+ #: elle ne refuse rien de plus qu'avant, la lecture comparee n'ecrivant
1642
+ #: rien. Il existe parce que ``run_extraction`` **refuse** une source dont
1643
+ #: la cadence n'est pas corroborable (``extraction.py``, ARB-6): sans ce
1644
+ #: champ, un conteneur avare -- Matroska sans duree ni ``nb_frames`` --
1645
+ #: laisse la previz reussir puis l'extraction refuser, c'est-a-dire vingt
1646
+ #: minutes de lecture avant un refus previsible des le depart. L'appelant
1647
+ #: qui enchaine les deux temps lit ce champ pour **avertir** au premier.
1648
+ #:
1649
+ #: Le defaut ``True`` reproduit l'etat anterieur -- rien n'avertissait --
1650
+ #: pour les sessions construites a la main; le seul producteur reel,
1651
+ #: ``prepare_previz``, le renseigne **toujours** explicitement.
1652
+ cadence_corroboree: bool = True
1653
+
1654
+
1655
+ @dataclass(frozen=True)
1656
+ class PrevizResult:
1657
+ """Resultat complet d'une session de lecture. Aucun artefact, aucun fichier."""
1658
+
1659
+ session: PrevizSession
1660
+ reports: tuple[PlaybackReport, ...]
1661
+ interrupted: bool
1662
+ decoded_frames: int
1663
+ cache_hits: int
1664
+ cache_misses: int
1665
+
1666
+
1667
+ def _validated_targets(fps_targets: Sequence[float]) -> tuple[float, ...]:
1668
+ if not fps_targets:
1669
+ raise ExtractionInputError(
1670
+ "Aucune cadence cible: fournir au moins un --fps (repetable pour "
1671
+ "comparer plusieurs cadences dans une seule session)."
1672
+ )
1673
+ validated: list[float] = []
1674
+ for value in fps_targets:
1675
+ if not is_strict_number(value):
1676
+ raise ExtractionInputError(
1677
+ f"Parametre --fps invalide: {value!r}. Attendu un nombre."
1678
+ )
1679
+ if value != value or value in (float("inf"), float("-inf")):
1680
+ raise ExtractionInputError(f"Parametre --fps non fini: {value!r}.")
1681
+ if value <= 0:
1682
+ raise ExtractionInputError(
1683
+ f"Parametre --fps invalide: {value}. Attendu un nombre strictement "
1684
+ "positif."
1685
+ )
1686
+ validated.append(float(value))
1687
+ return tuple(validated)
1688
+
1689
+
1690
+ def prepare_previz(
1691
+ *,
1692
+ video_path: str | Path,
1693
+ fps_targets: Sequence[float],
1694
+ source_in_timecode: str | None = None,
1695
+ source_out_timecode: str | None = None,
1696
+ start_seconds: float | None = None,
1697
+ duration_seconds: float | None = None,
1698
+ probe: Mapping[str, Any] | None = None,
1699
+ ffprobe_binary: str = "ffprobe",
1700
+ logger: logging.Logger | None = None,
1701
+ ) -> PrevizSession:
1702
+ """Preparer une session: probe, qualification, cardinal, selections.
1703
+
1704
+ L'ordre est impose et chaque etape est **consommee**, jamais reimplementee:
1705
+ ``video_metadata.probe_media`` -> ``extraction.qualify_source`` ->
1706
+ ``extraction.resolve_source_frame_count`` -> ``select_source_frames``.
1707
+ C'est cette chaine, et le fait qu'elle recoive exactement les memes
1708
+ arguments que ``extract``, qui garantit que la previz montre exactement les
1709
+ frames que ``extract`` retiendrait.
1710
+
1711
+ Deux paires d'options, deux effets qu'il ne faut jamais confondre
1712
+ (story 3.7, AC 12):
1713
+
1714
+ * ``source_in_timecode`` / ``source_out_timecode`` (``--in`` / ``--out``)
1715
+ changent **quelles frames sont retenues**. Elles descendent telles quelles
1716
+ dans ``select_source_frames``, comme dans ``extract``: c'est la meme
1717
+ fenetre, calculee une seule fois, dans le meme noyau.
1718
+ * ``start_seconds`` / ``duration_seconds`` (``--start`` / ``--duration``)
1719
+ changent **lesquelles, parmi les retenues, sont jouees** dans cette
1720
+ session. Elles n'entrent jamais dans la selection.
1721
+
1722
+ Les deux paires se combinent, et ``--start`` se lit toujours sur la ligne
1723
+ de temps du rush entier -- ``t_theo_s`` derive du ``source_index`` absolu --
1724
+ et non sur celle de l'extrait.
1725
+
1726
+ Rien n'est ecrit: aucune arborescence projet n'est creee, aucun journal
1727
+ fichier n'est ouvert, aucun manifest n'est touche.
1728
+ """
1729
+ path = Path(video_path)
1730
+ targets = _validated_targets(fps_targets)
1731
+ if not path.is_file():
1732
+ raise ExtractionInputError(f"Fichier video introuvable: {path}")
1733
+
1734
+ probe_data = (
1735
+ video_metadata.probe_media(str(path), ffprobe_bin=ffprobe_binary)
1736
+ if probe is None
1737
+ else dict(probe)
1738
+ )
1739
+ qualification = extraction.qualify_source(probe_data)
1740
+
1741
+ # ARB-12 (`decisions-2026-08-05.md`): meme garde, meme message et meme
1742
+ # moment que `extract`. Elle etait posee plus bas, dans le noyau de
1743
+ # selection, donc **apres** un eventuel `ffprobe -count_frames` qui paie un
1744
+ # decodage complet: la previz faisait attendre l'utilisateur avant de
1745
+ # refuser ce que l'extraction refusait tout de suite.
1746
+ for fps_target in targets:
1747
+ if Fraction(exact_frame_rate(fps_target)) > qualification.fps_source:
1748
+ raise ExtractionInputError(
1749
+ extraction.upsampling_refusal_message(
1750
+ fps_target, qualification.fps_source
1751
+ )
1752
+ )
1753
+
1754
+ source_frame_count, is_exact = extraction.resolve_source_frame_count(
1755
+ path, qualification, ffprobe_bin=ffprobe_binary, logger=logger
1756
+ )
1757
+
1758
+ if logger is not None:
1759
+ logger.info(
1760
+ "Source qualifiee: cadence %s im/s, %d frame(s) source (%s), timecode "
1761
+ "de depart %s",
1762
+ qualification.fps_source,
1763
+ source_frame_count,
1764
+ "comptage exact" if is_exact else "ESTIMATION",
1765
+ qualification.start_timecode or "absent",
1766
+ )
1767
+
1768
+ selections = tuple(
1769
+ select_source_frames(
1770
+ fps_source=qualification.fps_source,
1771
+ fps_target=fps_target,
1772
+ source_frame_count=source_frame_count,
1773
+ source_start_timecode=qualification.start_timecode,
1774
+ source_in_timecode=source_in_timecode,
1775
+ source_out_timecode=source_out_timecode,
1776
+ source_duration_seconds=qualification.stream_duration_seconds,
1777
+ source_frame_count_is_exact=is_exact,
1778
+ )
1779
+ for fps_target in targets
1780
+ )
1781
+ schedules = tuple(
1782
+ restrict_schedule(
1783
+ build_schedule(selection),
1784
+ start_seconds=start_seconds,
1785
+ duration_seconds=duration_seconds,
1786
+ )
1787
+ for selection in selections
1788
+ )
1789
+ # Le message dit sur quoi la selection a porte. Sur un extrait, affirmer
1790
+ # « la selection porte sur le rush entier » enverrait l'operateur chercher
1791
+ # une erreur de --start alors que ce sont ses bornes qui excluent la plage
1792
+ # demandee -- ou l'inverse. Les deux causes doivent rester distinguables.
1793
+ if source_in_timecode is None and source_out_timecode is None:
1794
+ portee = "la selection porte sur le rush entier"
1795
+ else:
1796
+ portee = (
1797
+ "la selection est deja bornee a l'extrait "
1798
+ f"{source_in_timecode or 'debut du rush'} -> "
1799
+ f"{source_out_timecode or 'fin du rush'}"
1800
+ )
1801
+ for fps_target, schedule in zip(targets, schedules):
1802
+ if not schedule:
1803
+ raise ExtractionInputError(
1804
+ f"Aucune frame a presenter a {fps_target:g} im/s dans la fenetre "
1805
+ f"demandee (--start / --duration): {portee}, mais aucune frame "
1806
+ "retenue ne tombe dans cette plage."
1807
+ )
1808
+ return PrevizSession(
1809
+ video_path=path,
1810
+ fps_source=qualification.fps_source,
1811
+ source_frame_count=source_frame_count,
1812
+ source_frame_count_is_exact=is_exact,
1813
+ start_timecode=qualification.start_timecode,
1814
+ stream_duration_seconds=qualification.stream_duration_seconds,
1815
+ fps_targets=targets,
1816
+ selections=selections,
1817
+ schedules=schedules,
1818
+ # Lu chez la qualification, jamais recalcule ici: le critere de
1819
+ # corroboration appartient a `extraction.SourceQualification` et une
1820
+ # seconde implementation divergerait du refus qu'elle commande.
1821
+ cadence_corroboree=qualification.cadence_corroboree,
1822
+ )
1823
+
1824
+
1825
+ def play_cadences(
1826
+ session: PrevizSession,
1827
+ *,
1828
+ sink: Any,
1829
+ height: int | None = DEFAULT_PREVIEW_HEIGHT,
1830
+ memory_budget_mb: float = DEFAULT_MEMORY_BUDGET_MB,
1831
+ on_late: str = ON_LATE_REPORT,
1832
+ clock: Callable[[], float] = time.monotonic,
1833
+ capture_factory: Callable[[], Any] | None = None,
1834
+ logger: logging.Logger | None = None,
1835
+ reader: SequentialFrameReader | None = None,
1836
+ cache: FrameCache | None = None,
1837
+ ) -> PrevizResult:
1838
+ """Lire les cadences d'une session, dans **une seule** ouverture du rush.
1839
+
1840
+ La navigation entre cadences se fait au clavier, sans reouvrir le fichier
1841
+ ni recalculer quoi que ce soit: ``n`` cadence suivante, ``p`` precedente,
1842
+ ``r`` rejouer, ``l`` lecture en boucle (bascule), ``q`` quitter. Un cache
1843
+ memoire borne sert les passes suivantes et les indices communs a deux
1844
+ cadences (a 25 im/s, 12,5 et 5 partagent une frame sur deux).
1845
+
1846
+ **La boucle est coupee par defaut**, et elle ne s'arme que par ``l``: une
1847
+ session qui ne touche pas cette touche -- toute session ``--no-display``,
1848
+ donc toute session d'integration continue -- se deroule exactement comme
1849
+ avant. ``l`` s'appuie pendant la lecture aussi bien qu'en fin de passe;
1850
+ ``q`` reste prioritaire, y compris boucle armee, sans quoi la boucle serait
1851
+ un piege.
1852
+
1853
+ ``Ctrl+C`` interrompt proprement: la capture et la fenetre sont liberees
1854
+ dans un ``finally``, un rapport **partiel** est rendu, et aucune trace
1855
+ Python n'est affichee.
1856
+ """
1857
+ if on_late not in ON_LATE_POLICIES:
1858
+ raise ExtractionInputError(
1859
+ f"Politique --on-late inconnue: {on_late!r}. Valeurs admises: "
1860
+ f"{', '.join(ON_LATE_POLICIES)}."
1861
+ )
1862
+ if height is not None and height <= 0:
1863
+ raise ExtractionInputError(
1864
+ f"Parametre --height invalide: {height!r}. Attendu un entier strictement "
1865
+ "positif."
1866
+ )
1867
+ if memory_budget_mb <= 0:
1868
+ raise ExtractionInputError(
1869
+ f"Parametre --memory-budget-mb invalide: {memory_budget_mb!r}. Attendu un "
1870
+ "nombre strictement positif."
1871
+ )
1872
+
1873
+ reader = reader or SequentialFrameReader(
1874
+ session.video_path, height=height, capture_factory=capture_factory
1875
+ )
1876
+ cache = cache or FrameCache(int(memory_budget_mb * _BYTES_PER_MB))
1877
+
1878
+ reports: list[PlaybackReport] = []
1879
+ interrupted = False
1880
+ current = 0
1881
+ #: Lecture en boucle: coupee au demarrage, et rien d'autre que la touche
1882
+ #: `l` ne peut l'armer. C'est ce qui rend le comportement de `mmu previz`
1883
+ #: inchange tant que personne n'appuie dessus.
1884
+ loop_active = False
1885
+
1886
+ try:
1887
+ while 0 <= current < len(session.schedules):
1888
+ if logger is not None:
1889
+ logger.info(
1890
+ "Lecture a %g im/s: %d frame(s) retenue(s) sur %d frame(s) "
1891
+ "source",
1892
+ session.fps_targets[current],
1893
+ len(session.schedules[current]),
1894
+ session.source_frame_count,
1895
+ )
1896
+ # Les reserves de la selection (story 3.2) sont calculees ici
1897
+ # depuis toujours et n'etaient dites nulle part: `extract`
1898
+ # annonce `NTSC_RATE_OUT_OF_SCOPE` la ou `previz` restait
1899
+ # muette sur le meme rush a la meme cadence. Une commande dont
1900
+ # le seul but est d'eclairer le choix d'une cadence ne peut pas
1901
+ # taire ce qui disqualifie ce choix (revue du 2026-08-05).
1902
+ for code in session.selections[current].warnings:
1903
+ logger.info(" reserve de selection: %s", code)
1904
+ outcome = _play_one_pass(
1905
+ session.schedules[current],
1906
+ fps_target=session.fps_targets[current],
1907
+ source_frame_count=session.source_frame_count,
1908
+ lot_total_frames=session.selections[current].expected_frame_count,
1909
+ reader=reader,
1910
+ cache=cache,
1911
+ sink=sink,
1912
+ clock=clock,
1913
+ on_late=on_late,
1914
+ loop_active=loop_active,
1915
+ logger=logger,
1916
+ )
1917
+ reports.append(outcome.report)
1918
+ loop_active = outcome.loop_active
1919
+ if logger is not None:
1920
+ # Une ligne compacte pendant la session; le rapport chiffre
1921
+ # complet est rendu une seule fois, par l'appelant, a la fin.
1922
+ logger.info(
1923
+ "Passe a %g im/s terminee: %d/%d frame(s) presentee(s), %d "
1924
+ "omise(s), retard max %.1f ms, derive %.1f ms, temps reel %s",
1925
+ outcome.report.fps_target,
1926
+ outcome.report.frames_presentees,
1927
+ outcome.report.frames_attendues,
1928
+ outcome.report.frames_omises,
1929
+ outcome.report.retard_max_s * 1000,
1930
+ outcome.report.derive_finale_s * 1000,
1931
+ "tenu" if outcome.report.temps_reel_tenu else "NON TENU",
1932
+ )
1933
+
1934
+ if outcome.interrupted:
1935
+ interrupted = True
1936
+ break
1937
+
1938
+ key = outcome.key
1939
+ # Rejouer sans rien demander suppose DEUX choses: que la boucle
1940
+ # soit armee, et que la passe ait reellement montre quelque chose.
1941
+ #
1942
+ # `frames_presentees > 0` n'est pas une precaution de style. Une
1943
+ # passe qui n'a rien montre -- flux tronque des la premiere image,
1944
+ # fenetre d'affichage hors du rush -- n'a jamais appele `poll_key`:
1945
+ # rebouclee, elle ne laisserait JAMAIS a l'operateur l'occasion
1946
+ # d'appuyer sur quoi que ce soit. Une boucle qu'on ne peut pas
1947
+ # couper n'est pas une fonction, c'est un blocage. Dans ce cas on
1948
+ # rend la main au clavier, boucle armee ou non.
1949
+ peut_reboucler = (
1950
+ loop_active
1951
+ and sink.interactive
1952
+ and outcome.report.frames_presentees > 0
1953
+ )
1954
+ if key is None and sink.interactive and not peut_reboucler:
1955
+ key = sink.wait_for_key()
1956
+ if key == KEY_LOOP:
1957
+ # `l` en fin de passe arme la boucle, et la bascule VAUT la
1958
+ # reponse: il n'y a plus de touche a attendre, la cadence
1959
+ # courante repart. La bascule INVERSE ne s'atteint pas ici
1960
+ # -- boucle armee, on n'attend plus de touche --, et c'est
1961
+ # voulu: on coupe une boucle en vol, pendant que les images
1962
+ # defilent, jamais entre deux passes.
1963
+ loop_active = True
1964
+ key = None
1965
+ peut_reboucler = outcome.report.frames_presentees > 0
1966
+ if key == KEY_QUIT:
1967
+ break
1968
+ # Boucle armee et passe close par sa propre fin: on rejoue LA MEME
1969
+ # cadence, sans reouvrir le fichier ni redemander de touche.
1970
+ if key is None and peut_reboucler:
1971
+ continue
1972
+ if not sink.interactive and key is None:
1973
+ current += 1
1974
+ continue
1975
+ if key == KEY_PREVIOUS:
1976
+ current = max(0, current - 1)
1977
+ elif key == KEY_REPLAY:
1978
+ # Rejouer la meme cadence: la capture est rouverte par le
1979
+ # lecteur des qu'un indice anterieur est demande, jamais
1980
+ # deplacee par CAP_PROP_POS_FRAMES.
1981
+ continue
1982
+ else:
1983
+ current += 1
1984
+ except KeyboardInterrupt:
1985
+ interrupted = True
1986
+ finally:
1987
+ reader.close()
1988
+ sink.close()
1989
+
1990
+ return PrevizResult(
1991
+ session=session,
1992
+ reports=tuple(reports),
1993
+ interrupted=interrupted,
1994
+ decoded_frames=reader.read_count,
1995
+ cache_hits=cache.hits,
1996
+ cache_misses=cache.misses,
1997
+ )