diff --git a/app/ffmpeg.py b/app/ffmpeg.py index 20fd1f3..7ed6990 100644 --- a/app/ffmpeg.py +++ b/app/ffmpeg.py @@ -1,6 +1,8 @@ import shlex from pathlib import Path +from app.models import Progress + # FFmpeg options that take a value (not exhaustive, covers common ones) OPTIONS_WITH_VALUES = { "-c", "-c:v", "-c:a", "-b", "-b:v", "-b:a", "-r", "-s", "-ar", "-ac", @@ -41,3 +43,59 @@ def resolve_paths(args: list[str], data_path: str) -> list[str]: resolved.append(arg) return resolved + + +def parse_progress(output: str, duration_seconds: float | None) -> Progress: + """Parse FFmpeg progress output into Progress model.""" + data: dict[str, str] = {} + for line in output.strip().split("\n"): + if "=" in line: + key, value = line.split("=", 1) + data[key.strip()] = value.strip() + + frame = int(data.get("frame", 0)) + fps = float(data.get("fps", 0.0)) + out_time_ms = int(data.get("out_time_ms", 0)) + bitrate = data.get("bitrate", "0kbits/s") + + # Convert out_time_ms to HH:MM:SS.mm format + total_seconds = out_time_ms / 1_000_000 + hours = int(total_seconds // 3600) + minutes = int((total_seconds % 3600) // 60) + seconds = total_seconds % 60 + time_str = f"{hours:02d}:{minutes:02d}:{seconds:05.2f}" + + # Calculate percent if duration is known + percent = None + if duration_seconds and duration_seconds > 0: + percent = (total_seconds / duration_seconds) * 100 + percent = min(percent, 100.0) # Cap at 100% + + return Progress( + frame=frame, + fps=fps, + time=time_str, + bitrate=bitrate, + percent=percent, + ) + + +def extract_output_path(args: list[str]) -> str | None: + """Extract output file path from FFmpeg arguments (last non-option argument).""" + # Work backwards to find the last argument that isn't an option or option value + i = len(args) - 1 + while i >= 0: + arg = args[i] + # Skip if it's an option + if arg.startswith("-"): + i -= 1 + continue + # Check if previous arg is an option that takes a value + if i > 0 and args[i - 1] in OPTIONS_WITH_VALUES: + i -= 1 + continue + # Check if it looks like a file path (has extension or contains /) + if "." in arg or "/" in arg: + return arg + i -= 1 + return None diff --git a/tests/test_ffmpeg.py b/tests/test_ffmpeg.py index d9ec14c..73c81a0 100644 --- a/tests/test_ffmpeg.py +++ b/tests/test_ffmpeg.py @@ -1,6 +1,6 @@ import pytest -from app.ffmpeg import parse_command, resolve_paths +from app.ffmpeg import parse_command, resolve_paths, parse_progress, extract_output_path def test_parse_simple_command(): @@ -57,3 +57,45 @@ def test_resolve_paths_skips_options(): # Options and their values should not be resolved as paths assert resolved == ["-c:v", "libx264", "-preset", "fast"] + + +def test_parse_progress(): + output = """frame=1234 +fps=30.24 +total_size=5678900 +out_time_ms=83450000 +bitrate=1250.5kbits/s +progress=continue +""" + progress = parse_progress(output, duration_seconds=120.0) + + assert progress.frame == 1234 + assert progress.fps == 30.24 + assert progress.time == "00:01:23.45" + assert progress.bitrate == "1250.5kbits/s" + assert progress.percent == pytest.approx(69.54, rel=0.01) + + +def test_parse_progress_no_duration(): + output = "frame=100\nfps=25.0\nout_time_ms=4000000\nbitrate=500kbits/s\n" + + progress = parse_progress(output, duration_seconds=None) + + assert progress.frame == 100 + assert progress.percent is None + + +def test_extract_output_path(): + args = ["-i", "input.mp4", "-c:v", "libx264", "output.mp4"] + + output_path = extract_output_path(args) + + assert output_path == "output.mp4" + + +def test_extract_output_path_complex(): + args = ["-i", "a.mp4", "-i", "b.mp4", "-filter_complex", "[0:v][1:v]concat", "out.mp4"] + + output_path = extract_output_path(args) + + assert output_path == "out.mp4"