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,1516 @@
|
|
|
1
|
+
"""Stable filename conventions for the MMU reconstruction chain.
|
|
2
|
+
|
|
3
|
+
Story 2.3 defines the minimal, deterministic naming contract that lets a
|
|
4
|
+
third party reconstruct project/rush/lot/page/slot associations directly from
|
|
5
|
+
file names on disk (see ARCHITECTURE_DETAILED.md section 5, matrice de
|
|
6
|
+
responsabilite des metadonnees).
|
|
7
|
+
|
|
8
|
+
`project_id` and `rush_id` are canonically capped at 48 characters before a
|
|
9
|
+
short, deterministic derive is required so file names stay usable across
|
|
10
|
+
filesystems while remaining stable and independent from the local machine
|
|
11
|
+
(`derive_short_id` relies only on `hashlib`, never on Python's randomized
|
|
12
|
+
`hash()`).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import math
|
|
21
|
+
import re
|
|
22
|
+
import unicodedata
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
#: Longueur maximale d'un identifiant canonique (`project_id`, `rush_id`,
|
|
26
|
+
#: `lot_id`).
|
|
27
|
+
#:
|
|
28
|
+
#: **Revenue de 64 a 48 le 2026-08-31** (`EPIC11-ARB-110`, tranche par Egan sur
|
|
29
|
+
#: mesure). Elle avait ete portee a 64 le 2026-08-28 par `ff57780`, un commit
|
|
30
|
+
#: qui ne touche que ce fichier et un banc, qui n'a MESURE aucun QR, et dont le
|
|
31
|
+
#: message cite `EPIC5-ARB-106` -- un numero qui porte en realite « le vrac se
|
|
32
|
+
#: declenche par l'absence de `--lot-slug` », c'est-a-dire rien a voir. C'est
|
|
33
|
+
#: la reference perimee que la section « les politiques se MESURENT » du
|
|
34
|
+
#: CLAUDE.md signale deja.
|
|
35
|
+
#:
|
|
36
|
+
#: **La falaise est a 58 caracteres**, mesuree par le chemin de production
|
|
37
|
+
#: (`plan_page_payload` puis `encode_qr_image` et `symbol_version` sur le cote
|
|
38
|
+
#: du raster). Au cardinal 8 -- une planche a 8 frames, cardinal courant --
|
|
39
|
+
#: cinq identifiants de 58 caracteres encodent sur la **version 22** du
|
|
40
|
+
#: symbole, que `QR_BANNED_SYMBOL_VERSIONS` interdit parce que le detecteur de
|
|
41
|
+
#: production ne la decode a AUCUNE taille imprimee.
|
|
42
|
+
#:
|
|
43
|
+
#: ```
|
|
44
|
+
#: longueur 48 -> version 20 sain, dix caracteres sous la falaise
|
|
45
|
+
#: longueur 57 -> version 21 sain, dernier palier
|
|
46
|
+
#: longueur 58 -> version 22 BANNIE
|
|
47
|
+
#: longueur 64 -> version 22 BANNIE <- la borne d'avant
|
|
48
|
+
#: ```
|
|
49
|
+
#:
|
|
50
|
+
#: 48 est donc retenue plutot que 57 : la falaise exacte ne laisserait AUCUNE
|
|
51
|
+
#: marge, et le prochain champ ajoute au payload reconduirait le defaut a
|
|
52
|
+
#: l'identique. Une frontiere de banc mesure cette marge
|
|
53
|
+
#: (`tests/unit/test_page_payload.py`).
|
|
54
|
+
CANONICAL_ID_MAX_LENGTH = 48
|
|
55
|
+
|
|
56
|
+
#: Longueur maximale TOLEREE A LA LECTURE d'un identifiant deja ecrit.
|
|
57
|
+
#:
|
|
58
|
+
#: `EPIC11-ARB-110` a ramene la borne de CREATION de 64 a 48 le 2026-08-31, sur
|
|
59
|
+
#: la mesure de la falaise QR. Mais la borne a valu 64 du 2026-08-28 au
|
|
60
|
+
#: 2026-08-31, et **des identifiants de 49 a 64 caracteres ont pu etre ecrits
|
|
61
|
+
#: pendant ces trois jours -- puis IMPRIMES dans le QR de planches papier**
|
|
62
|
+
#: (trouve en revue, couche 2, vague 3). Appliquer la borne de creation a la
|
|
63
|
+
#: lecture rendrait ces planches irrescannables, sans nommer d'issue : une
|
|
64
|
+
#: regression silencieuse sur des feuilles qu'aucun calcul ne refait.
|
|
65
|
+
#:
|
|
66
|
+
#: La tolerance est donc EXACTEMENT l'ensemble des identifiants que l'outil a
|
|
67
|
+
#: pu produire, et pas un caractere de plus. Elle ne s'applique qu'a la
|
|
68
|
+
#: LECTURE : `build_lot_id` et les autres constructeurs refusent toujours au
|
|
69
|
+
#: dela de `CANONICAL_ID_MAX_LENGTH`, sans quoi la falaise QR se rouvrirait.
|
|
70
|
+
LEGACY_ID_MAX_LENGTH = 64
|
|
71
|
+
SHORT_DERIVE_HASH_LENGTH = 8
|
|
72
|
+
|
|
73
|
+
#: Longueur du condensat de bornes ajoute a l'identite d'un lot borne
|
|
74
|
+
#: (story 3.7). Fixe, hexadecimal, donc conforme par construction au pattern
|
|
75
|
+
#: `^[A-Za-z0-9_-]+$` du schema v2 quelle que soit la forme des timecodes.
|
|
76
|
+
BOUNDS_SUFFIX_LENGTH = 8
|
|
77
|
+
|
|
78
|
+
#: Extension du fichier des frames extraites par `extract` (story 3.1).
|
|
79
|
+
EXTRACTED_FRAME_SUFFIX = ".tiff"
|
|
80
|
+
|
|
81
|
+
_INVALID_TIMECODE_PATTERN = re.compile(r"[\\/]")
|
|
82
|
+
|
|
83
|
+
# Pattern impose aux identifiants par `mixed_media_utility/specs/project.schema.json`
|
|
84
|
+
# (`project_id`, `rushes[].rush_id`, `lots[].lot_id`). Un `lot_id` qui ne le
|
|
85
|
+
# satisfait pas ferait echouer `validate_manifest` apres une extraction
|
|
86
|
+
# potentiellement longue: la garde vit donc ici, au point de construction.
|
|
87
|
+
_MANIFEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class NamingError(ValueError):
|
|
91
|
+
"""Raised when a naming helper receives an invalid input."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def derive_short_id(value: str, max_length: int = CANONICAL_ID_MAX_LENGTH) -> str:
|
|
95
|
+
"""Return `value` unchanged, or a deterministic short derive if too long.
|
|
96
|
+
|
|
97
|
+
The derive keeps a readable prefix of `value` plus a stable hash suffix,
|
|
98
|
+
computed with `hashlib.sha256` so the result is identical regardless of
|
|
99
|
+
the machine or Python process running it (unlike the built-in, randomized
|
|
100
|
+
`hash()`).
|
|
101
|
+
"""
|
|
102
|
+
if not value:
|
|
103
|
+
raise NamingError("Cannot derive a short id from an empty value")
|
|
104
|
+
if len(value) <= max_length:
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:SHORT_DERIVE_HASH_LENGTH]
|
|
108
|
+
prefix_length = max_length - SHORT_DERIVE_HASH_LENGTH - 1
|
|
109
|
+
if prefix_length < 1:
|
|
110
|
+
raise NamingError(f"max_length {max_length} is too small to fit a short derive")
|
|
111
|
+
prefix = value[:prefix_length]
|
|
112
|
+
return f"{prefix}-{digest}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
#: Caracteres a remplacer pour satisfaire `_MANIFEST_ID_PATTERN`.
|
|
116
|
+
_IDENTIFIER_FORBIDDEN_PATTERN = re.compile(r"[^A-Za-z0-9_-]+")
|
|
117
|
+
_IDENTIFIER_DASH_RUN_PATTERN = re.compile(r"-{2,}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def normalize_identifier(value: str, *, label: str = "valeur") -> str:
|
|
121
|
+
"""Return a schema-conformant identifier derived from any human name.
|
|
122
|
+
|
|
123
|
+
Helper **generique** `nom quelconque -> identifiant`, volontairement pas
|
|
124
|
+
specifique au rush: la story 3.1 l'appelle sur le nom du fichier source
|
|
125
|
+
pour `rush_id`, la story 3.4 sur le nom du dossier projet pour
|
|
126
|
+
`project_id`. Un helper nomme et code « rush » obligerait la seconde a en
|
|
127
|
+
redevelopper un second (`decisions-2026-08-02.md`, decision 2: une seule
|
|
128
|
+
implementation par convention).
|
|
129
|
+
|
|
130
|
+
Chaine de traitement, deterministe et identique sur Windows, macOS et
|
|
131
|
+
Linux (risque R2):
|
|
132
|
+
|
|
133
|
+
1. decomposition Unicode NFKD puis retrait des marques combinantes, de
|
|
134
|
+
sorte que `Cafe` accentue rende `Cafe` plutot qu'une suite de
|
|
135
|
+
caracteres interdits;
|
|
136
|
+
2. remplacement de toute suite de caracteres hors `[A-Za-z0-9_-]` par un
|
|
137
|
+
tiret unique;
|
|
138
|
+
3. reduction des tirets consecutifs et retrait des tirets de bordure;
|
|
139
|
+
4. raccourcissement par `derive_short_id` au-dela de
|
|
140
|
+
`CANONICAL_ID_MAX_LENGTH`.
|
|
141
|
+
|
|
142
|
+
L'ordre des etapes 1-3 avant l'etape 4 n'est pas negociable:
|
|
143
|
+
`derive_short_id` recopie son prefixe **verbatim**, donc normaliser apres
|
|
144
|
+
elle laisserait passer des caracteres interdits par le pattern
|
|
145
|
+
`^[A-Za-z0-9_-]+$` du schema v2.
|
|
146
|
+
|
|
147
|
+
Raises `NamingError` when the normalization yields an empty string (a name
|
|
148
|
+
made only of forbidden characters, e.g. `###`), rather than calling
|
|
149
|
+
`derive_short_id("")` which would raise a message mentioning an empty
|
|
150
|
+
value the caller never supplied.
|
|
151
|
+
"""
|
|
152
|
+
if value is None:
|
|
153
|
+
raise NamingError(f"Impossible de normaliser {label}: valeur absente")
|
|
154
|
+
|
|
155
|
+
text = str(value)
|
|
156
|
+
decomposed = unicodedata.normalize("NFKD", text)
|
|
157
|
+
without_marks = "".join(char for char in decomposed if not unicodedata.combining(char))
|
|
158
|
+
replaced = _IDENTIFIER_FORBIDDEN_PATTERN.sub("-", without_marks)
|
|
159
|
+
collapsed = _IDENTIFIER_DASH_RUN_PATTERN.sub("-", replaced)
|
|
160
|
+
trimmed = collapsed.strip("-")
|
|
161
|
+
|
|
162
|
+
if not trimmed:
|
|
163
|
+
raise NamingError(
|
|
164
|
+
f"Impossible de deriver un identifiant depuis {label} {text!r}: apres "
|
|
165
|
+
"normalisation il ne reste aucun caractere autorise. Le schema v2 impose "
|
|
166
|
+
"le pattern ^[A-Za-z0-9_-]+$ (lettres ASCII, chiffres, tiret, souligne). "
|
|
167
|
+
"Renommer la source avec au moins un caractere alphanumerique ASCII."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
identifier = derive_short_id(trimmed)
|
|
171
|
+
if not _MANIFEST_ID_PATTERN.match(identifier):
|
|
172
|
+
# Defense en profondeur: inatteignable tant que les etapes ci-dessus
|
|
173
|
+
# precedent `derive_short_id`, mais un reordonnancement du code le
|
|
174
|
+
# rendrait atteignable en silence.
|
|
175
|
+
raise NamingError(
|
|
176
|
+
f"Identifiant normalise non conforme au schema v2: {identifier!r} "
|
|
177
|
+
f"(depuis {label} {text!r})"
|
|
178
|
+
)
|
|
179
|
+
return identifier
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def validate_manifest_identifier(
|
|
183
|
+
value: str, *, label: str = "valeur",
|
|
184
|
+
longueur_max: int = CANONICAL_ID_MAX_LENGTH,
|
|
185
|
+
) -> str:
|
|
186
|
+
"""Valider qu'un identifiant est conforme au contrat du manifest v2.
|
|
187
|
+
|
|
188
|
+
Implementation unique de la regle « identifiant canonique » cote lecture
|
|
189
|
+
(revue 4.5): pattern ``^[A-Za-z0-9_-]+$`` du schema v2 et longueur
|
|
190
|
+
maximale ``CANONICAL_ID_MAX_LENGTH``. C'est **strictement** le contrat du
|
|
191
|
+
schema — pas le point fixe de ``normalize_identifier``, qui est plus
|
|
192
|
+
severe (il reduit les suites de tirets et retire les tirets de bordure)
|
|
193
|
+
et refusait des identifiants legitimes du depot: ``derive_short_id``
|
|
194
|
+
recopie son prefixe verbatim, donc ``build_lot_id`` peut produire un
|
|
195
|
+
``lot_id`` en ``...--<hash>`` parfaitement valide au schema mais non
|
|
196
|
+
point-fixe. Verifier ici, jamais transformer.
|
|
197
|
+
"""
|
|
198
|
+
if not isinstance(value, str) or not value:
|
|
199
|
+
raise NamingError(f"{label} doit etre une chaine non vide, recu {value!r}")
|
|
200
|
+
if len(value) > longueur_max:
|
|
201
|
+
raise NamingError(
|
|
202
|
+
f"{label} depasse la longueur canonique maximale de "
|
|
203
|
+
f"{longueur_max} caracteres: {value!r} ({len(value)}). "
|
|
204
|
+
"Un identifiant du manifest passe par derive_short_id des sa "
|
|
205
|
+
"creation et ne peut pas etre plus long."
|
|
206
|
+
)
|
|
207
|
+
if not _MANIFEST_ID_PATTERN.fullmatch(value):
|
|
208
|
+
raise NamingError(
|
|
209
|
+
f"{label} non conforme au pattern ^[A-Za-z0-9_-]+$ du schema v2: "
|
|
210
|
+
f"{value!r}. Passer la valeur canonique du manifest, jamais un nom "
|
|
211
|
+
"humain brut (espaces, accents...)."
|
|
212
|
+
)
|
|
213
|
+
return value
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
#: Forme canonique d'un timecode de selection (story 3.2): ``hh:mm:ss:ff``.
|
|
217
|
+
#: La forme assainie ``hh-mm-ss-ff`` n'existe que dans les noms de fichiers.
|
|
218
|
+
_SELECTION_TIMECODE_PATTERN = re.compile(r"^\d{2}:\d{2}:\d{2}:\d{2}$")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def validate_frame_timecode(value: str, *, label: str = "frame_timecode") -> str:
|
|
222
|
+
"""Valider qu'un timecode porte la forme canonique de la selection 3.2.
|
|
223
|
+
|
|
224
|
+
Point unique de cette regle (revue 4.5): un payload QR portant la forme
|
|
225
|
+
fichier ``hh-mm-ss-ff`` casserait le recoupement QR <-> nom de fichier
|
|
226
|
+
sans aucune erreur — exactement le filet de securite 4.6. Verification
|
|
227
|
+
seule, aucune transformation: la conversion vers la forme fichier vit
|
|
228
|
+
dans ``sanitize_timecode`` au moment du nommage.
|
|
229
|
+
"""
|
|
230
|
+
if not isinstance(value, str) or not value:
|
|
231
|
+
raise NamingError(f"{label} doit etre une chaine non vide, recu {value!r}")
|
|
232
|
+
if not _SELECTION_TIMECODE_PATTERN.fullmatch(value):
|
|
233
|
+
raise NamingError(
|
|
234
|
+
f"{label} ne porte pas la forme canonique hh:mm:ss:ff de la "
|
|
235
|
+
f"selection (story 3.2): {value!r}. La forme assainie hh-mm-ss-ff "
|
|
236
|
+
"des noms de fichiers ne doit jamais entrer dans un payload."
|
|
237
|
+
)
|
|
238
|
+
return value
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def format_fps_short(fps: float) -> str:
|
|
242
|
+
"""Return a compact, filename-safe short form for an fps value.
|
|
243
|
+
|
|
244
|
+
Integral fps values (e.g. `24.0`) are rendered as `"24"`. Fractional
|
|
245
|
+
values (e.g. `23.976`) keep their decimal part with the dot replaced by
|
|
246
|
+
`"p"` (e.g. `"23p976"`).
|
|
247
|
+
|
|
248
|
+
A non-finite fps (`inf`, `nan`) is refused with `NamingError` instead of
|
|
249
|
+
letting `int()` raise a bare `OverflowError`/`ValueError`: no caller has a
|
|
250
|
+
meaning for it and CLI entry points map `NamingError` to an actionable
|
|
251
|
+
exit code (revue 4.1 du 2026-08-06).
|
|
252
|
+
"""
|
|
253
|
+
if not math.isfinite(fps):
|
|
254
|
+
raise NamingError(
|
|
255
|
+
f"Cadence invalide pour un identifiant de lot: {fps!r}. La cadence "
|
|
256
|
+
"doit etre un nombre fini (ex. 5, 12.5, 23.976)."
|
|
257
|
+
)
|
|
258
|
+
if fps == int(fps):
|
|
259
|
+
return str(int(fps))
|
|
260
|
+
text = f"{fps}".rstrip("0").rstrip(".")
|
|
261
|
+
return text.replace(".", "p")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def sanitize_timecode(timecode: str) -> str:
|
|
265
|
+
"""Return a filename-safe timecode, replacing `:`/`;` separators with `-`.
|
|
266
|
+
|
|
267
|
+
Raises `NamingError` if the result would still contain a path separator.
|
|
268
|
+
"""
|
|
269
|
+
if not timecode:
|
|
270
|
+
raise NamingError("Cannot sanitize an empty timecode")
|
|
271
|
+
sanitized = timecode.replace(":", "-").replace(";", "-")
|
|
272
|
+
if _INVALID_TIMECODE_PATTERN.search(sanitized):
|
|
273
|
+
raise NamingError(f"Invalid timecode '{timecode}': path separators are not allowed")
|
|
274
|
+
return sanitized
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def bounds_suffix(
|
|
278
|
+
source_in_timecode: str | None, source_out_timecode: str | None
|
|
279
|
+
) -> str | None:
|
|
280
|
+
"""Condensat identifiant une fenetre d'extraction bornee (story 3.7).
|
|
281
|
+
|
|
282
|
+
Rend `None` quand aucune borne n'est fournie: un lot non borne garde
|
|
283
|
+
exactement l'identite et le nom de dossier qu'il avait avant cette story,
|
|
284
|
+
et reste indiscernable d'un lot d'avant (AC 14).
|
|
285
|
+
|
|
286
|
+
**Un vrai condensat, jamais `derive_short_id`.** Cette derniere rend son
|
|
287
|
+
entree *inchangee* sous `CANONICAL_ID_MAX_LENGTH` caracteres: ce n'est pas
|
|
288
|
+
un hacheur mais un raccourcisseur conditionnel. Lui passer
|
|
289
|
+
`rush-001_3_15:34:30:00_15:34:50:00` (34 caracteres) rendrait la chaine
|
|
290
|
+
brute, deux-points compris, que `build_lot_id` refuserait ensuite via
|
|
291
|
+
`_MANIFEST_ID_PATTERN` -- et seulement sur un nom de rush court, donc un
|
|
292
|
+
defaut dependant des donnees, invisible a un test par echantillonnage.
|
|
293
|
+
|
|
294
|
+
Le separateur `|` n'est pas decoratif: sans lui, `("15:34:30:00", None)` et
|
|
295
|
+
`(None, "15:34:30:00")` condenseraient la meme chaine, alors que ce sont
|
|
296
|
+
deux extraits differents (l'un part de la borne, l'autre s'y arrete).
|
|
297
|
+
"""
|
|
298
|
+
if source_in_timecode is None and source_out_timecode is None:
|
|
299
|
+
return None
|
|
300
|
+
material = f"{source_in_timecode or ''}|{source_out_timecode or ''}"
|
|
301
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:BOUNDS_SUFFIX_LENGTH]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def valider_nom_court_de_cadence(nom_court: str) -> str:
|
|
305
|
+
"""Rend `nom_court` s'il peut nommer une cadence dans un identifiant.
|
|
306
|
+
|
|
307
|
+
Ajout de la story 11.4, lot P (`EPIC11-ARB-62`). Le nom court d'une cadence
|
|
308
|
+
est **calcule ailleurs** -- la ou la cadence est encore une `Fraction`
|
|
309
|
+
exacte, c'est-a-dire `tui/cadences.nom_court_de_cadence` -- et il arrive ici
|
|
310
|
+
deja ecrit. Cette fonction ne le fabrique pas, elle **refuse** ce qui ne peut
|
|
311
|
+
pas entrer dans un identifiant de lot.
|
|
312
|
+
|
|
313
|
+
Le refus est nomme et il arrive **a l'entree** : un nom court fautif qui
|
|
314
|
+
passerait cette garde produirait un `lot_id` que `validate_manifest`
|
|
315
|
+
rejetterait apres une extraction potentiellement longue, ce qui est
|
|
316
|
+
exactement le motif pour lequel `_MANIFEST_ID_PATTERN` vit deja dans ce
|
|
317
|
+
module.
|
|
318
|
+
"""
|
|
319
|
+
if not nom_court:
|
|
320
|
+
raise NamingError(
|
|
321
|
+
"Nom court de cadence vide: un identifiant de lot ne peut pas etre "
|
|
322
|
+
"construit sans fragment de cadence."
|
|
323
|
+
)
|
|
324
|
+
# **`fullmatch`, jamais `match`** (revue de la vague 3, couche 1, finding
|
|
325
|
+
# `T2`). Le motif est ancre `^...$`, et en Python `$` accepte **un saut de
|
|
326
|
+
# ligne final** : `.match()` laissait donc passer `'25s3\n'`, d'ou un
|
|
327
|
+
# `lot_id` et surtout un COMPOSANT DE CHEMIN porteurs d'un `\n`. La ligne
|
|
328
|
+
# 157 de ce meme module emploie `fullmatch` sur le meme motif depuis
|
|
329
|
+
# toujours -- c'est cette asymetrie-la qui etait le defaut.
|
|
330
|
+
if not _MANIFEST_ID_PATTERN.fullmatch(nom_court):
|
|
331
|
+
raise NamingError(
|
|
332
|
+
f"Nom court de cadence invalide: {nom_court!r}. Seuls [A-Za-z0-9_-] "
|
|
333
|
+
"sont acceptes par le schema v2 du manifest (la convention "
|
|
334
|
+
"`EPIC11-ARB-62` ecrit `25s3`, le `s` tenant la place de la barre)."
|
|
335
|
+
)
|
|
336
|
+
return nom_court
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
#: Bornes du rang de version d'un lot (story 5.29, `EPIC11-ARB-89`). Le rang 1
|
|
340
|
+
#: est le lot d'origine et ne porte **aucun** fragment -- seuls les rangs 2 et
|
|
341
|
+
#: au-dela recoivent `_v<rang>`. `EPIC11-ARB-88` (verbatim d'Egan) tranche pour
|
|
342
|
+
#: la forme sans zero de tete: `_v2` ... `_v99`, jamais `_v02`.
|
|
343
|
+
VERSION_RANK_MIN = 2
|
|
344
|
+
VERSION_RANK_MAX = 99
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def format_version_suffix(version_rank: int) -> str:
|
|
348
|
+
"""Rend le fragment `_v<rang>` d'un identifiant de lot versionne.
|
|
349
|
+
|
|
350
|
+
Cinquieme axe de nommage optionnel, au meme niveau que le suffixe de
|
|
351
|
+
bornes (`bounds_suffix`) et le nom court de cadence
|
|
352
|
+
(`valider_nom_court_de_cadence`): **calcule une fois ici**, transporte tel
|
|
353
|
+
quel par `build_lot_id` et `project_layout.rush_dir_slug` (ARB-3 --
|
|
354
|
+
identifiant de lot et nom de dossier ne divergent jamais).
|
|
355
|
+
|
|
356
|
+
`version_rank` hors de `[VERSION_RANK_MIN, VERSION_RANK_MAX]` est refuse
|
|
357
|
+
nommement: en dessous de 2, ce n'est pas une version (le lot d'origine
|
|
358
|
+
n'a pas de fragment) ; au-dela de 99, le fragment gagnerait un troisieme
|
|
359
|
+
chiffre et romprait la promesse de largeur fixe que le budget de
|
|
360
|
+
`CANONICAL_ID_MAX_LENGTH` suppose deja acquise.
|
|
361
|
+
"""
|
|
362
|
+
if not isinstance(version_rank, int) or isinstance(version_rank, bool):
|
|
363
|
+
raise NamingError(
|
|
364
|
+
f"Rang de version invalide: {version_rank!r} (un entier est attendu)."
|
|
365
|
+
)
|
|
366
|
+
if not (VERSION_RANK_MIN <= version_rank <= VERSION_RANK_MAX):
|
|
367
|
+
raise NamingError(
|
|
368
|
+
f"Rang de version hors bornes: {version_rank!r}. Attendu entre "
|
|
369
|
+
f"{VERSION_RANK_MIN} et {VERSION_RANK_MAX} inclus (le rang 1 est le lot "
|
|
370
|
+
"d'origine, qui ne porte aucun fragment de version)."
|
|
371
|
+
)
|
|
372
|
+
return f"_v{version_rank}"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
#: Le fragment de version, reconnu **en fin de nom** et seulement la. La tige
|
|
376
|
+
#: est exigee non vide (`.+`) : `_v2` tout seul n'est pas un nom versionne,
|
|
377
|
+
#: c'est un fragment orphelin. Le premier chiffre est pris dans `[1-9]`, ce qui
|
|
378
|
+
#: refuse les zeros de tete d'un seul coup (`EPIC11-ARB-88` : `_v2` ... `_v99`,
|
|
379
|
+
#: jamais `_v02`).
|
|
380
|
+
_FRAGMENT_DE_VERSION = re.compile(r"^.+_v([1-9][0-9]*)$")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def rang_du_fragment_de_version(nom: str) -> int:
|
|
384
|
+
"""Rendre le rang de version que porte un nom -- l'inverse de la fabrique.
|
|
385
|
+
|
|
386
|
+
C'est l'inverse exact de :func:`format_version_suffix`, et il vit **a cote
|
|
387
|
+
d'elle** pour la meme raison que la regle des rangs vit une seule fois dans
|
|
388
|
+
`io.version_ranks` : la forme `_v<rang>` est une convention, et une
|
|
389
|
+
convention relue ailleurs qu'a l'endroit ou elle est ecrite finit par en
|
|
390
|
+
devenir une seconde.
|
|
391
|
+
|
|
392
|
+
Un nom **sans** fragment rend **1** : le rang d'origine ne porte aucun
|
|
393
|
+
fragment, son absence le dit, et `format_version_suffix` refuse nommement
|
|
394
|
+
de l'ecrire. Tout ce qui *ressemble* a un fragment sans en etre un --
|
|
395
|
+
`_v1` (jamais ecrit), `_v0` et `_v100` (hors des bornes), `_v02` (zero de
|
|
396
|
+
tete), `_vx` -- rend **1** lui aussi : ce n'est pas un rang, donc c'est le
|
|
397
|
+
nom d'une origine dont la tige se termine ainsi, et non une erreur.
|
|
398
|
+
|
|
399
|
+
**Ce module n'est pas le seul a connaitre cette forme, et le dire vaut
|
|
400
|
+
mieux que le taire** : `scan_ingest` porte sa propre expression sur le meme
|
|
401
|
+
fragment, pour un role different -- elle le **reserve** dans un slug
|
|
402
|
+
d'ingestion, afin qu'un dossier ne soit pas a la fois un scan a part
|
|
403
|
+
entiere et la version d'un autre. La rapatrier ici est un refactor qui
|
|
404
|
+
appartient a la story qui touchera `scan_ingest`.
|
|
405
|
+
"""
|
|
406
|
+
fragment = _FRAGMENT_DE_VERSION.match(str(nom))
|
|
407
|
+
if fragment is None:
|
|
408
|
+
return 1
|
|
409
|
+
rang = int(fragment.group(1))
|
|
410
|
+
if not (VERSION_RANK_MIN <= rang <= VERSION_RANK_MAX):
|
|
411
|
+
return 1
|
|
412
|
+
return rang
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
#: Largeur maximale du fragment de DESAMBIGUISATION, tiret compris : `-99`.
|
|
416
|
+
#:
|
|
417
|
+
#: Calculee, jamais ecrite en dur : elle suit `VERSION_RANK_MAX`, de sorte
|
|
418
|
+
#: qu'un jour ou la borne des rangs bougerait, la tige se raccourcirait
|
|
419
|
+
#: d'autant sans qu'on ait a y penser.
|
|
420
|
+
LARGEUR_MAX_FRAGMENT_DESAMBIGUISATION = 1 + len(str(VERSION_RANK_MAX))
|
|
421
|
+
|
|
422
|
+
#: Le fragment de desambiguisation, reconnu **en fin de nom** et seulement la.
|
|
423
|
+
#: Meme forme que `_FRAGMENT_DE_VERSION`, au separateur pres : la tige est
|
|
424
|
+
#: exigee non vide (`.+`) et le premier chiffre est pris dans `[1-9]`, ce qui
|
|
425
|
+
#: refuse les zeros de tete.
|
|
426
|
+
_FRAGMENT_DE_DESAMBIGUISATION = re.compile(r"^(.+)-([1-9][0-9]*)$")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def tige_de_desambiguisation(rush_id: str) -> str:
|
|
430
|
+
"""La TIGE sur laquelle le fragment de desambiguisation se pose.
|
|
431
|
+
|
|
432
|
+
C'est `rush_id` lui-meme dans le cas courant, et un raccourci deterministe
|
|
433
|
+
au-dela de `CANONICAL_ID_MAX_LENGTH - LARGEUR_MAX_FRAGMENT_DESAMBIGUISATION`
|
|
434
|
+
caracteres.
|
|
435
|
+
|
|
436
|
+
**Pourquoi raccourcir ICI plutot que de laisser `normalize_identifier` le
|
|
437
|
+
faire apres coup**, et c'est un defaut mesure sur le code d'avant : cette
|
|
438
|
+
derniere raccourcit la chaine ENTIERE, fragment compris, en recopiant un
|
|
439
|
+
prefixe verbatim suivi d'un condensat -- donc le fragment DISPARAIT du nom
|
|
440
|
+
ecrit. Sur un `rush_id` de 48 caracteres, deux forcages successifs
|
|
441
|
+
calculaient alors le meme rang (le nom ecrit n'etant plus reconnaissable
|
|
442
|
+
comme membre de la famille) et le rendaient sous le meme condensat : deux
|
|
443
|
+
rushes distincts sous le meme identifiant. Raccourcir la tige d'abord, a
|
|
444
|
+
largeur FIXE et independamment du rang, fait tenir tous les rangs de la
|
|
445
|
+
famille sous la meme tige -- ce qui est exactement ce que
|
|
446
|
+
:func:`rang_de_desambiguisation` doit pouvoir relire.
|
|
447
|
+
|
|
448
|
+
**La tige est rendue DEJA CONFORME, et ce n'est pas une coquetterie.**
|
|
449
|
+
`derive_short_id` recopie son prefixe **verbatim** : si `rush_id` porte un
|
|
450
|
+
tiret a l'indice ou la coupe tombe, la tige gagne un DOUBLE tiret. Le nom
|
|
451
|
+
complet repasse ensuite par `normalize_identifier`
|
|
452
|
+
(:func:`extraction.disambiguated_rush_id`), qui **recolle** toute suite de
|
|
453
|
+
tirets en un seul -- le nom ecrit cesse alors d'etre `<tige>-<rang>`,
|
|
454
|
+
:func:`rang_de_desambiguisation` rend `None`, le rang n'est jamais compte
|
|
455
|
+
employe, et **tous les homonymes suivants recoivent le rang 2**.
|
|
456
|
+
|
|
457
|
+
Mesure du 2026-09-05, couche 2 de la revue de la 11.4e, sur un nom de
|
|
458
|
+
fichier entierement naturel (`Tournage exterieur prise 001 camera A bis
|
|
459
|
+
final.mov`, 47 caracteres, tiret a l'indice 35) : trois declarations, trois
|
|
460
|
+
succes annonces, et **deux** entrees au manifeste -- la deuxieme ecrasee en
|
|
461
|
+
silence, `build_rush_declaration_manifest` fusionnant en place par
|
|
462
|
+
`rush_id`. C'est l'ecrasement destructif silencieux qu'`EPIC11-ARB-89` et
|
|
463
|
+
`EPIC11-ARB-104` interdisent nommement, et c'est mot pour mot le defaut que
|
|
464
|
+
le paragraphe ci-dessus declare fermer -- rouvert un appel plus loin.
|
|
465
|
+
|
|
466
|
+
Le recollement se fait donc **ici**, sur la tige seule, avant que le rang
|
|
467
|
+
ne soit pose. Il reste deterministe (fonction du seul `rush_id`), donc tous
|
|
468
|
+
les rangs d'une meme famille partagent toujours la meme tige, et le
|
|
469
|
+
`normalize_identifier` de l'appelant devient un no-op sur ce nom.
|
|
470
|
+
"""
|
|
471
|
+
return _IDENTIFIER_DASH_RUN_PATTERN.sub(
|
|
472
|
+
"-",
|
|
473
|
+
derive_short_id(
|
|
474
|
+
rush_id,
|
|
475
|
+
CANONICAL_ID_MAX_LENGTH - LARGEUR_MAX_FRAGMENT_DESAMBIGUISATION,
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def format_rang_de_desambiguisation(rush_id: str, rang: int) -> str:
|
|
481
|
+
"""Rendre l'identifiant desambigue `<tige>-<rang>` (`EPIC11-ARB-233`).
|
|
482
|
+
|
|
483
|
+
Sixieme axe de nommage, et le seul qui ne designe pas une VERSION : deux
|
|
484
|
+
rushes homonymes ne sont pas deux etats d'un meme objet, ce sont deux
|
|
485
|
+
objets. Le rang n'y numerote donc pas une version mais une **occurrence**
|
|
486
|
+
-- ce qui est precisement pourquoi la forme est `-<rang>` et non le
|
|
487
|
+
`_v<rang>` de :func:`format_version_suffix`. Ecrire `prise01_v2` dirait
|
|
488
|
+
« deuxieme version de prise01 », ce qui est faux.
|
|
489
|
+
|
|
490
|
+
Ce que la forme partage avec les versions, en revanche, c'est la **regle**
|
|
491
|
+
: le rang se consomme et ne se rend qu'en queue (`io.version_ranks`,
|
|
492
|
+
`EPIC11-ARB-108` -- il n'y a pas de mecanisme different par objet). Cette
|
|
493
|
+
fonction n'en calcule rien ; elle met en forme le rang qu'on lui donne.
|
|
494
|
+
|
|
495
|
+
Les bornes sont celles des rangs (`VERSION_RANK_MIN`..`VERSION_RANK_MAX`) :
|
|
496
|
+
en dessous de 2 ce n'est pas une occurrence supplementaire (le premier rush
|
|
497
|
+
ne porte aucun fragment, son absence le dit), au-dela de 99 le fragment
|
|
498
|
+
gagnerait un chiffre et la tige raccourcie ne lui ferait plus de place.
|
|
499
|
+
"""
|
|
500
|
+
if not isinstance(rang, int) or isinstance(rang, bool):
|
|
501
|
+
raise NamingError(
|
|
502
|
+
f"Rang de desambiguisation invalide: {rang!r} (un entier est attendu)."
|
|
503
|
+
)
|
|
504
|
+
if not (VERSION_RANK_MIN <= rang <= VERSION_RANK_MAX):
|
|
505
|
+
raise NamingError(
|
|
506
|
+
f"Rang de desambiguisation hors bornes: {rang!r}. Attendu entre "
|
|
507
|
+
f"{VERSION_RANK_MIN} et {VERSION_RANK_MAX} inclus (le premier rush "
|
|
508
|
+
"d'une famille d'homonymes ne porte aucun fragment)."
|
|
509
|
+
)
|
|
510
|
+
return f"{tige_de_desambiguisation(rush_id)}-{rang}"
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def rang_de_desambiguisation(nom: str, tige: str) -> int | None:
|
|
514
|
+
"""Le rang que `nom` occupe dans la famille de `tige` -- l'inverse exact.
|
|
515
|
+
|
|
516
|
+
Rend :
|
|
517
|
+
|
|
518
|
+
* ``1`` quand `nom` EST la tige : le premier rush ne porte aucun fragment,
|
|
519
|
+
et c'est ainsi que `io.version_ranks.RANG_ORIGINE` s'ecrit -- par
|
|
520
|
+
omission ;
|
|
521
|
+
* le rang quand `nom` vaut ``<tige raccourcie>-<rang>`` avec un rang dans
|
|
522
|
+
les bornes ;
|
|
523
|
+
* ``None`` quand `nom` n'appartient pas a cette famille -- y compris pour
|
|
524
|
+
tout ce qui *ressemble* a un fragment sans en etre un (`-0`, `-100`,
|
|
525
|
+
`-02`, `-hd`). Un `prise01-hd` ecrit avant `EPIC11-ARB-233` est donc un
|
|
526
|
+
etranger a la famille, ce qui est exact : il occupe son nom et aucun
|
|
527
|
+
rang, et le prochain forcage rendra `prise01-2`, libre.
|
|
528
|
+
|
|
529
|
+
Vit **a cote** de la fabrique pour la meme raison que
|
|
530
|
+
:func:`rang_du_fragment_de_version` : une convention relue ailleurs qu'a
|
|
531
|
+
l'endroit ou elle est ecrite finit par en devenir une seconde.
|
|
532
|
+
"""
|
|
533
|
+
texte = str(nom)
|
|
534
|
+
racine = tige_de_desambiguisation(str(tige))
|
|
535
|
+
if texte == str(tige) or texte == racine:
|
|
536
|
+
return 1
|
|
537
|
+
fragment = _FRAGMENT_DE_DESAMBIGUISATION.match(texte)
|
|
538
|
+
if fragment is None or fragment.group(1) != racine:
|
|
539
|
+
return None
|
|
540
|
+
rang = int(fragment.group(2))
|
|
541
|
+
if not (VERSION_RANK_MIN <= rang <= VERSION_RANK_MAX):
|
|
542
|
+
return None
|
|
543
|
+
return rang
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def build_lot_id(
|
|
547
|
+
rush_id: str,
|
|
548
|
+
fps_target: float | int,
|
|
549
|
+
*,
|
|
550
|
+
source_in_timecode: str | None = None,
|
|
551
|
+
source_out_timecode: str | None = None,
|
|
552
|
+
fps_short_name: str | None = None,
|
|
553
|
+
version_rank: int | None = None,
|
|
554
|
+
) -> str:
|
|
555
|
+
"""Return the canonical `lot_id` for `rush_id` extracted at `fps_target`.
|
|
556
|
+
|
|
557
|
+
Convention de la story 3.4 (son AC 5), **implementation unique** du depot:
|
|
558
|
+
`derive_short_id(f"{rush_id}_{format_fps_short(fps_target)}")`. La story 3.1
|
|
559
|
+
l'appelle pour remplir son objet de resultat, la story 3.4 pour le
|
|
560
|
+
controler; la garde `LotIdentityMismatchError` de 3.4 n'a de valeur que si
|
|
561
|
+
les deux cotes appellent cette fonction et non deux copies
|
|
562
|
+
(`decisions-2026-08-02.md`, decision 2).
|
|
563
|
+
|
|
564
|
+
Deterministe et inter-machine: `derive_short_id` s'appuie sur
|
|
565
|
+
`hashlib.sha256`, jamais sur le `hash()` randomise de Python.
|
|
566
|
+
|
|
567
|
+
Le fragment de cadence passe par `format_fps_short`, donc `12.5` rend
|
|
568
|
+
`12p5` et jamais `12.5`: un point violerait le pattern
|
|
569
|
+
`^[A-Za-z0-9_-]+$` que le schema v2 impose a `lots[].lot_id`. C'est la
|
|
570
|
+
meme fonction que celle utilisee par `project_layout.rush_dir_slug`
|
|
571
|
+
depuis l'arbitrage ARB-3 du 2026-08-03, donc le nom de dossier de lot et
|
|
572
|
+
l'identifiant de lot ne peuvent plus diverger.
|
|
573
|
+
|
|
574
|
+
Depuis la story 3.7, une extraction bornee par timecodes ajoute un
|
|
575
|
+
condensat de bornes (`bounds_suffix`), le **meme fragment** que celui que
|
|
576
|
+
`project_layout.rush_dir_slug` ajoute de son cote (ARB-3): `rush-001_3`
|
|
577
|
+
pour une extraction complete, `rush-001_3-a1b2c3d4` pour un extrait. Sans
|
|
578
|
+
lui, deux extraits distincts du meme rush a la meme cadence porteraient le
|
|
579
|
+
meme identifiant et pointeraient vers le meme dossier, donc le second
|
|
580
|
+
ecraserait le premier.
|
|
581
|
+
|
|
582
|
+
Depuis la story 5.29 (`EPIC11-ARB-89`), un cinquieme axe optionnel:
|
|
583
|
+
`version_rank`, place **apres** le suffixe de bornes et compose par
|
|
584
|
+
`format_version_suffix` -- `rush-001_3_v2` pour la seconde version d'une
|
|
585
|
+
extraction complete, `rush-001_3-a1b2c3d4_v2` pour la seconde version d'un
|
|
586
|
+
extrait borne. Absent (defaut), le comportement est inchange au caractere
|
|
587
|
+
pres: c'est le lot de rang 1, qui ne porte aucun fragment.
|
|
588
|
+
|
|
589
|
+
Raises `NamingError` when the result would not satisfy the schema pattern
|
|
590
|
+
(e.g. a `rush_id` carrying a space, or an fps value rendered as a ratio).
|
|
591
|
+
"""
|
|
592
|
+
if not rush_id:
|
|
593
|
+
raise NamingError("Cannot build a lot id from an empty rush_id")
|
|
594
|
+
suffix = bounds_suffix(source_in_timecode, source_out_timecode)
|
|
595
|
+
# `fps_short_name` absent, c'est le chemin d'avant la story 11.4 et il est
|
|
596
|
+
# inchange au caractere pres: tout appelant qui ne passe pas de nom court
|
|
597
|
+
# obtient exactement le nom qu'il obtenait.
|
|
598
|
+
if fps_short_name is None:
|
|
599
|
+
fragment = format_fps_short(fps_target)
|
|
600
|
+
else:
|
|
601
|
+
fragment = valider_nom_court_de_cadence(fps_short_name)
|
|
602
|
+
if suffix is not None:
|
|
603
|
+
fragment = f"{fragment}-{suffix}"
|
|
604
|
+
if version_rank is not None:
|
|
605
|
+
fragment = f"{fragment}{format_version_suffix(version_rank)}"
|
|
606
|
+
|
|
607
|
+
complet = f"{rush_id}_{fragment}"
|
|
608
|
+
# Revue du 2026-08-06. `derive_short_id` raccourcit au-dela de
|
|
609
|
+
# CANONICAL_ID_MAX_LENGTH, `project_layout.rush_dir_slug` non: passe ce
|
|
610
|
+
# seuil, l'identifiant de lot et le nom de dossier divergent, et ARB-3
|
|
611
|
+
# interdit cette divergence. Le condensat de bornes ajoute neuf
|
|
612
|
+
# caracteres, ce qui abaissait le seuil de 47 a 38 -- donc a portee d'un
|
|
613
|
+
# nom de fichier de camera ordinaire, mesure a la revue sur
|
|
614
|
+
# `A005_C012_20260806_TOURNAGE_PLATEAU_PRISE_04` (44 caracteres): lot
|
|
615
|
+
# `...PRI-b7677409` contre dossier `...PRISE_04_5-8c3c0d14`, l'identifiant
|
|
616
|
+
# ayant au passage perdu jusqu'au fragment de cadence.
|
|
617
|
+
#
|
|
618
|
+
# Arbitrage d'Egan a la revue (option c): **refuser a l'entree** plutot que
|
|
619
|
+
# de raccourcir aussi le dossier (qui rendrait illisibles les dossiers de
|
|
620
|
+
# tous les rushs a nom long, y compris non bornes) ou d'ecourter le
|
|
621
|
+
# condensat (qui repousserait le seuil sans le supprimer, en affaiblissant
|
|
622
|
+
# la protection contre les collisions). L'operateur apprend le probleme au
|
|
623
|
+
# moment ou il peut encore renommer son fichier.
|
|
624
|
+
#
|
|
625
|
+
# Le refus ne vise **que** les extractions bornees: le seuil du chemin non
|
|
626
|
+
# borne (47 caracteres) preexiste a la story 3.7, et le durcir ici
|
|
627
|
+
# changerait le comportement d'extractions deja livrees.
|
|
628
|
+
if suffix is not None and len(complet) > CANONICAL_ID_MAX_LENGTH:
|
|
629
|
+
marge = CANONICAL_ID_MAX_LENGTH - len(fragment) - 1
|
|
630
|
+
raise NamingError(
|
|
631
|
+
f"Nom de rush trop long pour une extraction bornee: {rush_id!r} fait "
|
|
632
|
+
f"{len(rush_id)} caracteres, or une extraction bornee n'en accepte que "
|
|
633
|
+
f"{marge} au plus (le condensat de bornes en occupe "
|
|
634
|
+
f"{BOUNDS_SUFFIX_LENGTH + 1}, et l'identifiant de lot est plafonne a "
|
|
635
|
+
f"{CANONICAL_ID_MAX_LENGTH}). Au-dela, l'identifiant du lot serait "
|
|
636
|
+
"raccourci alors que le nom de son dossier ne le serait pas, et les "
|
|
637
|
+
"deux se contrediraient dans le manifest. Deux issues: renommer le "
|
|
638
|
+
"fichier source plus court, ou extraire le rush entier (sans --in ni "
|
|
639
|
+
"--out), qui n'a pas cette limite."
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
# Story 11.4, lot P. Un nom court fourni ne se fait **jamais** tronquer:
|
|
643
|
+
# `derive_short_id` remplacerait sa queue par un condensat, donc `25s3`
|
|
644
|
+
# deviendrait illisible juste au moment ou il etait cense dire « une image
|
|
645
|
+
# sur trois ». Le refus est nomme, comme celui de l'extraction bornee
|
|
646
|
+
# ci-dessus et pour la meme raison: l'operateur apprend le probleme quand il
|
|
647
|
+
# peut encore renommer son fichier. La garde ne vise **que** le chemin du
|
|
648
|
+
# nom court: le seuil du chemin ordinaire (raccourcissement silencieux)
|
|
649
|
+
# preexiste et le durcir changerait le nom d'extractions deja livrees.
|
|
650
|
+
if fps_short_name is not None and len(complet) > CANONICAL_ID_MAX_LENGTH:
|
|
651
|
+
marge = CANONICAL_ID_MAX_LENGTH - len(fragment) - 1
|
|
652
|
+
raise NamingError(
|
|
653
|
+
f"Nom de rush trop long pour la cadence {fps_short_name!r}: "
|
|
654
|
+
f"{rush_id!r} fait {len(rush_id)} caracteres, or cette cadence n'en "
|
|
655
|
+
f"accepte que {marge} au plus (l'identifiant de lot est plafonne a "
|
|
656
|
+
f"{CANONICAL_ID_MAX_LENGTH}). Au-dela, le fragment de cadence serait "
|
|
657
|
+
"remplace par un condensat et le nom ne dirait plus quelle cadence a "
|
|
658
|
+
"ete extraite. Deux issues: renommer le fichier source plus court, "
|
|
659
|
+
"ou choisir une cadence au nom plus court."
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# Story 5.29 (`EPIC11-ARB-89`), meme raisonnement que les deux refus
|
|
663
|
+
# ci-dessus, applique au cinquieme axe: `derive_short_id` tronquerait le
|
|
664
|
+
# fragment de version par un condensat, si bien qu'une "nouvelle version"
|
|
665
|
+
# perdrait jusqu'a son propre numero -- exactement ce que l'operatrice
|
|
666
|
+
# demandait de lire. Le refus ne vise **que** le chemin versionne: un lot
|
|
667
|
+
# de rang 1 (`version_rank is None`) garde le seuil et le comportement
|
|
668
|
+
# d'avant cette story.
|
|
669
|
+
if version_rank is not None and len(complet) > CANONICAL_ID_MAX_LENGTH:
|
|
670
|
+
marge = CANONICAL_ID_MAX_LENGTH - len(fragment) - 1
|
|
671
|
+
raise NamingError(
|
|
672
|
+
f"Nom de rush trop long pour une nouvelle version (rang {version_rank}): "
|
|
673
|
+
f"{rush_id!r} fait {len(rush_id)} caracteres, or cette combinaison n'en "
|
|
674
|
+
f"accepte que {marge} au plus (l'identifiant de lot est plafonne a "
|
|
675
|
+
f"{CANONICAL_ID_MAX_LENGTH}). Au-dela, l'identifiant de la nouvelle "
|
|
676
|
+
"version serait raccourci par un condensat et pourrait perdre jusqu'a "
|
|
677
|
+
"son propre numero de version. Deux issues: renommer le fichier source "
|
|
678
|
+
"plus court, ou ecraser sciemment la version existante plutot que d'en "
|
|
679
|
+
"creer une nouvelle."
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
lot_id = derive_short_id(complet)
|
|
683
|
+
if not _MANIFEST_ID_PATTERN.match(lot_id):
|
|
684
|
+
raise NamingError(
|
|
685
|
+
f"Invalid lot id '{lot_id}': only [A-Za-z0-9_-] are allowed by the v2 "
|
|
686
|
+
f"manifest schema (rush_id={rush_id!r}, fps_target={fps_target!r})"
|
|
687
|
+
)
|
|
688
|
+
return lot_id
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def build_extracted_frame_filename(
|
|
692
|
+
rush_id: str,
|
|
693
|
+
fps_target: float | int,
|
|
694
|
+
frame_timecode: str,
|
|
695
|
+
suffix: str = EXTRACTED_FRAME_SUFFIX,
|
|
696
|
+
*,
|
|
697
|
+
fps_short_name: str | None = None,
|
|
698
|
+
) -> str:
|
|
699
|
+
"""Return the file name of one frame extracted by `extract` (story 3.1).
|
|
700
|
+
|
|
701
|
+
Layout: `<rush>_<fps-court>_<timecode-assaini>.tiff`, par exemple
|
|
702
|
+
`rush-001_12p5_00-00-00-00.tiff`. Il s'aligne exactement sur le slug de
|
|
703
|
+
dossier de `project_layout.rush_dir_slug`, puisque les deux consomment
|
|
704
|
+
`format_fps_short`.
|
|
705
|
+
|
|
706
|
+
**Appartenance**: ce helper appartient au contrat de la story 3.1 (nommage
|
|
707
|
+
des frames extraites). Il est ajoute ici par la story 3.4 pour une raison
|
|
708
|
+
de sequencement et d'une seule: son AC 15 exige que
|
|
709
|
+
`io/extraction_manifest.verify_extracted_lot` controle les noms de fichiers
|
|
710
|
+
en **appelant** la convention plutot qu'en la reimplementant, et 3.4 est
|
|
711
|
+
developpee avant 3.1. La story 3.1 le **consomme** au lieu de le recreer
|
|
712
|
+
(`decisions-2026-08-02.md`, decision 2: une seule implementation).
|
|
713
|
+
|
|
714
|
+
A ne pas confondre avec `build_frame_filename`, qui est la convention de la
|
|
715
|
+
chaine PDF/scan (story 2.3) et exige un `page_index` inexistant a
|
|
716
|
+
l'extraction. `build_frame_filename` reste strictement inchangee.
|
|
717
|
+
|
|
718
|
+
Depuis la story 11.4 (lot P, `EPIC11-ARB-62`), le mot-cle optionnel
|
|
719
|
+
`fps_short_name` remplace le fragment de cadence par le nom court **deja
|
|
720
|
+
calcule** la ou la cadence etait encore une `Fraction` exacte
|
|
721
|
+
(`tui.cadences.nom_court_de_cadence`) : `..._25s3_00-00-00-00.tiff` au lieu
|
|
722
|
+
de `..._8p333333333333334_00-00-00-00.tiff`. C'est le meme mot-cle, de meme
|
|
723
|
+
forme et de meme defaut, que celui de `build_lot_id` et de
|
|
724
|
+
`project_layout.rush_dir_slug` : les trois fragments d'un meme lot --
|
|
725
|
+
l'identifiant, le nom de dossier et le nom de fichier -- viennent du meme
|
|
726
|
+
nom court ou d'aucun, jamais d'un melange des deux. Un lot dont le dossier
|
|
727
|
+
dirait `25s3` et les fichiers `8p333333333333334` echouerait a
|
|
728
|
+
`extraction.check_fps_form_consistency`, qui existe pour cette raison.
|
|
729
|
+
"""
|
|
730
|
+
if not rush_id:
|
|
731
|
+
raise NamingError("Cannot build an extracted frame filename from an empty rush_id")
|
|
732
|
+
short_rush_id = derive_short_id(rush_id)
|
|
733
|
+
# Meme dispatch, meme garde et meme defaut que `build_lot_id` : hors du nom
|
|
734
|
+
# court, le chemin d'avant est repris au caractere pres.
|
|
735
|
+
if fps_short_name is None:
|
|
736
|
+
fps_short = format_fps_short(fps_target)
|
|
737
|
+
else:
|
|
738
|
+
fps_short = valider_nom_court_de_cadence(fps_short_name)
|
|
739
|
+
sanitized_timecode = sanitize_timecode(frame_timecode)
|
|
740
|
+
return f"{short_rush_id}_{fps_short}_{sanitized_timecode}{suffix}"
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
#: Timecode assaini tel que `sanitize_timecode` le produit: `hh-mm-ss-ff`.
|
|
744
|
+
#:
|
|
745
|
+
#: `[0-9]` explicite, jamais `\d`: en Python `\d` est **Unicode** par defaut, si
|
|
746
|
+
#: bien qu'un nom dont le dernier chiffre est un chiffre arabo-indien (U+0660)
|
|
747
|
+
#: etait relu et declare conforme, aller-retour complet (revue 5.6 du
|
|
748
|
+
#: 2026-08-08). Le
|
|
749
|
+
#: nom de fichier est le seul support d'identite d'une frame qui voyage seule:
|
|
750
|
+
#: un caractere qu'aucun systeme de destination ne rend de la meme facon n'y a
|
|
751
|
+
#: pas sa place. La constante est partagee par les deux lecteurs
|
|
752
|
+
#: (`read_extracted_frame_timecode` et `read_scan_frame_timecode`), et le
|
|
753
|
+
#: durcissement ne fait que **retirer** des noms non ASCII du domaine accepte.
|
|
754
|
+
_SANITIZED_TIMECODE_PATTERN = re.compile(r"^[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{2}$")
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def read_extracted_frame_timecode(
|
|
758
|
+
name: str, suffix: str = EXTRACTED_FRAME_SUFFIX
|
|
759
|
+
) -> str:
|
|
760
|
+
"""Rendre le timecode porte par un nom de frame extraite, ou echouer.
|
|
761
|
+
|
|
762
|
+
Inverse de `build_extracted_frame_filename`, et **seul** endroit ou la
|
|
763
|
+
forme `<rush>_<fps-court>_<timecode-assaini>.tiff` est relue. Sans elle,
|
|
764
|
+
`io/extraction_manifest.verify_extracted_lot` reconstruisait le prefixe et
|
|
765
|
+
le motif de timecode en local pour decider quels fichiers examiner, ce que
|
|
766
|
+
son AC 15 interdit nommement: une seconde ecriture de la convention
|
|
767
|
+
divergerait au premier ajustement de la story 3.1
|
|
768
|
+
(`decisions-2026-08-02.md`, decision 2).
|
|
769
|
+
|
|
770
|
+
Ne dit **pas** si le nom appartient au lot: l'appelant reste tenu de
|
|
771
|
+
repasser le timecode a `build_extracted_frame_filename` et de comparer le
|
|
772
|
+
resultat au nom lu. Cette fonction ne fait que la lecture.
|
|
773
|
+
"""
|
|
774
|
+
if not name.endswith(suffix):
|
|
775
|
+
raise NamingError(f"'{name}' ne porte pas le suffixe attendu '{suffix}'")
|
|
776
|
+
stem = name[: -len(suffix)]
|
|
777
|
+
_, separator, sanitized_timecode = stem.rpartition("_")
|
|
778
|
+
if not separator:
|
|
779
|
+
raise NamingError(f"'{name}' ne suit pas la convention <rush>_<fps>_<timecode>")
|
|
780
|
+
if not _SANITIZED_TIMECODE_PATTERN.match(sanitized_timecode):
|
|
781
|
+
raise NamingError(f"'{name}' ne porte pas un timecode assaini hh-mm-ss-ff")
|
|
782
|
+
return sanitized_timecode.replace("-", ":")
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
#: Prefixe des frames **scannees** ecrites sous `frames-scannees/`
|
|
786
|
+
#: (story 5.6, vocabulaire tranche par la story 11.14 / `EPIC11-ARB-214`).
|
|
787
|
+
#:
|
|
788
|
+
#: Le nom de FICHIER, lui, n'a jamais ete faux : `scan_` est deja le mot
|
|
789
|
+
#: d'Egan, et c'est ce que le releve de la 11.14 a etabli -- « le seul
|
|
790
|
+
#: niveau ou le vocabulaire est faux est celui du dossier, de l'option et
|
|
791
|
+
#: du champ de manifeste, pas celui du fichier ». Aucun fichier deja ecrit
|
|
792
|
+
#: n'a donc a etre renomme, ce qui est aussi ce qu'`EPIC11-ARB-171` et
|
|
793
|
+
#: `EPIC11-ARB-222` exigent.
|
|
794
|
+
#:
|
|
795
|
+
#: Il distingue une frame **issue d'un scan** d'une frame extraite d'un rush
|
|
796
|
+
#: (`extract-frames/`), qui porte la meme forme sans prefixe. Le cout est assume et
|
|
797
|
+
#: documente en question ouverte 1 de la story 5.6: deux conventions voisines,
|
|
798
|
+
#: donc deux lecteurs a maintenir -- d'ou leur cohabitation **ici**, cote a
|
|
799
|
+
#: cote, pour que la divergence se voie en un coup d'oeil plutot que de se
|
|
800
|
+
#: decouvrir sur un dossier de sortie introuvable.
|
|
801
|
+
SCAN_FRAME_PREFIX = "scan_"
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def build_scan_frame_filename(
|
|
805
|
+
rush_id: str,
|
|
806
|
+
fps_target: float | int,
|
|
807
|
+
frame_timecode: str,
|
|
808
|
+
suffix: str = EXTRACTED_FRAME_SUFFIX,
|
|
809
|
+
) -> str:
|
|
810
|
+
"""Nom d'une frame scannee ecrite sous `frames-scannees/` (story 5.6).
|
|
811
|
+
|
|
812
|
+
Forme: `scan_<rush>_<fps-court>_<timecode-assaini>.tiff`, par exemple
|
|
813
|
+
`scan_rush-001_12p5_00-00-00-00.tiff`. C'est **strictement** le motif de
|
|
814
|
+
`build_extracted_frame_filename` prefixe de `SCAN_FRAME_PREFIX`, avec les
|
|
815
|
+
memes briques (`derive_short_id`, `format_fps_short`, `sanitize_timecode`,
|
|
816
|
+
`EXTRACTED_FRAME_SUFFIX`): aucune seconde recette de nommage n'est ecrite,
|
|
817
|
+
ni pour la cadence, ni pour le timecode, ni pour le raccourcissement.
|
|
818
|
+
|
|
819
|
+
**Perimetre de `derive_short_id`**: il s'applique au seul `rush_id`, jamais
|
|
820
|
+
a `<rush>_<fps>`. Au-dela de `CANONICAL_ID_MAX_LENGTH` caracteres de
|
|
821
|
+
`rush_id`, le nom de fichier porte donc le derive court alors que le nom de
|
|
822
|
+
dossier (`project_layout.rush_dir_slug`) porte le `rush_id` entier. C'est le
|
|
823
|
+
comportement deja en place cote `extract-frames/`; il est reproduit tel quel et non
|
|
824
|
+
corrige ici, une correction faisant diverger les deux conventions.
|
|
825
|
+
|
|
826
|
+
Le nom est le **seul** support d'identite d'un fichier de sortie qui voyage
|
|
827
|
+
seul, sans manifest partage: il doit etre reconstructible depuis le payload
|
|
828
|
+
QR de sa page et rien d'autre (story 5.6, AC 1).
|
|
829
|
+
"""
|
|
830
|
+
if not rush_id:
|
|
831
|
+
raise NamingError(
|
|
832
|
+
"Cannot build a scan frame filename from an empty rush_id"
|
|
833
|
+
)
|
|
834
|
+
short_rush_id = derive_short_id(rush_id)
|
|
835
|
+
fps_short = format_fps_short(fps_target)
|
|
836
|
+
sanitized_timecode = sanitize_timecode(frame_timecode)
|
|
837
|
+
return f"{SCAN_FRAME_PREFIX}{short_rush_id}_{fps_short}_{sanitized_timecode}{suffix}"
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def read_scan_frame_timecode(
|
|
841
|
+
name: str, suffix: str = EXTRACTED_FRAME_SUFFIX
|
|
842
|
+
) -> str:
|
|
843
|
+
"""Rendre le timecode porte par un nom de frame scannee, ou echouer.
|
|
844
|
+
|
|
845
|
+
Inverse de `build_scan_frame_filename`, et **seul** endroit ou la forme
|
|
846
|
+
`scan_<rush>_<fps-court>_<timecode-assaini>.tiff` est relue. Le prefixe est
|
|
847
|
+
exige: sans lui, un nom de frame extraite (`extract-frames/`) serait accepte
|
|
848
|
+
ici, et
|
|
849
|
+
la verification de conformite du dossier de sortie (story 5.6, AC 7)
|
|
850
|
+
declarerait conforme un fichier qui n'appartient pas a cette convention.
|
|
851
|
+
|
|
852
|
+
Ne dit **pas** si le nom appartient au lot: l'appelant reste tenu de
|
|
853
|
+
repasser le timecode a `build_scan_frame_filename` et de comparer le
|
|
854
|
+
resultat au nom lu -- exactement le contrat de
|
|
855
|
+
`read_extracted_frame_timecode`, dont ce lecteur est le jumeau.
|
|
856
|
+
"""
|
|
857
|
+
if not isinstance(name, str):
|
|
858
|
+
raise NamingError(
|
|
859
|
+
f"Nom de frame scannee invalide: {name!r}. Une chaine est attendue."
|
|
860
|
+
)
|
|
861
|
+
if not name.startswith(SCAN_FRAME_PREFIX):
|
|
862
|
+
raise NamingError(
|
|
863
|
+
f"'{name}' ne porte pas le prefixe attendu '{SCAN_FRAME_PREFIX}' des "
|
|
864
|
+
"frames scannees (story 5.6)"
|
|
865
|
+
)
|
|
866
|
+
if not name.endswith(suffix):
|
|
867
|
+
raise NamingError(f"'{name}' ne porte pas le suffixe attendu '{suffix}'")
|
|
868
|
+
stem = name[len(SCAN_FRAME_PREFIX) : -len(suffix)]
|
|
869
|
+
_, separator, sanitized_timecode = stem.rpartition("_")
|
|
870
|
+
if not separator:
|
|
871
|
+
raise NamingError(
|
|
872
|
+
f"'{name}' ne suit pas la convention scan_<rush>_<fps>_<timecode>"
|
|
873
|
+
)
|
|
874
|
+
if not _SANITIZED_TIMECODE_PATTERN.match(sanitized_timecode):
|
|
875
|
+
raise NamingError(f"'{name}' ne porte pas un timecode assaini hh-mm-ss-ff")
|
|
876
|
+
return sanitized_timecode.replace("-", ":")
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
#: Longueur de l'abreviation d'orientation portee par le nom d'un tirage
|
|
880
|
+
#: (`EPIC11-ARB-171`). Elle n'est pas une table -- `portrait` et `paysage`
|
|
881
|
+
#: rendent `por` et `pay` par TRONCATURE de l'orientation du gabarit, si bien
|
|
882
|
+
#: qu'un vocabulaire d'orientations qui changerait changerait le nom avec lui.
|
|
883
|
+
#: La contrepartie est une collision possible entre deux orientations qui
|
|
884
|
+
#: partageraient leurs trois premieres lettres : c'est une frontiere negative du
|
|
885
|
+
#: banc `test_nommage_des_tirages.py` qui la mesure, pas un commentaire.
|
|
886
|
+
ORIENTATION_ABBREV_LENGTH = 3
|
|
887
|
+
|
|
888
|
+
#: Marqueur de la forme d'AVANT `EPIC11-ARB-171` : `<projet>_<lot>_planches.pdf`.
|
|
889
|
+
#: Il n'est plus JAMAIS ecrit -- il est seulement RECONNU, pour que les tirages
|
|
890
|
+
#: deja poses sur un disque continuent de consommer leur rang.
|
|
891
|
+
LEGACY_SHEETS_MARKER = "planches"
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def sheets_layout_fragment(template_id: str) -> str:
|
|
895
|
+
"""Le fragment de mise en page d'un nom de tirage: `8f-pay`, `4f-por`.
|
|
896
|
+
|
|
897
|
+
**Il est DERIVE du `template_id`, jamais compose a part** (`EPIC11-ARB-171`,
|
|
898
|
+
AC 2.9a). Le cardinal et l'orientation sont lus dans le registre des
|
|
899
|
+
gabarits -- `page_templates.get_template(...)` --, et non redecoupes de la
|
|
900
|
+
chaine : re-deriver ici la grammaire `tpl-a4-<ori>-<N>f[-m<marge>]-<version>`
|
|
901
|
+
serait recopier une convention dont `page_templates` est proprietaire, ce
|
|
902
|
+
que la mesure du 2026-09-02 ecarte nommement dans l'autre sens (« parser les
|
|
903
|
+
noms presents re-deriverait une convention que `io/naming.py` possede »).
|
|
904
|
+
|
|
905
|
+
**Deux conventions coexistent, et c'est assume** (AC 2.9b) : le gabarit
|
|
906
|
+
ecrit l'orientation PUIS le cardinal (`paysage-6f`), le nom de fichier ecrit
|
|
907
|
+
le cardinal PUIS l'orientation abregee (`6f-pay`). `build_template_id` fixe
|
|
908
|
+
la premiere et rien ne la change ; la seconde est celle des maquettes.
|
|
909
|
+
|
|
910
|
+
**Ce que le fragment NE porte pas**, dit plutot que tu : ni le preset de
|
|
911
|
+
marge, ni la version de geometrie. Deux gabarits qui ne different que par
|
|
912
|
+
l'un ou l'autre rendent donc le meme fragment. Ce n'est pas une collision de
|
|
913
|
+
fichiers -- le rang, qui se compte par lot (`EPIC11-ARB-175`), avance a
|
|
914
|
+
chaque tirage et distingue les deux noms --, c'est une information que le
|
|
915
|
+
nom seul ne rend pas. Routee en dette plutot que tranchee ici.
|
|
916
|
+
"""
|
|
917
|
+
# Import LOCAL, et le motif n'est pas la cyclicite: `page_templates` est une
|
|
918
|
+
# feuille qui n'importe rien du paquet. Il est de ne pas trainer le registre
|
|
919
|
+
# des 63 gabarits -- construit a l'import -- dans tous les consommateurs de
|
|
920
|
+
# `io.naming`, dont la TUI qui n'en lit que `CANONICAL_ID_MAX_LENGTH`.
|
|
921
|
+
from ..page_templates import UnknownTemplateError, get_template
|
|
922
|
+
|
|
923
|
+
if not isinstance(template_id, str) or not template_id:
|
|
924
|
+
raise NamingError(
|
|
925
|
+
"Cannot build a sheets PDF filename without a template_id "
|
|
926
|
+
f"(template_id={template_id!r}). C'est lui qui porte la mise en "
|
|
927
|
+
"page, et le nom d'un tirage la porte depuis `EPIC11-ARB-171`."
|
|
928
|
+
)
|
|
929
|
+
try:
|
|
930
|
+
spec = get_template(template_id)
|
|
931
|
+
except UnknownTemplateError as erreur:
|
|
932
|
+
# Traduit en `NamingError` a dessein: les appelants qui BALAIENT des
|
|
933
|
+
# gabarits (`pdf_composition._rangs_sur_le_disque`) n'attrapent que
|
|
934
|
+
# celle-la, et un refus qui leur echapperait ferait disparaitre les
|
|
935
|
+
# tirages des autres formes -- exactement le mode de panne que
|
|
936
|
+
# `EPIC11-ARB-175` (consequence 2) existe pour fermer.
|
|
937
|
+
raise NamingError(str(erreur)) from None
|
|
938
|
+
abrege = spec.orientation[:ORIENTATION_ABBREV_LENGTH]
|
|
939
|
+
return f"{spec.frames_per_page}f-{abrege}"
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def legacy_sheets_pdf_filename(
|
|
943
|
+
project_id: str,
|
|
944
|
+
rush_id: str,
|
|
945
|
+
lot_id: str,
|
|
946
|
+
version_rank: int | None = None,
|
|
947
|
+
) -> str:
|
|
948
|
+
"""La forme d'AVANT `EPIC11-ARB-171`, RECONNUE et jamais ecrite.
|
|
949
|
+
|
|
950
|
+
Elle existe pour une raison mesuree : un tirage deja pose sur le disque a
|
|
951
|
+
**consomme son rang**, et l'ignorer ferait re-attribuer ce rang a une
|
|
952
|
+
planche neuve -- deux feuilles PAPIER portant le meme « tirage N », ce
|
|
953
|
+
qu'`EPIC11-ARB-92` interdit et qu'aucun fichier ne rattrape une fois
|
|
954
|
+
l'encre seche. Aucun fichier deja ecrit n'est renomme (portee tranchee par
|
|
955
|
+
Egan sur `EPIC11-ARB-91`), donc l'ancienne forme reste lisible.
|
|
956
|
+
|
|
957
|
+
Aucun chemin d'ECRITURE ne l'appelle : `build_sheets_pdf_filename` est le
|
|
958
|
+
seul producteur de noms de tirages.
|
|
959
|
+
"""
|
|
960
|
+
_refuser_identifiants_de_tirage_vides(project_id, rush_id, lot_id)
|
|
961
|
+
short_project_id = derive_short_id(project_id)
|
|
962
|
+
suffixe = "" if version_rank is None else format_version_suffix(version_rank)
|
|
963
|
+
return f"{short_project_id}_{lot_id}_{LEGACY_SHEETS_MARKER}{suffixe}.pdf"
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _refuser_identifiants_de_tirage_vides(
|
|
967
|
+
project_id: str, rush_id: str, lot_id: str
|
|
968
|
+
) -> None:
|
|
969
|
+
"""Garde commune aux deux formes de nom de tirage, ecrite une seule fois."""
|
|
970
|
+
if not project_id or not rush_id or not lot_id:
|
|
971
|
+
raise NamingError(
|
|
972
|
+
"Cannot build a sheets PDF filename from empty identifiers "
|
|
973
|
+
f"(project_id={project_id!r}, rush_id={rush_id!r}, lot_id={lot_id!r})"
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def build_sheets_pdf_filename(
|
|
978
|
+
project_id: str,
|
|
979
|
+
rush_id: str,
|
|
980
|
+
lot_id: str,
|
|
981
|
+
version_rank: int | None = None,
|
|
982
|
+
*,
|
|
983
|
+
template_id: str,
|
|
984
|
+
) -> str:
|
|
985
|
+
"""Nom du PDF de planches d'un lot (story 4.1, EPIC4-ARB-3).
|
|
986
|
+
|
|
987
|
+
Layout: `<projet>_<lot>_<Nf-ori>[_vN].pdf`. `project_id` passe par
|
|
988
|
+
`derive_short_id` comme dans `build_frame_filename`; `lot_id` est repris
|
|
989
|
+
verbatim car il est deja une forme courte -- `build_lot_id` le produit par
|
|
990
|
+
`derive_short_id`, et le schema v2 borne son alphabet. Le fichier sort sous
|
|
991
|
+
`planches/` (`EPIC11-ARB-225`; migration `pdf/` au backlog).
|
|
992
|
+
|
|
993
|
+
**Le `rush_id` n'entre PAS dans le nom** (`EPIC7-ARB-91`, 2026-08-27). Il y
|
|
994
|
+
figurait jusqu'ici entre le projet et le lot, alors que `build_lot_id`
|
|
995
|
+
construit precisement `lot_id` comme `<rush_id>_<cadence-courte>` : le nom
|
|
996
|
+
portait donc le rush deux fois de suite, ce qui donnait sur un rush au nom
|
|
997
|
+
parlant `projet_demo_planche_4f_heteroclite_planche_4f_heteroclite_4_planches.pdf`
|
|
998
|
+
la ou `projet_demo_planche_4f_heteroclite_4_planches.pdf` dit strictement
|
|
999
|
+
la meme chose. Aucune information de reconstruction n'est perdue -- le
|
|
1000
|
+
rush et la cadence restent lisibles **dans** le fragment `lot_id`, avec le
|
|
1001
|
+
decoupage que `build_lot_id` fixe.
|
|
1002
|
+
|
|
1003
|
+
**`version_rank` est le rang de version de la PLANCHE**
|
|
1004
|
+
(`EPIC11-ARB-91`, 2026-08-31), et il ne se confond pas avec celui du lot :
|
|
1005
|
+
un lot versionne porte deja son rang dans son `lot_id`, donc dans ce nom.
|
|
1006
|
+
Celui-ci compte les reimpressions du MEME lot apres un reglage change.
|
|
1007
|
+
|
|
1008
|
+
Il ne suffit pas a lui seul, et c'est ce qui distingue la planche du lot ou
|
|
1009
|
+
du master : **une planche imprimee a quitte le disque**. Un nom de fichier
|
|
1010
|
+
ne protege pas une feuille posee sur un bureau. Le rang doit donc atteindre
|
|
1011
|
+
AUSSI l'en-tete imprime et le payload QR -- c'est ce qu'Egan a tranche, et
|
|
1012
|
+
sans quoi versionner une planche serait une precaution qui s'arrete au
|
|
1013
|
+
moment ou elle deviendrait utile.
|
|
1014
|
+
|
|
1015
|
+
Le parametre `rush_id` est **conserve a la signature** et reste valide :
|
|
1016
|
+
tous les appelants l'ont sous la main, il documente l'appartenance du lot,
|
|
1017
|
+
et le retirer casserait les appels nommes sans rien gagner. Aucun fichier
|
|
1018
|
+
deja ecrit n'est renomme (portee tranchee par Egan) -- c'est
|
|
1019
|
+
`legacy_sheets_pdf_filename` qui continue de les RECONNAITRE.
|
|
1020
|
+
|
|
1021
|
+
**Le mot `planches` a disparu du nom** (`EPIC11-ARB-171`, retour A4 d'Egan,
|
|
1022
|
+
2026-09-02). Il ne portait aucune information -- tous les fichiers de ce
|
|
1023
|
+
dossier sont des planches -- alors que deux mises en page du MEME lot
|
|
1024
|
+
rendaient le meme nom, ce qui rendait le motif du conflit opaque : « ce
|
|
1025
|
+
tirage existe deja » sans dire que c'est une autre forme. La place qu'il
|
|
1026
|
+
occupait porte desormais la mise en page, et le nom n'a pas grandi (mesure
|
|
1027
|
+
sur l'exemple des maquettes : 38 caracteres avant, 36 apres).
|
|
1028
|
+
|
|
1029
|
+
**`template_id` est NOMME et obligatoire.** Positionnel, il se serait glisse
|
|
1030
|
+
a la place de `version_rank` chez un appelant distrait, et le nom serait
|
|
1031
|
+
sorti sans bruit avec une mise en page fausse.
|
|
1032
|
+
|
|
1033
|
+
**Aucune garde de longueur n'est posee ici** (AC 2.9e), et c'est delibere :
|
|
1034
|
+
la fonction n'en avait aucune, `CANONICAL_ID_MAX_LENGTH` ne l'a jamais
|
|
1035
|
+
bornee -- `project_id` y passe deja par `derive_short_id`, `lot_id` est
|
|
1036
|
+
repris verbatim --, et en poser une serait un arbitrage neuf que personne
|
|
1037
|
+
n'a demande. La borne reelle est celle du systeme de fichiers (255 octets
|
|
1038
|
+
par composant sur ext4), que ce module ne connait pas et qui etait deja
|
|
1039
|
+
franchissable avant cet arbitrage ; elle est routee en dette.
|
|
1040
|
+
"""
|
|
1041
|
+
_refuser_identifiants_de_tirage_vides(project_id, rush_id, lot_id)
|
|
1042
|
+
fragment = sheets_layout_fragment(template_id)
|
|
1043
|
+
short_project_id = derive_short_id(project_id)
|
|
1044
|
+
# Le rang vient APRES le fragment de mise en page, et le motif est mesure
|
|
1045
|
+
# plutot que d'ecole. La premiere redaction le placait avant, en raisonnant
|
|
1046
|
+
# sur la lecture (« version 2 du lot de planches »). Essai fait: un lot deja
|
|
1047
|
+
# versionne donnait `demo_R_24_v3_v2_planches.pdf` -- DEUX fragments `_vN`
|
|
1048
|
+
# colles, ou ni l'oeil ni une analyse ne peuvent dire lequel est le rang du
|
|
1049
|
+
# lot et lequel celui de la planche.
|
|
1050
|
+
#
|
|
1051
|
+
# `build_master_filename` ne rencontre pas ce probleme pour une raison
|
|
1052
|
+
# precise: le marqueur `_mmu_` SEPARE les deux rangs
|
|
1053
|
+
# (`L_24_v2_mmu_prores_422_v3.mov`). Le fragment de mise en page joue ce
|
|
1054
|
+
# role de marqueur ici, exactement comme `_planches` le jouait avant
|
|
1055
|
+
# `EPIC11-ARB-171` -- une seule regle pour les deux noms, et non deux.
|
|
1056
|
+
if version_rank is not None:
|
|
1057
|
+
return (f"{short_project_id}_{lot_id}_{fragment}"
|
|
1058
|
+
f"{format_version_suffix(version_rank)}.pdf")
|
|
1059
|
+
return f"{short_project_id}_{lot_id}_{fragment}.pdf"
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
#: Longueur du fragment **lisible** du libelle de chaine conserve dans le nom du
|
|
1063
|
+
#: PDF de page de calibration (story 5.23).
|
|
1064
|
+
#:
|
|
1065
|
+
#: Le libelle est de la prose saisie par l'operateur (« hp envy 4520 tiff 600 dpi
|
|
1066
|
+
#: auto corr off »): il ne peut pas entrer verbatim dans un nom de fichier, et il
|
|
1067
|
+
#: n'a pas de forme canonique. Le fragment sert donc a **reconnaitre** le fichier a
|
|
1068
|
+
#: l'oeil dans `planches/`; c'est le condensat qui, lui, porte l'identite.
|
|
1069
|
+
SCAN_CHAIN_SLUG_MAX_LENGTH = 40
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def scan_chain_suffix(scan_chain_label: str) -> str:
|
|
1073
|
+
"""Condensat identifiant un **libelle de chaine de scan** (story 5.23).
|
|
1074
|
+
|
|
1075
|
+
Meme recette que :func:`bounds_suffix`, au caractere: `hashlib.sha256` du
|
|
1076
|
+
materiau brut, tronque a :data:`BOUNDS_SUFFIX_LENGTH` caracteres hexadecimaux.
|
|
1077
|
+
Deterministe et inter-machine -- jamais le `hash()` randomise de Python, jamais
|
|
1078
|
+
une horodate.
|
|
1079
|
+
|
|
1080
|
+
**Pourquoi un condensat et pas seulement un slug**, et c'est la classe de defaut
|
|
1081
|
+
la plus payee du depot: `normalize_identifier` est **non injective**. « hp envy »,
|
|
1082
|
+
« hp-envy » et « hp envy » rendent le meme slug `hp-envy`, donc trois chaines de
|
|
1083
|
+
scan distinctes ecriraient le meme fichier et la derniere ecraserait les autres
|
|
1084
|
+
en silence. Le condensat est calcule sur le libelle **brut**, avant toute
|
|
1085
|
+
normalisation, ce qui est la seule facon de faire porter l'identite par ce qui
|
|
1086
|
+
distingue reellement deux chaines.
|
|
1087
|
+
|
|
1088
|
+
Il n'est pas non plus `derive_short_id`: celle-ci rend son entree *inchangee*
|
|
1089
|
+
sous `CANONICAL_ID_MAX_LENGTH` caracteres -- c'est un raccourcisseur
|
|
1090
|
+
conditionnel, pas un hacheur --, donc elle ne condenserait rien sur un libelle
|
|
1091
|
+
court et ne separerait pas deux libelles qui se normalisent pareil.
|
|
1092
|
+
"""
|
|
1093
|
+
if not scan_chain_label:
|
|
1094
|
+
raise NamingError(
|
|
1095
|
+
"Cannot derive a scan chain suffix from an empty label: une page de "
|
|
1096
|
+
"calibration anonyme ne se distinguerait pas d'une autre."
|
|
1097
|
+
)
|
|
1098
|
+
return hashlib.sha256(
|
|
1099
|
+
scan_chain_label.encode("utf-8")).hexdigest()[:BOUNDS_SUFFIX_LENGTH]
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
#: La queue fixe du nom d'un PDF de page de calibration.
|
|
1103
|
+
#:
|
|
1104
|
+
#: Elle est **nommee** depuis le 2026-09-02 parce qu'un appelant la recopiait :
|
|
1105
|
+
#: `tui/atelier_pdf_calibration.queue_du_nom` composait `_calibration.pdf` en
|
|
1106
|
+
#: litteral, et la frontiere de l'AC 2.8 le voyait -- « un ecran a le droit
|
|
1107
|
+
#: d'AFFICHER un nom que le manifeste lui donne ; il n'a pas le droit d'en
|
|
1108
|
+
#: COMPOSER un ». Le geste est celui qu'`EPIC11-ARB-181` a tranche le meme jour
|
|
1109
|
+
#: pour `RANG_ORIGINE` : la valeur vit ici, l'appelant la nomme, personne ne la
|
|
1110
|
+
#: recopie.
|
|
1111
|
+
CALIBRATION_PDF_SUFFIX = "_calibration.pdf"
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def build_calibration_pdf_filename(project_id: str, scan_chain_label: str) -> str:
|
|
1115
|
+
"""Nom du PDF de la **page de calibration** d'une chaine de scan.
|
|
1116
|
+
|
|
1117
|
+
Layout: `<projet>_<chaine>-<condensat>_calibration.pdf`, meme recette
|
|
1118
|
+
d'abreviation de projet que `build_sheets_pdf_filename` et meme dossier de
|
|
1119
|
+
sortie (`planches/`).
|
|
1120
|
+
|
|
1121
|
+
**Ni rush, ni lot dans ce nom** (story 5.23, AC 8bis, `EPIC5-ARB-82`). Le layout
|
|
1122
|
+
de 5.22 etait `<projet>_<rush>_<lot>_calibration.pdf`, reste du regime **par
|
|
1123
|
+
lot** de 5.16 ou cette page etait la page 0 d'un lot. Depuis `EPIC5-ARB-80` elle
|
|
1124
|
+
sert **toute une chaine de scan** -- tous les lots, tous les rushes, toutes les
|
|
1125
|
+
cadences -- et une page nommee d'un rush est fausse au sens propre.
|
|
1126
|
+
|
|
1127
|
+
**Mais un projet en porte AUTANT que de chaines de scan**, et c'est la correction
|
|
1128
|
+
d'Egan du 2026-08-18: le layout intermediaire `<projet>_calibration.pdf` n'en
|
|
1129
|
+
autorisait qu'**une**, donc calibrer une seconde chaine dans le meme projet
|
|
1130
|
+
aurait exige d'ecraser la premiere. Le nom porte donc **le projet et la chaine**.
|
|
1131
|
+
|
|
1132
|
+
Le libelle etant libre et verbeux, il est reduit en deux morceaux qui ne jouent
|
|
1133
|
+
pas le meme role: un **slug lisible** tronque a
|
|
1134
|
+
:data:`SCAN_CHAIN_SLUG_MAX_LENGTH`, pour reconnaitre le fichier a l'oeil, et le
|
|
1135
|
+
**condensat** de :func:`scan_chain_suffix`, qui porte l'identite. Deux libelles
|
|
1136
|
+
differents rendent donc deux noms differents, y compris quand ils se normalisent
|
|
1137
|
+
en un meme slug -- c'est le condensat, calcule sur le libelle brut, qui les
|
|
1138
|
+
separe (voir `scan_chain_suffix`).
|
|
1139
|
+
|
|
1140
|
+
Le suffixe reste **different** de `_planches`: la page de calibration coexiste
|
|
1141
|
+
avec le PDF de planches dans le meme dossier, et un nom partage aurait fait
|
|
1142
|
+
ecraser l'un par l'autre en silence.
|
|
1143
|
+
"""
|
|
1144
|
+
if not project_id:
|
|
1145
|
+
raise NamingError(
|
|
1146
|
+
"Cannot build a calibration PDF filename from an empty project_id "
|
|
1147
|
+
f"(project_id={project_id!r})"
|
|
1148
|
+
)
|
|
1149
|
+
suffix = scan_chain_suffix(scan_chain_label)
|
|
1150
|
+
try:
|
|
1151
|
+
slug = normalize_identifier(scan_chain_label, label="scan_chain_label")
|
|
1152
|
+
except NamingError as exc:
|
|
1153
|
+
# Un libelle qui ne contient **aucun** caractere alphanumerique ASCII
|
|
1154
|
+
# (« --- », « !!! ») ne laisse aucun fragment lisible. Le condensat suffirait a
|
|
1155
|
+
# nommer le fichier, mais un nom que rien ne rattache a l'oeil a une chaine est
|
|
1156
|
+
# exactement ce que le fragment lisible existe pour eviter: on refuse, en
|
|
1157
|
+
# parlant du libelle et non d'un fichier source.
|
|
1158
|
+
raise NamingError(
|
|
1159
|
+
f"Libelle de chaine de scan inutilisable pour nommer un fichier: "
|
|
1160
|
+
f"{scan_chain_label!r}. Il doit porter au moins un caractere "
|
|
1161
|
+
f"alphanumerique ASCII ({exc})"
|
|
1162
|
+
) from exc
|
|
1163
|
+
slug = slug[:SCAN_CHAIN_SLUG_MAX_LENGTH]
|
|
1164
|
+
# `normalize_identifier` peut rendre un slug termine par un tiret apres la
|
|
1165
|
+
# troncature; le laisser produirait `...-` colle au condensat, illisible.
|
|
1166
|
+
slug = slug.rstrip("-") or "chaine"
|
|
1167
|
+
return f"{derive_short_id(project_id)}_{slug}-{suffix}{CALIBRATION_PDF_SUFFIX}"
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
#: Marqueur de fabrique insere dans le nom d'un master video (story 6.1).
|
|
1171
|
+
#:
|
|
1172
|
+
#: Il distingue un fichier produit par cet outil d'un rush d'origine depose dans
|
|
1173
|
+
#: le meme dossier, et il ne collisionne avec aucun `profile_id` du catalogue
|
|
1174
|
+
#: (verifie sur les sept). `grep "_mmu_"` rendait zero occurrence avant cette
|
|
1175
|
+
#: story: la recette de nom de master n'existait nulle part.
|
|
1176
|
+
MASTER_FILENAME_MARKER = "_mmu_"
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def build_master_filename(
|
|
1180
|
+
*,
|
|
1181
|
+
lot_id: str,
|
|
1182
|
+
profile_id: str,
|
|
1183
|
+
container: str,
|
|
1184
|
+
resolution_segment: str | None = None,
|
|
1185
|
+
version_rank: int | None = None,
|
|
1186
|
+
) -> str:
|
|
1187
|
+
"""Nom du master video d'un lot (story 6.1, AC 12).
|
|
1188
|
+
|
|
1189
|
+
Forme: `<lot_id>_mmu_<profile_id>[_<resolution>].<conteneur>`, par exemple
|
|
1190
|
+
`TEST_FILE_12p5_mmu_prores_hq.mov`. Motif repris de
|
|
1191
|
+
`build_sheets_pdf_filename`: le `lot_id` est pris **verbatim**, car il est
|
|
1192
|
+
deja une forme courte -- `build_lot_id` le produit par `derive_short_id` et
|
|
1193
|
+
le schema v2 borne son alphabet.
|
|
1194
|
+
|
|
1195
|
+
Le nom porte le **lot** et non le rush: deux lots du meme rush a deux
|
|
1196
|
+
cadences sont le cas nominal depuis la v2.1, et un nom fonde sur le rush
|
|
1197
|
+
ferait porter le meme nom a deux masters differents.
|
|
1198
|
+
|
|
1199
|
+
**`version_rank` est le rang du MASTER, distinct du rang du LOT**
|
|
1200
|
+
(`EPIC11-ARB-91`, 2026-08-31). La distinction est facile a manquer : un
|
|
1201
|
+
lot versionne porte deja `_v2` dans son `lot_id`, donc son master s'appelle
|
|
1202
|
+
deja `rushA_24_v2_mmu_prores_422.mov` sans que ce parametre existe. Ce
|
|
1203
|
+
parametre-ci sert au cas different et frequent : **re-encoder le MEME lot
|
|
1204
|
+
au MEME profil** apres un reglage change, sans creer un lot. Sans lui, la
|
|
1205
|
+
seule issue face au master deja present etait `--overwrite`, c'est-a-dire
|
|
1206
|
+
l'issue unique qu'`EPIC11-ARB-89` interdit.
|
|
1207
|
+
|
|
1208
|
+
Verbatim d'Egan qui a tranche que la convention s'y transpose : « le nom
|
|
1209
|
+
d'un master, si je ne m'abuse, porte le codec ? Non ? Donc le v2 indique
|
|
1210
|
+
qu'il s'agit d'une nouvelle version du meme codec non ? » -- exact, et
|
|
1211
|
+
c'est ce qui rend le suffixe univoque ici : profil ET resolution etant
|
|
1212
|
+
deja dans le nom, un `_v2` ne peut designer qu'une nouvelle version du
|
|
1213
|
+
meme profil a la meme resolution.
|
|
1214
|
+
|
|
1215
|
+
`resolution_segment` n'apparait **que** lorsque la resolution n'est pas le
|
|
1216
|
+
defaut: sans lui, les deux sorties possibles d'un meme lot au meme profil
|
|
1217
|
+
(defaut et native) porteraient le meme nom. Sa valeur est decidee par
|
|
1218
|
+
`encode.resolution_name_segment` -- identifiant du registre pour une entree
|
|
1219
|
+
nommee, `<largeur>x<hauteur>` pour une resolution personnalisee -- et n'est
|
|
1220
|
+
pas recalculee ici: ce module nomme, il ne decide pas.
|
|
1221
|
+
"""
|
|
1222
|
+
if not lot_id or not profile_id or not container:
|
|
1223
|
+
raise NamingError(
|
|
1224
|
+
"Cannot build a master filename from empty identifiers "
|
|
1225
|
+
f"(lot_id={lot_id!r}, profile_id={profile_id!r}, container={container!r})"
|
|
1226
|
+
)
|
|
1227
|
+
stem = f"{lot_id}{MASTER_FILENAME_MARKER}{profile_id}"
|
|
1228
|
+
if resolution_segment:
|
|
1229
|
+
stem = f"{stem}_{resolution_segment}"
|
|
1230
|
+
# Le rang de version vient EN DERNIER, apres le profil et la resolution
|
|
1231
|
+
# -- meme ordre que pour un lot (cadence, puis bornes, puis version,
|
|
1232
|
+
# `EPIC11-ARB-88`). Ce n'est pas une preference d'ecriture : les deux
|
|
1233
|
+
# fragments qui precedent sont ceux qui DISTINGUENT deux masters d'un
|
|
1234
|
+
# meme lot, et un rang glisse entre eux ferait varier le prefixe commun
|
|
1235
|
+
# de deux sorties du meme profil.
|
|
1236
|
+
if version_rank is not None:
|
|
1237
|
+
stem = f"{stem}{format_version_suffix(version_rank)}"
|
|
1238
|
+
return f"{stem}.{container}"
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def build_frame_filename(
|
|
1242
|
+
*,
|
|
1243
|
+
project_id: str,
|
|
1244
|
+
rush_id: str,
|
|
1245
|
+
lot_id: str,
|
|
1246
|
+
page_index: int,
|
|
1247
|
+
frame_timecode: str,
|
|
1248
|
+
fps_target: float,
|
|
1249
|
+
slot_index: int | None = None,
|
|
1250
|
+
suffix: str = ".png",
|
|
1251
|
+
) -> str:
|
|
1252
|
+
"""Build a deterministic, stable frame filename per the story 2.3 convention.
|
|
1253
|
+
|
|
1254
|
+
Layout: `{project_id}_{lot_id}_p{page_index}_[s{slot_index}_]tc{timecode}{suffix}`
|
|
1255
|
+
`project_id` is shortened via `derive_short_id` when it exceeds
|
|
1256
|
+
`CANONICAL_ID_MAX_LENGTH`.
|
|
1257
|
+
|
|
1258
|
+
**Ni le rush ni la cadence n'entrent dans le nom** (`EPIC7-ARB-91`,
|
|
1259
|
+
2026-08-27). Ce nom en portait **deux** repetitions, pas une :
|
|
1260
|
+
`build_lot_id` construit `lot_id` comme `<rush_id>_<cadence-courte>`, si
|
|
1261
|
+
bien que `<projet>_<rush>_<lot>_..._fps<cadence>` ecrivait le rush deux
|
|
1262
|
+
fois et la cadence deux fois. Un lot `TEST_FILE_12p5` donnait
|
|
1263
|
+
`projet_demo_TEST_FILE_TEST_FILE_12p5_p001_tc00-00-00-00_fps12p5.png`
|
|
1264
|
+
la ou `projet_demo_TEST_FILE_12p5_p001_tc00-00-00-00.png` dit exactement
|
|
1265
|
+
la meme chose.
|
|
1266
|
+
|
|
1267
|
+
**Le contrat de reconstruction de la story 2.3 tient inchange** : projet,
|
|
1268
|
+
rush, lot, page et emplacement restent tous lisibles du seul nom de
|
|
1269
|
+
fichier. Le rush et la cadence se lisent **dans** le fragment `lot_id`,
|
|
1270
|
+
selon le decoupage que `build_lot_id` fixe -- ils ne sont pas devines.
|
|
1271
|
+
|
|
1272
|
+
Les parametres `rush_id` et `fps_target` sont **conserves a la signature**
|
|
1273
|
+
et restent valides : ils identifient le lot chez tous les appelants et
|
|
1274
|
+
leur retrait casserait des appels nommes sans rien gagner. Aucun fichier
|
|
1275
|
+
deja ecrit n'est renomme (portee tranchee par Egan).
|
|
1276
|
+
|
|
1277
|
+
A ne pas confondre avec `build_extracted_frame_filename` et
|
|
1278
|
+
`build_scan_frame_filename` : ces deux-la ne portent **pas** de `lot_id`,
|
|
1279
|
+
donc ne repetaient rien, et restent strictement inchangees.
|
|
1280
|
+
"""
|
|
1281
|
+
short_project_id = derive_short_id(project_id)
|
|
1282
|
+
sanitized_timecode = sanitize_timecode(frame_timecode)
|
|
1283
|
+
|
|
1284
|
+
components = [short_project_id, lot_id, f"p{page_index + 1:03d}"]
|
|
1285
|
+
if slot_index is not None:
|
|
1286
|
+
components.append(f"s{slot_index:02d}")
|
|
1287
|
+
components.append(f"tc{sanitized_timecode}")
|
|
1288
|
+
|
|
1289
|
+
return "_".join(components) + suffix
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
# ===========================================================================
|
|
1293
|
+
# Relire un nom : les DECOMPOSEURS, a cote de leurs fabriques
|
|
1294
|
+
# ===========================================================================
|
|
1295
|
+
#
|
|
1296
|
+
# **Pourquoi ces deux fonctions vivent ICI et non dans l'ecran qui les
|
|
1297
|
+
# emploie.** Elles ont ete ecrites dans `tui/projet_inventaire.py` et
|
|
1298
|
+
# `tui/projet_suppression.py` par la story 11.11, et c'est une frontiere du
|
|
1299
|
+
# depot qui a dit que c'etait faux :
|
|
1300
|
+
# `test_versionnage_du_scan_en_tui.py::test_AUCUN_module_de_la_TUI_ne_redige_une_regle_de_RANG`
|
|
1301
|
+
# compte a **zero** le vocabulaire des rangs dans `tui/`, prose comprise.
|
|
1302
|
+
#
|
|
1303
|
+
# La frontiere avait raison, et la premiere redaction se contredisait
|
|
1304
|
+
# elle-meme : sa propre docstring citait « l'inverse exact de
|
|
1305
|
+
# `format_version_suffix`, et il vit a cote d'elle pour la meme raison que la
|
|
1306
|
+
# regle des rangs vit une seule fois » -- puis la posait ailleurs. Un
|
|
1307
|
+
# decomposeur loge loin de sa fabrique est exactement la seconde convention que
|
|
1308
|
+
# ce module existe pour empecher (`EPIC11-ARB-108`, « il n'y a pas de mecanisme
|
|
1309
|
+
# different par objet »).
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def tige_et_rang(nom: str) -> tuple[str, int]:
|
|
1313
|
+
"""`("plan-04_12p5", 2)` pour `plan-04_12p5_v2` -- par les DEUX fonctions
|
|
1314
|
+
du depot, jamais par une expression ecrite ailleurs.
|
|
1315
|
+
|
|
1316
|
+
:func:`rang_du_fragment_de_version` lit le rang et
|
|
1317
|
+
:func:`format_version_suffix` rend le fragment a retirer. Une troisieme
|
|
1318
|
+
expression reguliere sur la forme `_vN` serait la seconde convention que ce
|
|
1319
|
+
module existe pour empecher.
|
|
1320
|
+
|
|
1321
|
+
Un nom sans fragment rend `(nom, 1)` : le rang d'origine n'en porte aucun,
|
|
1322
|
+
et son absence le dit. La garde se lit sur `VERSION_RANK_MIN` plutot que
|
|
1323
|
+
sur `version_ranks.RANG_ORIGINE` pour ne pas faire dependre `io.naming` de
|
|
1324
|
+
`io.version_ranks` -- les deux bornes disent ici la meme chose, et
|
|
1325
|
+
`format_version_suffix` **refuse nommement** d'ecrire un fragment sous
|
|
1326
|
+
`VERSION_RANK_MIN` : appeler `len()` sur son refus serait une panne.
|
|
1327
|
+
"""
|
|
1328
|
+
rang = rang_du_fragment_de_version(nom)
|
|
1329
|
+
if rang < VERSION_RANK_MIN:
|
|
1330
|
+
return nom, rang
|
|
1331
|
+
return nom[:-len(format_version_suffix(rang))], rang
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
@dataclass(frozen=True)
|
|
1335
|
+
class ProfilDuMaster:
|
|
1336
|
+
"""Ce qu'un nom de master dit de lui-meme : profil, resolution, rang."""
|
|
1337
|
+
|
|
1338
|
+
profil: str
|
|
1339
|
+
resolution: str | None = None
|
|
1340
|
+
rang: int = 1
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def profil_du_master(nom: str, lot_id: str) -> ProfilDuMaster | None:
|
|
1344
|
+
"""Relire le profil dans le nom d'un master. Rend ``None`` si le nom ne le dit pas.
|
|
1345
|
+
|
|
1346
|
+
**Ce n'est pas une decomposition de nom par ressemblance**, et la
|
|
1347
|
+
distinction est tout le sujet -- le depot refuse partout ailleurs de
|
|
1348
|
+
deviner une filiation en decoupant un identifiant. Ici, trois choses
|
|
1349
|
+
rendent la lecture EXACTE :
|
|
1350
|
+
|
|
1351
|
+
1. **le `lot_id` est connu**, il n'est pas devine. C'est le contexte que
|
|
1352
|
+
`EPIC11-ARB-224` impose deja aux cinq cibles fines, et il est lu du
|
|
1353
|
+
noeud PARENT dans l'arbre. Le prefixe se retire donc par egalite, jamais
|
|
1354
|
+
par une expression reguliere qui « trouverait » un lot dans un nom ;
|
|
1355
|
+
2. **`MASTER_FILENAME_MARKER` est un separateur ecrit par le depot**
|
|
1356
|
+
(`_mmu_`), pas une convention supposee : c'est litteralement ce que
|
|
1357
|
+
:func:`build_master_filename` interpose entre le lot et le profil ;
|
|
1358
|
+
3. **`codec_profiles.PROFILES` est un registre FERME** de sept
|
|
1359
|
+
identifiants -- celui-la meme dont :func:`build_master_filename` a
|
|
1360
|
+
recopie le `profile_id`. On ne lit donc pas « ce qui ressemble a un
|
|
1361
|
+
profil », on reconnait un membre d'un ensemble fini. Un nom qui n'en
|
|
1362
|
+
nomme aucun rend ``None`` plutot qu'une valeur plausible.
|
|
1363
|
+
|
|
1364
|
+
**La reconnaissance s'arrete sur une FRONTIERE, et c'est la seule garde.**
|
|
1365
|
+
`dnxhr_hq` est un prefixe strict de `dnxhr_hqx` : un `startswith` naif
|
|
1366
|
+
lirait `dnxhr_hq` dans `dnxhr_hqx_uhd` et designerait le master d'un AUTRE
|
|
1367
|
+
profil. Le candidat doit donc s'arreter sur une fin de chaine ou sur un `_`,
|
|
1368
|
+
qui est le seul separateur que :func:`build_master_filename` emploie.
|
|
1369
|
+
|
|
1370
|
+
**Une seconde garde a ete RETIREE, et c'est la campagne de mutation qui l'a
|
|
1371
|
+
demande.** Le tri par longueur decroissante -- essayer `dnxhr_hqx` avant
|
|
1372
|
+
`dnxhr_hq` -- rendait le meme resultat, et les deux gardes se masquaient
|
|
1373
|
+
mutuellement : chacune tuait le mutant de l'autre, si bien qu'**aucune des
|
|
1374
|
+
deux n'etait individuellement mesurable**. Deux mutants ont survecu a la
|
|
1375
|
+
campagne pour cette seule raison. Retirer la redondance ne perd rien -- la
|
|
1376
|
+
frontiere de separateur suffit, mesuree sur les sept profils du registre --
|
|
1377
|
+
et rend la garde restante mortelle : le mutant `startswith(profil)` meurt
|
|
1378
|
+
desormais.
|
|
1379
|
+
|
|
1380
|
+
**Ce que ce nom NE tranche PAS, dit plutot que tu.** Le coeur arbitre sur le
|
|
1381
|
+
MANIFESTE, pas sur le nom : devant un master renomme a la main, il REFUSE
|
|
1382
|
+
au lieu de choisir, et aucun inode ne bouge. C'est ce qui rend cette lecture
|
|
1383
|
+
sure plutot que dangereuse -- elle propose une cible, elle ne l'impose pas.
|
|
1384
|
+
|
|
1385
|
+
Ecrit sur demande d'Egan du 2026-09-05 (« il faut resoudre le probleme du
|
|
1386
|
+
"profile". Il est contenu dans le nom du master »), apres une premiere
|
|
1387
|
+
redaction qui renoncait a le lire.
|
|
1388
|
+
"""
|
|
1389
|
+
from .. import codec_profiles
|
|
1390
|
+
|
|
1391
|
+
# **Un `lot_id` VIDE n'est pas un lot connu** (finding `C2-10` de la couche
|
|
1392
|
+
# 2). Sans cette garde, `profil_du_master('_mmu_prores_hq.mov', '')` rend
|
|
1393
|
+
# `ProfilDuMaster('prores_hq', None, 1)` : le prefixe `"" + "_mmu_"` matche,
|
|
1394
|
+
# et la fonction lit un profil sans avoir jamais retire de lot. C'est
|
|
1395
|
+
# exactement ce que le point 1 ci-dessus interdit -- le prefixe se retire
|
|
1396
|
+
# par egalite avec un lot CONNU --, et la garde etait asymetrique :
|
|
1397
|
+
# `cible_du_noeud` ne repoussait que `lot_id is None`.
|
|
1398
|
+
if not lot_id:
|
|
1399
|
+
return None
|
|
1400
|
+
point = nom.rfind(".")
|
|
1401
|
+
tige = nom[:point] if point > 0 else nom
|
|
1402
|
+
prefixe = f"{lot_id}{MASTER_FILENAME_MARKER}"
|
|
1403
|
+
if not tige.startswith(prefixe):
|
|
1404
|
+
return None
|
|
1405
|
+
reste, rang = tige_et_rang(tige[len(prefixe):])
|
|
1406
|
+
if not reste:
|
|
1407
|
+
return None
|
|
1408
|
+
for profil in sorted(codec_profiles.PROFILES):
|
|
1409
|
+
if reste == profil:
|
|
1410
|
+
return ProfilDuMaster(profil, None, rang)
|
|
1411
|
+
if reste.startswith(f"{profil}_"):
|
|
1412
|
+
return ProfilDuMaster(profil, reste[len(profil) + 1:] or None, rang)
|
|
1413
|
+
return None
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def nom_de_version(tige: str, rang: int) -> str:
|
|
1417
|
+
"""`("plan-04_25", 2)` rend `plan-04_25_v2` -- l'inverse de :func:`tige_et_rang`.
|
|
1418
|
+
|
|
1419
|
+
Elle existe pour que personne n'ait a ecrire la concatenation ailleurs. Un
|
|
1420
|
+
`f"{tige}_v{rang}"` compose a la main serait la seconde convention que ce
|
|
1421
|
+
module existe pour empecher, et il ne passerait par aucune des gardes de
|
|
1422
|
+
:func:`format_version_suffix` -- ni la borne haute, ni le refus nomme du
|
|
1423
|
+
rang d'origine.
|
|
1424
|
+
"""
|
|
1425
|
+
return f"{tige}{format_version_suffix(rang)}"
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
#: Ce qu'un suffixe doit avoir pour compter comme une EXTENSION de fichier.
|
|
1429
|
+
#:
|
|
1430
|
+
#: Une borne plutot qu'un registre : `mov`, `mxf`, `pdf`, `tiff`, `png` sont
|
|
1431
|
+
#: les extensions que ce depot produit aujourd'hui, mais en fermer la liste
|
|
1432
|
+
#: ferait echouer la decomposition sur la premiere extension neuve -- et
|
|
1433
|
+
#: echouer en silence, par un orphelin qui part en queue. La borne, elle, ne
|
|
1434
|
+
#: connait rien du contenu : un suffixe court et purement alphanumerique est
|
|
1435
|
+
#: une extension, `_v2` et `12p5` n'en sont pas.
|
|
1436
|
+
LONGUEUR_MAX_D_EXTENSION = 8
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _coupe_l_extension(nom: str) -> tuple[str, str]:
|
|
1440
|
+
"""`("a_v2", ".mov")` pour `a_v2.mov` ; `(nom, "")` quand il n'y en a pas."""
|
|
1441
|
+
point = nom.rfind(".")
|
|
1442
|
+
if point <= 0:
|
|
1443
|
+
return nom, ""
|
|
1444
|
+
suffixe = nom[point + 1:]
|
|
1445
|
+
if not suffixe.isalnum() or len(suffixe) > LONGUEUR_MAX_D_EXTENSION:
|
|
1446
|
+
return nom, ""
|
|
1447
|
+
return nom[:point], nom[point:]
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def tige_et_rang_de_fichier(nom: str) -> tuple[str, int]:
|
|
1451
|
+
"""Comme :func:`tige_et_rang`, mais le rang est AVANT l'extension.
|
|
1452
|
+
|
|
1453
|
+
**Et sans elle, la greffe de version ne marchait NI pour un master NI pour
|
|
1454
|
+
une planche** -- trouve par la couche 2 de la revue de la story 11.11
|
|
1455
|
+
(finding `C2-1`), mesure :
|
|
1456
|
+
|
|
1457
|
+
```
|
|
1458
|
+
tige_et_rang('plan-04_25_mmu_prores_hq_v2.mov')
|
|
1459
|
+
-> ('plan-04_25_mmu_prores_hq_v2.mov', 1) # le nom INTACT
|
|
1460
|
+
```
|
|
1461
|
+
|
|
1462
|
+
`build_master_filename` pose le fragment de version **avant** l'extension
|
|
1463
|
+
-- c'est la seule place ou il puisse aller, un fichier gardant son
|
|
1464
|
+
extension en queue --, tandis que :func:`tige_et_rang` lit le fragment en
|
|
1465
|
+
**fin de nom**. Les deux ont raison chacune de son cote ; ce qui manquait
|
|
1466
|
+
est la fonction qui les accorde. Les deux entrees `NATURE_MASTER` et
|
|
1467
|
+
`NATURE_PLANCHE` du registre des ancres de l'ecran d'inventaire etaient
|
|
1468
|
+
donc MORTES : aucune version de master ni de planche ne se greffait, et
|
|
1469
|
+
elles partaient toutes en queue d'arbre.
|
|
1470
|
+
|
|
1471
|
+
La tige rendue **garde son extension** : c'est le nom du fichier
|
|
1472
|
+
d'ORIGINE, donc celui du noeud declare a cote duquel la version se pose.
|
|
1473
|
+
`('plan-04_25_mmu_prores_hq.mov', 2)` pour l'exemple ci-dessus.
|
|
1474
|
+
|
|
1475
|
+
Un nom sans extension retombe exactement sur :func:`tige_et_rang` -- un
|
|
1476
|
+
lot, un scan et un rush n'en ont pas, et rien ne change pour eux.
|
|
1477
|
+
"""
|
|
1478
|
+
tige, extension = _coupe_l_extension(nom)
|
|
1479
|
+
if not extension:
|
|
1480
|
+
return tige_et_rang(nom)
|
|
1481
|
+
sans_rang, rang = tige_et_rang(tige)
|
|
1482
|
+
return f"{sans_rang}{extension}", rang
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def nom_de_version_de_fichier(nom: str, rang: int) -> str:
|
|
1486
|
+
"""L'inverse EXACT de :func:`tige_et_rang_de_fichier`.
|
|
1487
|
+
|
|
1488
|
+
`("plan-04_25_mmu_prores_hq.mov", 2)` rend
|
|
1489
|
+
`plan-04_25_mmu_prores_hq_v2.mov`, la ou :func:`nom_de_version` rendrait
|
|
1490
|
+
`plan-04_25_mmu_prores_hq.mov_v2` -- un nom que rien ne sait relire, et
|
|
1491
|
+
qu'aucun lecteur de fichiers ne reconnaitrait comme un `.mov`.
|
|
1492
|
+
|
|
1493
|
+
Les deux fonctions vont par paire : decomposer d'un cote et recomposer de
|
|
1494
|
+
l'autre avec des regles differentes est exactement la divergence que ce
|
|
1495
|
+
module existe pour empecher.
|
|
1496
|
+
"""
|
|
1497
|
+
tige, extension = _coupe_l_extension(nom)
|
|
1498
|
+
if not extension:
|
|
1499
|
+
return nom_de_version(nom, rang)
|
|
1500
|
+
return f"{nom_de_version(tige, rang)}{extension}"
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def est_un_rang_de_version(rang: int | None) -> bool:
|
|
1504
|
+
"""Ce rang designe-t-il une VERSION, ou l'origine (ou rien) ?
|
|
1505
|
+
|
|
1506
|
+
Elle existe pour que ses appelants n'aient pas a ecrire `rang > 1`. Ce `1`
|
|
1507
|
+
est la regle des rangs -- ecrite a la main, c'est une seconde convention ;
|
|
1508
|
+
et un appelant qui l'ecrirait dans `tui/` redigerait une regle de rang dans
|
|
1509
|
+
un ecran, ce qu'une frontiere du depot compte a zero.
|
|
1510
|
+
|
|
1511
|
+
``None`` rend ``False`` : un objet sans rang n'a pas de rang. C'est
|
|
1512
|
+
volontairement le meme verdict que le rang d'origine, parce que les deux
|
|
1513
|
+
commandent la meme chose a l'appel -- ne transmettre aucune `version=` au
|
|
1514
|
+
coeur, qui viserait alors un fragment que rien n'ecrit.
|
|
1515
|
+
"""
|
|
1516
|
+
return rang is not None and rang >= VERSION_RANK_MIN
|