@@ -28,6 +28,36 @@ def run_ffmpeg(args: list[str], output_log_level: str = "error") -> None:
2828 subprocess .run (["ffmpeg" , "-y" , "-loglevel" , output_log_level , * args ], check = True , capture_output = True , text = True )
2929
3030
31+ _svtav1_available_cache : Optional [bool ] = None
32+
33+
34+ def is_svtav1_available () -> bool :
35+ """Return True iff the running ffmpeg binary has a working libsvtav1 encoder.
36+
37+ Probes by attempting to encode a single 16×16 black frame to a null sink.
38+ Result is cached in a module-level variable so the probe runs at most once.
39+ """
40+ global _svtav1_available_cache
41+ if _svtav1_available_cache is None :
42+ try :
43+ subprocess .run (
44+ [
45+ "ffmpeg" , "-y" , "-loglevel" , "error" ,
46+ "-f" , "lavfi" , "-i" , "color=black:size=16x16:rate=1:duration=0.1" ,
47+ "-c:v" , "libsvtav1" ,
48+ "-pix_fmt" , "yuv420p" ,
49+ "-f" , "null" , "-" ,
50+ ],
51+ check = True ,
52+ capture_output = True ,
53+ text = True ,
54+ )
55+ _svtav1_available_cache = True
56+ except (subprocess .CalledProcessError , FileNotFoundError ):
57+ _svtav1_available_cache = False
58+ return _svtav1_available_cache
59+
60+
3161def should_include (path : Path , include_globs : list [str ], exclude_globs : list [str ], root : Path ) -> bool :
3262 relative = path .relative_to (root ).as_posix ()
3363 included = any (fnmatch .fnmatch (relative , pattern ) for pattern in include_globs )
@@ -147,8 +177,10 @@ def convert_image(source: Path, config: dict, relative_path: str) -> tuple[Path,
147177 # silently strips alpha channels, producing a tiny but completely transparent
148178 # output. PNG files without alpha and all JPEG sources are safe to encode as
149179 # AVIF because yuv420p has no alpha plane.
180+ # is_svtav1_available() probes once and caches the result, so no per-file
181+ # overhead and no per-file warnings when the encoder is absent.
150182 can_use_avif = ext in {".jpg" , ".jpeg" } or (ext == ".png" and not has_alpha )
151- if can_use_avif and bool (config .get ("jpeg_avif_enabled" , False )):
183+ if can_use_avif and bool (config .get ("jpeg_avif_enabled" , False )) and is_svtav1_available () :
152184 avif_crf = int (config .get ("jpeg_avif_crf" , 30 ))
153185 avif_preset = int (config .get ("jpeg_avif_preset" , 4 ))
154186 p_avif = source .parent / f".{ source .stem } .lossy.avif.tmp"
@@ -167,8 +199,6 @@ def convert_image(source: Path, config: dict, relative_path: str) -> tuple[Path,
167199 )
168200 temp_candidates .append ((p_avif , f"{ src_label } -avif-svtav1-crf{ avif_crf } -p{ avif_preset } " , False , ".avif" ))
169201 except subprocess .CalledProcessError :
170- # Log a warning so the operator knows AVIF was requested but unavailable.
171- print (f"::warning::AVIF requested for { relative_path } but libsvtav1 encode failed; falling back to WebP." )
172202 p_avif .unlink (missing_ok = True )
173203
174204 # ── Pick the smallest candidate that meets the SSIM threshold ────────────
@@ -298,6 +328,11 @@ def optimize(root: Path, config: dict, dry_run: bool) -> dict:
298328 allow_video = bool (config .get ("enable_video_conversion" , True ))
299329 keep_originals = bool (config .get ("keep_original_files" , True ))
300330
331+ # Probe libsvtav1 once upfront so we emit at most one notice rather than
332+ # one warning per AVIF-eligible file when the encoder is absent.
333+ if bool (config .get ("jpeg_avif_enabled" , False )) and not is_svtav1_available ():
334+ print ("::notice::jpeg_avif_enabled is true but libsvtav1 is unavailable; AVIF skipped, falling back to WebP." )
335+
301336 results : list [ConversionResult ] = []
302337 skipped : list [str ] = []
303338
0 commit comments