feat: add FFmpeg progress parser and output path extraction
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import shlex
|
import shlex
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.models import Progress
|
||||||
|
|
||||||
# FFmpeg options that take a value (not exhaustive, covers common ones)
|
# FFmpeg options that take a value (not exhaustive, covers common ones)
|
||||||
OPTIONS_WITH_VALUES = {
|
OPTIONS_WITH_VALUES = {
|
||||||
"-c", "-c:v", "-c:a", "-b", "-b:v", "-b:a", "-r", "-s", "-ar", "-ac",
|
"-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)
|
resolved.append(arg)
|
||||||
|
|
||||||
return resolved
|
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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import pytest
|
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():
|
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
|
# Options and their values should not be resolved as paths
|
||||||
assert resolved == ["-c:v", "libx264", "-preset", "fast"]
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user