Repository: Zulko/moviepy (python)

Issue:
KeyError: 'video_fps'

### Expected Behavior

No error should occur  as per docs

### Actual Behavior

> Traceback (most recent call last):
>   File "intro_prepare.py", line 10, in <module>
>     
>   File "/Users/ishandutta2007/.pyenv/versions/3.5.0/lib/python3.5/site-packages/moviepy/video/io/VideoFileClip.py", line 91, in __init__
>     fps_source=fps_source)
>   File "/Users/ishandutta2007/.pyenv/versions/3.5.0/lib/python3.5/site-packages/moviepy/video/io/ffmpeg_reader.py", line 34, in __init__
>     self.fps = infos['video_fps']
> KeyError: 'video_fps'
> 
> 

### Steps to Reproduce the Problem
<!-- Please include code that demonstrates this problem so that we can reproduce it. For advice on how to do this, see https://stackoverflow.com/help/mcve -->

```
from moviepy.editor import *
from moviepy.video.io.ffmpeg_tools import ffmpeg_extract_subclip

from PIL import Image

ffmpeg_extract_subclip("./library/mySound_Effect.mp4", 6, 8.7, targetname="test.mp4")
intro_sound_vidio_clip = VideoFileClip('test.mp4')

```
### Specifications

  - Python Version: 3.5.0
  - Moviepy Version: 0.2.3.5
  - Platform Name: OSX 10.13
  - Platform Version: 10.13(High Sierra)


Pull request description:
Fix ffmpeg_reader not selecting a default stream
fix #869 #899
ffmpeg, at least the version that's installed automatically with moviepy, sometimes comes up with no default stream for a given input file. This PR does nothing more than set a default stream automatically when none is specified, which is the cause of the missing "video_fps" issue.

Unfortunately I fixed this bug a long time ago in a working branch, and can't find the file that raised this problem in the first place. I think it was a ".wmv" or ".asf".

Patch:
diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py
index 31645ef..b43f97f 100644
--- a/moviepy/video/io/ffmpeg_reader.py
+++ b/moviepy/video/io/ffmpeg_reader.py
@@ -384,8 +384,9 @@ class FFmpegInfosParser:
         # should be ignored
         self._inside_output = False
 
-        # flag which indicates that a default stream has not been found yet
-        self._default_stream_found = False
+        # map from stream type to default stream
+        # if a default stream is not indicated, pick the first one available
+        self._default_streams = {}
 
         # current input file, stream and chapter, which will be built at runtime
         self._current_input_file = {"streams": []}
@@ -484,20 +485,14 @@ class FFmpegInfosParser:
                     "stream_number": stream_number,
                     "stream_type": stream_type_lower,
                     "language": language,
-                    "default": not self._default_stream_found
-                    or line.endswith("(default)"),
+                    "default": (stream_type_lower not in self._default_streams)
+                    and line.endswith("(default)"),
                 }
-                self._default_stream_found = True
 
                 # for default streams, set their numbers globally, so it's
                 # easy to get without iterating all
                 if self._current_stream["default"]:
-                    self.result[
-                        f"default_{stream_type_lower}_input_number"
-                    ] = input_number
-                    self.result[
-                        f"default_{stream_type_lower}_stream_number"
-                    ] = stream_number
+                    self._default_streams[stream_type_lower] = self._current_stream
 
                 # exit chapter
                 if self._current_chapter:
@@ -516,21 +511,18 @@ class FFmpegInfosParser:
                             input_number
                         ]
 
-                    # add new input file to self.result
+                    # add new input file to result
                     self.result["inputs"].append(self._current_input_file)
                     self._current_input_file = {"input_number": input_number}
 
                 # parse relevant data by stream type
                 try:
-                    global_data, stream_data = self.parse_data_by_stream_type(
-                        stream_type, line
-                    )
+                    stream_data = self.parse_data_by_stream_type(stream_type, line)
                 except NotImplementedError as exc:
                     warnings.warn(
                         f"{str(exc)}\nffmpeg output:\n\n{self.infos}", UserWarning
                     )
                 else:
-                    self.result.update(global_data)
                     self._current_stream.update(stream_data)
             elif line.startswith("    Metadata:"):
                 # enter group "    Metadata:"
@@ -596,7 +588,7 @@ class FFmpegInfosParser:
                     self._last_metadata_field_added = field
                 self._current_chapter["metadata"][field] = value
 
-        # last input file, must be included in self.result
+        # last input file, must be included in the result
         if self._current_input_file:
             self._current_input_file["streams"].append(self._current_stream)
             # include their chapters, if there are any
@@ -609,6 +601,33 @@ class FFmpegInfosParser:
                 ]
             self.result["inputs"].append(self._current_input_file)
 
+        # set any missing default automatically
+        for stream in self._current_input_file["streams"]:
+            if stream["stream_type"] not in self._default_streams:
+                self._default_streams[stream["stream_type"]] = stream
+                stream["default"] = True
+
+        # set some global info based on the defaults
+        for stream_type_lower, stream_data in self._default_streams.items():
+            self.result[f"default_{stream_type_lower}_input_number"] = stream_data[
+                "input_number"
+            ]
+            self.result[f"default_{stream_type_lower}_stream_number"] = stream_data[
+                "stream_number"
+            ]
+
+            if stream_type_lower == "audio":
+                self.result["audio_found"] = True
+                self.result["audio_fps"] = stream_data["fps"]
+                self.result["audio_bitrate"] = stream_data["bitrate"]
+            elif stream_type_lower == "video":
+                self.result["video_found"] = True
+                self.result["video_size"] = stream_data.get("size", None)
+                self.result["video_bitrate"] = stream_data.get("bitrate", None)
+                self.result["video_fps"] = stream_data["fps"]
+                self.result["video_codec_name"] = stream_data.get("codec_name", None)
+                self.result["video_profile"] = stream_data.get("profile", None)
+
         # some video duration utilities
         if self.result["video_found"] and self.check_duration:
             self.result["video_duration"] = self.result["duration"]
@@ -646,7 +665,7 @@ class FFmpegInfosParser:
             return {
                 "Audio": self.parse_audio_stream_data,
                 "Video": self.parse_video_stream_data,
-                "Data": lambda _line: ({}, {}),
+                "Data": lambda _line: {},
             }[stream_type](line)
         except KeyError:
             raise NotImplementedError(
@@ -656,7 +675,7 @@ class FFmpegInfosParser:
 
     def parse_audio_stream_data(self, line):
         """Parses data from "Stream ... Audio" line."""
-        global_data, stream_data = ({"audio_found": True}, {})
+        stream_data = {}
         try:
             stream_data["fps"] = int(re.search(r" (\d+) Hz", line).group(1))
         except (AttributeError, ValueError):
@@ -667,14 +686,11 @@ class FFmpegInfosParser:
         stream_data["bitrate"] = (
             int(match_audio_bitrate.group(1)) if match_audio_bitrate else None
         )
-        if self._current_stream["default"]:
-            global_data["audio_fps"] = stream_data["fps"]
-            global_data["audio_bitrate"] = stream_data["bitrate"]
-        return (global_data, stream_data)
+        return stream_data
 
     def parse_video_stream_data(self, line):
         """Parses data from "Stream ... Video" line."""
-        global_data, stream_data = ({"video_found": True}, {})
+        stream_data = {}
 
         try:
             match_video_size = re.search(r" (\d+)x(\d+)[,\s]", line)
@@ -736,20 +752,7 @@ class FFmpegInfosParser:
             stream_data["codec_name"] = codec_name
             stream_data["profile"] = profile
 
-        if self._current_stream["default"] or "video_codec_name" not in self.result:
-            global_data["video_codec_name"] = stream_data.get("codec_name", None)
-
-        if self._current_stream["default"] or "video_profile" not in self.result:
-            global_data["video_profile"] = stream_data.get("profile", None)
-
-        if self._current_stream["default"] or "video_size" not in self.result:
-            global_data["video_size"] = stream_data.get("size", None)
-        if self._current_stream["default"] or "video_bitrate" not in self.result:
-            global_data["video_bitrate"] = stream_data.get("bitrate", None)
-        if self._current_stream["default"] or "video_fps" not in self.result:
-            global_data["video_fps"] = stream_data["fps"]
-
-        return (global_data, stream_data)
+        return stream_data
 
     def parse_fps(self, line):
         """Parses number of FPS from a line of the ``ffmpeg -i`` command output."""


Test patch:
diff --git a/tests/test_ffmpeg_reader.py b/tests/test_ffmpeg_reader.py
index 5cf89bc..df7468d 100644
--- a/tests/test_ffmpeg_reader.py
+++ b/tests/test_ffmpeg_reader.py
@@ -10,6 +10,7 @@ import pytest
 
 from moviepy.audio.AudioClip import AudioClip
 from moviepy.config import FFMPEG_BINARY
+from moviepy.tools import ffmpeg_escape_filename
 from moviepy.video.compositing.CompositeVideoClip import clips_array
 from moviepy.video.io.ffmpeg_reader import (
     FFMPEG_VideoReader,
@@ -56,6 +57,38 @@ def test_ffmpeg_parse_infos_video_nframes():
     assert d["video_n_frames"] == 5
 
 
+def test_ffmpeg_parse_infos_no_default_stream(util):
+    """WMV files don't have "default" streams marked in ffmpeg output.
+    Make sure that ffmpeg_parse_infos can handle this case.
+    """
+    mp4_filepath = os.path.abspath("media/smpte-2997.mp4")
+    wmv_filepath = os.path.join(
+        util.TMP_DIR, "ffmpeg_parse_infos_no_default_stream-smpte-2997.wmv"
+    )
+
+    cmd = [
+        FFMPEG_BINARY,
+        "-y",
+        "-i",
+        ffmpeg_escape_filename(mp4_filepath),
+        ffmpeg_escape_filename(wmv_filepath),
+    ]
+    with open(os.devnull, "w") as stderr:
+        subprocess.check_call(cmd, stderr=stderr)
+
+    d = ffmpeg_parse_infos(wmv_filepath)
+
+    for key in (
+        "default_video_stream_number",
+        "default_video_input_number",
+        "default_audio_stream_number",
+        "default_audio_input_number",
+        "video_fps",
+        "audio_fps",
+    ):
+        assert key in d
+
+
 @pytest.mark.parametrize(
     ("decode_file", "expected_duration"),
     (