diff --git a/CHANGELOG.md b/CHANGELOG.md index 4343a0d7..52398b88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to - 🐛(backend) make start-recording atomic and fault-tolerant - 🔒️(frontend) room ids are generated with non-cryptographic rand - ⬆️(mail) fix dependencies not having resolved or integrity field by updating #1321 +- 🐛(summary) complete webm support #1328 ## [1.15.0] - 2026-04-30 diff --git a/src/summary/summary/core/file_service.py b/src/summary/summary/core/file_service.py index 8df30f34..994b3d1d 100644 --- a/src/summary/summary/core/file_service.py +++ b/src/summary/summary/core/file_service.py @@ -23,6 +23,97 @@ settings = get_settings() logger = logging.getLogger(__name__) +def _get_duration_from_packets(local_path: Path) -> float: + """Estimate duration from audio packet timestamps.""" + # Run ffprobe to inspect the first audio stream in the file. + # ffprobe is part of FFmpeg and can output media metadata as JSON. + # + # ruff: noqa: S607 Hard to know the ffprobe path, it depends on the deployment + result = subprocess.run( + [ + "ffprobe", + # Suppress normal ffprobe logging output. + "-v", + "quiet", + # Ask ffprobe to return JSON. + "-print_format", + "json", + # Select only the first audio stream. + "-select_streams", + "a:0", + # Include packet-level information in the output. + "-show_packets", + # Only include each packet's start timestamp and duration. + "-show_entries", + "packet=pts_time,duration_time", + # Read only the last ~10 packets 99999999 is to go to the end of the file + "-read_intervals", + "99999999%+#10", + # Skip non-reference frames for speed + "-skip_frame", + "noref", + local_path, + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, # Decode stdout/stderr as strings instead of bytes. + ) + + data = json.loads(result.stdout) + # Build a list containing the end time of each audio packet. + # + # For each packet: + # end time = packet start time + packet duration + # + # pts_time is the packet presentation timestamp, meaning when that packet + # starts during playback. + # + # duration_time may be missing, so it defaults to 0. + packet_ends = [ + float(packet["pts_time"]) + float(packet.get("duration_time", 0)) + for packet in data.get("packets", []) + if "pts_time" in packet + ] + + # If no usable packets were found, the duration cannot be estimated. + if not packet_ends: + raise ValueError("Unable to determine recording duration.") + + # The recording duration is estimated as the latest packet end time. + return max(packet_ends) + + +def get_media_duration(local_path: Path): + """Get media (audio or video) file duration in seconds.""" + # ruff: noqa: S607 Hard to know the ffprobe path, it depends on the deployment + result = subprocess.run( + [ + "ffprobe", + # Suppress normal ffprobe logging output. + "-v", + "quiet", + # Ask ffprobe to return JSON. + "-print_format", + "json", + "-show_format", + local_path, + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + data = json.loads(result.stdout) + duration_value = data.get("format", {}).get("duration") + + if duration_value not in (None, "N/A"): + return float(duration_value) + + return _get_duration_from_packets(local_path) + + class FileServiceException(Exception): """Base exception for file service operations.""" @@ -155,25 +246,7 @@ class FileService: def _validate_duration(self, local_path: Path) -> float: """Validate audio file duration against configured maximum.""" - # ruff: noqa: S607 Hard to know the ffprobe path, it depends on the deployment - result = subprocess.run( - [ - "ffprobe", - "-v", - "quiet", - "-print_format", - "json", - "-show_format", - local_path, - ], - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - data = json.loads(result.stdout) - duration = float(data["format"]["duration"]) + duration = get_media_duration(local_path) logger.info( "Recording file duration: %.2f seconds", diff --git a/src/summary/tests/assets/audio-sample-android-chrome.webm b/src/summary/tests/assets/audio-sample-android-chrome.webm new file mode 100644 index 00000000..86fc13af Binary files /dev/null and b/src/summary/tests/assets/audio-sample-android-chrome.webm differ diff --git a/src/summary/tests/assets/audio-sample-android-firefox.ogg b/src/summary/tests/assets/audio-sample-android-firefox.ogg new file mode 100644 index 00000000..c8808a41 Binary files /dev/null and b/src/summary/tests/assets/audio-sample-android-firefox.ogg differ diff --git a/src/summary/tests/assets/audio-sample-android.m4a b/src/summary/tests/assets/audio-sample-android.m4a new file mode 100644 index 00000000..18c7d40f Binary files /dev/null and b/src/summary/tests/assets/audio-sample-android.m4a differ diff --git a/src/summary/tests/assets/audio-sample-chromium.webm b/src/summary/tests/assets/audio-sample-chromium.webm new file mode 100644 index 00000000..f77f9754 Binary files /dev/null and b/src/summary/tests/assets/audio-sample-chromium.webm differ diff --git a/src/summary/tests/assets/audio-sample-firefox.ogg b/src/summary/tests/assets/audio-sample-firefox.ogg new file mode 100644 index 00000000..83b5310a Binary files /dev/null and b/src/summary/tests/assets/audio-sample-firefox.ogg differ diff --git a/src/summary/tests/assets/audio-sample-ios-browser.webm b/src/summary/tests/assets/audio-sample-ios-browser.webm new file mode 100644 index 00000000..ef62b093 Binary files /dev/null and b/src/summary/tests/assets/audio-sample-ios-browser.webm differ diff --git a/src/summary/tests/assets/audio-sample-ios.m4a b/src/summary/tests/assets/audio-sample-ios.m4a new file mode 100644 index 00000000..b6ac5942 Binary files /dev/null and b/src/summary/tests/assets/audio-sample-ios.m4a differ diff --git a/src/summary/tests/assets/audio-sample-mac-os-safari.webm b/src/summary/tests/assets/audio-sample-mac-os-safari.webm new file mode 100644 index 00000000..1340634a Binary files /dev/null and b/src/summary/tests/assets/audio-sample-mac-os-safari.webm differ diff --git a/src/summary/tests/assets/video-sample-visio.mp4 b/src/summary/tests/assets/video-sample-visio.mp4 new file mode 100644 index 00000000..afa4369f Binary files /dev/null and b/src/summary/tests/assets/video-sample-visio.mp4 differ diff --git a/src/summary/tests/unit/test_file_service.py b/src/summary/tests/unit/test_file_service.py new file mode 100644 index 00000000..30a70d87 --- /dev/null +++ b/src/summary/tests/unit/test_file_service.py @@ -0,0 +1,30 @@ +"""Unit tests for the file service.""" + +from pathlib import Path + +import pytest + +from summary.core.file_service import get_media_duration + + +@pytest.mark.parametrize( + "filename, duration", + [ + ("audio-sample-android-chrome.webm", 2.2795), + ("audio-sample-android-firefox.ogg", 2.3025), + ("audio-sample-android.m4a", 1.38), + ("audio-sample-chromium.webm", 2.65), + ("audio-sample-firefox.ogg", 2.0865), + ("audio-sample-ios-browser.webm", 2.6229), + ("audio-sample-ios.m4a", 1.408), + ("audio-sample-mac-os-safari.webm", 2.3049), + ("video-sample-visio.mp4", 5.34059), + ], +) +def test_validate_duration_supports_all_used_file_formats( + filename: str, duration: float +) -> None: + """Validate duration for Safari iPhone WebM files without format duration.""" + audio_path = Path(__file__).parent.parent / "assets" / filename + + assert get_media_duration(audio_path) == pytest.approx(duration, 1e-3)