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