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.
- mixed_media_utility/__init__.py +1 -0
- mixed_media_utility/cadence_previz.py +1997 -0
- mixed_media_utility/cli.py +5325 -0
- mixed_media_utility/codec_profiles.py +2373 -0
- mixed_media_utility/color_calibration.py +4303 -0
- mixed_media_utility/color_metrics.py +1314 -0
- mixed_media_utility/color_pipeline.py +388 -0
- mixed_media_utility/constants.py +6 -0
- mixed_media_utility/declaration_de_rush.py +1335 -0
- mixed_media_utility/detection/__init__.py +35 -0
- mixed_media_utility/detection/aruco.py +463 -0
- mixed_media_utility/encode.py +3204 -0
- mixed_media_utility/encode_master.py +724 -0
- mixed_media_utility/encode_previz.py +1976 -0
- mixed_media_utility/extraction.py +1723 -0
- mixed_media_utility/extraction_previz.py +1002 -0
- mixed_media_utility/ffmpeg_utils.py +629 -0
- mixed_media_utility/frame_selection.py +994 -0
- mixed_media_utility/gamut_map.py +393 -0
- mixed_media_utility/gui/__init__.py +6 -0
- mixed_media_utility/gui/__main__.py +102 -0
- mixed_media_utility/gui/application.py +125 -0
- mixed_media_utility/gui/atelier_scan.py +639 -0
- mixed_media_utility/gui/barre_de_vue.py +310 -0
- mixed_media_utility/gui/cartes_taches.py +214 -0
- mixed_media_utility/gui/catalogue.py +399 -0
- mixed_media_utility/gui/chargeur_detections.py +119 -0
- mixed_media_utility/gui/chutier.py +1153 -0
- mixed_media_utility/gui/coquille.py +1384 -0
- mixed_media_utility/gui/depot_projets.py +266 -0
- mixed_media_utility/gui/ecran_projet.py +958 -0
- mixed_media_utility/gui/executeur.py +193 -0
- mixed_media_utility/gui/feuille_application.py +257 -0
- mixed_media_utility/gui/jetons.py +432 -0
- mixed_media_utility/gui/lecture_detection.py +431 -0
- mixed_media_utility/gui/modele_chutier.py +680 -0
- mixed_media_utility/gui/modele_projets.py +279 -0
- mixed_media_utility/gui/modele_zone_tampon.py +478 -0
- mixed_media_utility/gui/preferences_projets.py +98 -0
- mixed_media_utility/gui/raster_de_page.py +153 -0
- mixed_media_utility/gui/scan_jugement.py +1351 -0
- mixed_media_utility/gui/surimpressions.py +520 -0
- mixed_media_utility/gui/zone_tampon.py +587 -0
- mixed_media_utility/io/__init__.py +67 -0
- mixed_media_utility/io/calibration_profile.py +1316 -0
- mixed_media_utility/io/encode_manifest.py +940 -0
- mixed_media_utility/io/extraction_manifest.py +2018 -0
- mixed_media_utility/io/manifest.py +319 -0
- mixed_media_utility/io/metadata_matrix.py +237 -0
- mixed_media_utility/io/naming.py +1516 -0
- mixed_media_utility/io/payload.py +1334 -0
- mixed_media_utility/io/pdf_manifest.py +483 -0
- mixed_media_utility/io/profile_designation.py +478 -0
- mixed_media_utility/io/project_layout.py +660 -0
- mixed_media_utility/io/reconstruction.py +1120 -0
- mixed_media_utility/io/scan_manifest.py +2216 -0
- mixed_media_utility/io/version_ranks.py +164 -0
- mixed_media_utility/layout.py +313 -0
- mixed_media_utility/makepdf.py +1097 -0
- mixed_media_utility/numeric_guards.py +44 -0
- mixed_media_utility/page_payload.py +360 -0
- mixed_media_utility/page_roles.py +149 -0
- mixed_media_utility/page_templates.py +2701 -0
- mixed_media_utility/patch_presets.py +1599 -0
- mixed_media_utility/patch_values.py +1018 -0
- mixed_media_utility/pdf_composition.py +2763 -0
- mixed_media_utility/pdf_previz.py +1017 -0
- mixed_media_utility/pdf_render.py +417 -0
- mixed_media_utility/previz_common.py +361 -0
- mixed_media_utility/progression.py +340 -0
- mixed_media_utility/project_inventory.py +1213 -0
- mixed_media_utility/project_maintenance.py +2995 -0
- mixed_media_utility/qr_codes.py +920 -0
- mixed_media_utility/relink.py +634 -0
- mixed_media_utility/scan_calibrate.py +855 -0
- mixed_media_utility/scan_chain.py +240 -0
- mixed_media_utility/scan_corrections.py +1166 -0
- mixed_media_utility/scan_crop.py +481 -0
- mixed_media_utility/scan_detect.py +1395 -0
- mixed_media_utility/scan_detection.py +1432 -0
- mixed_media_utility/scan_ingest.py +1974 -0
- mixed_media_utility/scan_output_frames.py +2238 -0
- mixed_media_utility/scan_previz.py +3113 -0
- mixed_media_utility/scan_sorting.py +825 -0
- mixed_media_utility/scan_write.py +2298 -0
- mixed_media_utility/source_confirmation.py +1225 -0
- mixed_media_utility/specs/project.schema.json +999 -0
- mixed_media_utility/specs/project.schema.legacy.json +52 -0
- mixed_media_utility/specs/project.schema.v2-0.json +323 -0
- mixed_media_utility/video_metadata.py +416 -0
- mmu_cli-0.1.0.dist-info/METADATA +217 -0
- mmu_cli-0.1.0.dist-info/RECORD +96 -0
- mmu_cli-0.1.0.dist-info/WHEEL +4 -0
- mmu_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mmu_cli-0.1.0.dist-info/licenses/LICENSE +674 -0
- 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
|
+
)
|