Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions src/hflow/ffmpeg/_contact_sheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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}")

Expand All @@ -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",
Expand Down Expand Up @@ -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),
)
29 changes: 28 additions & 1 deletion tests/test_ffmpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down
Loading