diff --git a/src/hflow/ffmpeg/_contact_sheet.py b/src/hflow/ffmpeg/_contact_sheet.py index 022afc5b..7daa526b 100644 --- a/src/hflow/ffmpeg/_contact_sheet.py +++ b/src/hflow/ffmpeg/_contact_sheet.py @@ -5,9 +5,9 @@ we ship -- there is deliberately no bundled VLM client. Implementation: a single ffmpeg invocation over the frame files -(``concat`` + ``scale`` + ``tile``). Timestamp burn-in uses ``drawtext`` -when a usable font is found (``fc-match`` or common font paths); if none is -available the sheet is still produced without burn-in and +(``concat`` + ``scale`` + ``tile``). Timestamp burn-in uses ``drawtext`` when +both the filter and a usable font are available (``fc-match`` or common font +paths); otherwise the sheet is still produced without burn-in and ``ContactSheet.timestamps_burned`` is False -- callers can pass timestamps in the prompt instead. If more frames are given than ``max_tiles``, frames are sampled evenly and the drop is reported on the result, never silent. @@ -19,6 +19,7 @@ import tempfile from collections.abc import Sequence from dataclasses import dataclass +from functools import cache from pathlib import Path from typing import TYPE_CHECKING @@ -64,6 +65,22 @@ def _find_usable_font_file() -> Path | None: return None +@cache +def _ffmpeg_supports_drawtext(ffmpeg_binary: Path) -> bool: + completed = subprocess.run( + [str(ffmpeg_binary), "-hide_banner", "-filters"], + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + return False + return any( + len(columns := line.split()) > 1 and columns[1] == "drawtext" + for line in completed.stdout.splitlines() + ) + + def _evenly_sampled_indices(total_count: int, max_count: int) -> list[int]: """Up to ``max_count`` indices evenly spread over ``range(total_count)``, always including the first and last frame.""" @@ -132,9 +149,12 @@ def contact_sheet( selected_frames = [frames[index] for index in _evenly_sampled_indices(len(frames), max_tiles)] rows = math.ceil(len(selected_frames) / columns) + ffmpeg_binary = ffmpeg_path() font_file = _find_usable_font_file() + timestamps_burned = font_file is not None and _ffmpeg_supports_drawtext(ffmpeg_binary) filter_chain: list[str] = [f"scale={tile_width}:-1"] - if font_file is not None: + if timestamps_burned: + assert font_file is not None filter_chain.extend(_drawtext_filters(selected_frames, font_file, tile_width)) filter_chain.append(f"tile={columns}x{rows}") @@ -143,7 +163,7 @@ def contact_sheet( concat_list_path = Path(staging_dir_name) / "frames.txt" _write_concat_list(selected_frames, concat_list_path) command = [ - str(ffmpeg_path()), + str(ffmpeg_binary), "-hide_banner", "-nostats", "-y", @@ -171,6 +191,6 @@ def contact_sheet( columns=columns, rows=rows, tile_log_times_ns=[frame.log_time_ns for frame in selected_frames], - timestamps_burned=font_file is not None, + timestamps_burned=timestamps_burned, frames_sampled_from=len(frames), ) diff --git a/tests/test_ffmpeg.py b/tests/test_ffmpeg.py index 79ed0b99..a7bbc11c 100644 --- a/tests/test_ffmpeg.py +++ b/tests/test_ffmpeg.py @@ -582,7 +582,10 @@ def test_contact_sheet_grid_geometry( width, height = _probe_dimensions(output) assert width == 4 * 320 assert height == 3 * 240 # 320x240 sources scaled to width 320 keep height 240 - assert sheet.timestamps_burned == (_find_usable_font_file() is not None) + assert sheet.timestamps_burned == ( + _find_usable_font_file() is not None + and _contact_sheet._ffmpeg_supports_drawtext(ffmpeg_path()) + ) def test_contact_sheet_max_tiles_sampling( @@ -601,6 +604,30 @@ def test_contact_sheet_max_tiles_sampling( assert width == 4 * 160 +def test_contact_sheet_without_drawtext_still_produces_sheet( + ten_extracted_frames: list[ExtractedFrame], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + _contact_sheet, + "_find_usable_font_file", + lambda: Path("/a/usable/font.ttf"), + ) + monkeypatch.setattr( + _contact_sheet, + "_ffmpeg_supports_drawtext", + lambda _ffmpeg_binary: False, + ) + + output = tmp_path / "sheet-without-drawtext.jpg" + sheet = contact_sheet(ten_extracted_frames[:2], output, columns=2) + + assert output.is_file() + assert _probe_dimensions(output) == (640, 240) + assert sheet.timestamps_burned is False + + def test_contact_sheet_accepts_apostrophes_in_external_paths( ten_extracted_frames: list[ExtractedFrame], tmp_path: Path,