Skip to content

Commit

Permalink
use custom ffprobe parser:
Browse files Browse the repository at this point in the history
- reason:  to use ffmpeg json output, for easier parsing and better type support!
  • Loading branch information
Totto16 committed Jul 29, 2023
1 parent e94b2c6 commit 99e4a84
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 37 deletions.
1 change: 0 additions & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ name = "pypi"

[packages]
python-ffmpeg = "*"
ffprobe-python = "*"
torchaudio = "*"
speechbrain = "*"
torchvision = "*"
Expand Down
2 changes: 2 additions & 0 deletions ffmpeg/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.mp4
*.mkv
97 changes: 61 additions & 36 deletions src/classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import psutil
from enlighten import Manager
from ffprobe import FFProbe
from helper.ffprobe import ffprobe
from humanize import naturalsize
from pynvml import nvmlDeviceGetHandleByIndex, nvmlDeviceGetMemoryInfo, nvmlInit
from speechbrain.pretrained import EncoderClassifier
Expand All @@ -28,6 +28,8 @@

WAV_FILE_BAR_FMT = "{desc}{desc_pad}{percentage:3.0f}%|{bar}| {count:2n}/{total:2n} [{elapsed}<{eta}, {rate:.2f}{unit_pad}{unit}/s]"

TEMP_MAX_LENGTH = 1


def parse_int_safely(inp: str) -> Optional[int]:
try:
Expand Down Expand Up @@ -287,38 +289,53 @@ def __init__(self: Self, file: Path) -> None:
self.__runtime = runtime

def __get_info(self: Self) -> Optional[tuple[FileType, Status, Timestamp]]:
try:
metadata = FFProbe(str(self.__file.absolute()))
for stream in metadata.streams:
if stream.is_video():
return (
FileType.video,
Status.raw,
Timestamp.from_seconds(stream.duration_seconds()),
)

for stream in metadata.streams:
if stream.is_audio() and stream.codec() == "pcm_s16le":
return (
FileType.wav,
Status.ready,
Timestamp.from_seconds(stream.duration_seconds()),
)

for stream in metadata.streams:
if stream.is_audio():
return (
FileType.audio,
Status.raw,
Timestamp.from_seconds(stream.duration_seconds()),
)
except Exception as e: # noqa: BLE001
print(e, file=sys.stderr)
metadata, err = ffprobe(self.__file.absolute())
if err is not None or metadata is None:
with open("error.log", "a") as f:
print(f'"{self.__file}",', file=f)

print(
f"Unable to get a valid stream from file '{self.__file}'",
file=sys.stderr,
)

return None

if metadata.is_video():
video_streams = metadata.video_streams()
# multiple video makes no sense
if len(video_streams) > 1:
raise RuntimeError("Multiple Video Streams are not supported")

duration = video_streams[0].duration_seconds()
if duration is None:
return None

return (
FileType.video,
Status.raw,
Timestamp.from_seconds(duration),
)

if metadata.is_audio():
audio_streams = metadata.audio_streams()
# TODO: multiple audio streams can happen and shouldn't be a problem ! also every episode sghould hav an array of streams, with language etc!
if len(audio_streams) > 1:
return None

duration = audio_streams[0].duration_seconds()
if duration is None:
return None

if audio_streams[0].codec() == "pcm_s16le":
return (FileType.wav, Status.ready, Timestamp.from_seconds(duration))

return (
FileType.audio,
Status.raw,
Timestamp.from_seconds(duration),
)

print(
f"Unable to get a valid stream from file '{self.__file}'",
file=sys.stderr,
)
return None

@property
Expand Down Expand Up @@ -789,15 +806,21 @@ def get_segments(runtime: Timestamp) -> list[Segment]:
if bar is not None:
bar.update()

amount_scanned: float = 0.0
amount_scanned: float = 0.0 # TODO: calculate that

if TEMP_MAX_LENGTH < LANGUAGE_MINIMUM_AMOUNT:
break

if (
amount_scanned < LANGUAGE_MINIMUM_SCANNED
and LANGUAGE_MINIMUM_AMOUNT > i
# amount_scanned < LANGUAGE_MINIMUM_SCANNED
# and
LANGUAGE_MINIMUM_AMOUNT
> i
):
continue

# TODO temporary to scan fast scannable first!
if i > 10:
if i + 1 > TEMP_MAX_LENGTH:
break

best: PredictionBest = prediction.get_best(MeanType.truncated)
Expand All @@ -816,6 +839,8 @@ def get_segments(runtime: Timestamp) -> list[Segment]:

return (best, amount_scanned)

# END OF FOR LOOP

if bar is not None:
bar.close(clear=True)

Expand Down
Empty file added src/helper/__init__.py
Empty file.
130 changes: 130 additions & 0 deletions src/helper/ffprobe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import json
import pipes
import subprocess
from pathlib import Path
from typing import Any, Optional, Self, TypedDict


class FFprobeRawStream(TypedDict):
pass


def parse_float_safely(inp: str) -> Optional[float]:
try:
return float(inp)
except ValueError:
return None


# some things here were copied and modified from the original ffprobe-python repo:
# https://github.com/gbstack/ffprobe-python/blob/master/ffprobe/ffprobe.py
class FFprobeStream:
__stream: FFprobeRawStream

def __init__(self: Self, stream: FFprobeRawStream) -> None:
self.__stream = stream

def is_audio(self: Self) -> bool:
"""
Is this stream labelled as an audio stream?
"""
return self.__stream.get("codec_type", None) == "audio"

def is_video(self: Self) -> bool:
"""
Is the stream labelled as a video stream.
"""
return self.__stream.get("codec_type", None) == "video"

def is_subtitle(self: Self) -> bool:
"""
Is the stream labelled as a subtitle stream.
"""
return self.__stream.get("codec_type", None) == "subtitle"

def is_attachment(self: Self) -> bool:
"""
Is the stream labelled as a attachment stream.
"""
return self.__stream.get("codec_type", None) == "attachment"

def codec(self: Self) -> Optional[str]:
"""
Returns a string representation of the stream codec.
"""
val: Optional[Any] = self.__stream.get("codec_name", None)
return val if isinstance(val, str) else None

def duration_seconds(self: Self) -> Optional[float]:
"""
Returns the runtime duration of the video stream as a floating point number of seconds.
Returns None not a video or audio stream.
"""
if self.is_video() or self.is_audio():
val: Optional[Any] = self.__stream.get("duration", None)
return parse_float_safely(val) if isinstance(val, str) else None

return None


class FFProbeRawResult(TypedDict):
streams: list[FFprobeRawStream]


class FFProbeResult:
__raw: FFProbeRawResult

def __init__(self: Self, raw: FFProbeRawResult) -> None:
self.__raw = raw

@property
def streams(self: Self) -> list[FFprobeStream]:
return [FFprobeStream(stream) for stream in self.__raw["streams"]]

def video_streams(self: Self) -> list[FFprobeStream]:
"""
Get all video streams
"""
return [stream for stream in self.streams if stream.is_video()]

def is_video(self: Self) -> bool:
"""
Is the file a video alias has it at least one video stream
"""
return len(self.video_streams()) != 0

return False

def audio_streams(self: Self) -> list[FFprobeStream]:
"""
Get all audio streams
"""
return [stream for stream in self.streams if stream.is_audio()]

def is_audio(self: Self) -> bool:
"""
Is the file a audio alias has it at least one audio stream
"""
return len(self.audio_streams()) != 0


def ffprobe(file_path: Path) -> tuple[Optional[FFProbeResult], Optional[str]]:
commands = [
"ffprobe",
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
"-show_streams",
pipes.quote(str(file_path.absolute())),
]

if not file_path.exists():
return None, "File doesn't exist"

result = subprocess.run(commands, capture_output=True) # noqa: S603
if result.returncode == 0:
return FFProbeResult(json.loads(result.stdout)), None

return None, f"FFProbe failed for {file_path}, output: {result.stderr!s}"

0 comments on commit 99e4a84

Please sign in to comment.