Repository: just-work/fffw

Issue:
Inject stream metadata to objects
Input files, streams, filters and codecs should use stream metadata (like width/height, duration, start timestamp etc).

- [x] Refactor classes in `sources` module: Input is a common base class for FileSource, LavfiSource or capture device. Input contains multiple Streams, which could have Meta - stream metadata. 
- [x] When stream is connected to a filter, Meta is passed from source node to output node unchanged or transformed in some ways (i.e. scale filter changes width and height).
- [ ] ~~When graph output is connected to a codec, there should be some validation.~~

Discussion:

Patch:
diff --git a/fffw/encoding/codec.py b/fffw/encoding/codec.py
index aef4053..13620b0 100644
--- a/fffw/encoding/codec.py
+++ b/fffw/encoding/codec.py
@@ -55,8 +55,7 @@ class BaseCodec(BaseWrapper, base.Node, metaclass=abc.ABCMeta):
                      if self._args[k])
         )
 
-    def connect_edge(self, edge: base.Edge
-                     ) -> base.Edge:
+    def connect_edge(self, edge: base.Edge) -> base.Edge:
         """ Connects source to codec through an edge."""
         src = edge.input
         assert isinstance(src, base.Source), "Codec connects to Source"
@@ -71,6 +70,13 @@ class BaseCodec(BaseWrapper, base.Node, metaclass=abc.ABCMeta):
         self.map = src.name
         return edge
 
+    def __ror__(self, other: base.Dest) -> None:
+        """ Connects destination to codec. """
+        # TODO: unite Dest and codec into single object.
+        if not isinstance(other, base.Dest):
+            return NotImplemented
+        self.connect(other)
+
 
 class VideoCodec(BaseCodec):
     codec_type = base.VIDEO
diff --git a/fffw/encoding/ffmpeg.py b/fffw/encoding/ffmpeg.py
index 0d1a561..c5ce914 100644
--- a/fffw/encoding/ffmpeg.py
+++ b/fffw/encoding/ffmpeg.py
@@ -1,9 +1,8 @@
 from itertools import chain
-from typing import List, Tuple, Any, Union, Iterable
+from typing import List, Tuple, Any, Union
 
-from fffw.encoding import Muxer
-from fffw.encoding import codec
-from fffw.graph import FilterComplex, base, sources
+from fffw.encoding import Muxer, codec
+from fffw.graph import FilterComplex, base, inputs
 from fffw.wrapper import BaseWrapper, ensure_binary
 
 __all__ = ['FFMPEG']
@@ -33,7 +32,6 @@ class FFMPEG(BaseWrapper):
         ('time_offset', '-ss '),
         ('no_autorotate', '-noautorotate'),
         ('inputformat', '-f '),
-        ('inputfile', '-i '),
         ('pix_fmt', '-pix_fmt '),
         ('presize_offset', '-ss '),
         ('filter_complex', '-filter_complex '),
@@ -54,34 +52,26 @@ class FFMPEG(BaseWrapper):
         ('segment_list_flags', '-segment_list_flags '),
     ]
 
-    def __init__(self,
-                 inputfile: Union[
-                     None, sources.BaseSource,
-                     Iterable[sources.BaseSource]] = None,
-                 **kw: Any):
+    def __init__(self, *sources: Union[inputs.Input, str], **kw: Any):
+        """
+        :param sources: list of input files (or another ffmpeg sources)
+        :param kw: ffmpeg command line arguments
+        """
         super(FFMPEG, self).__init__(**kw)
-        self._args['inputfile'] = self.__inputs = InputList()
         self.__outputs: List[Tuple[Tuple[codec.BaseCodec, ...], Muxer]] = []
         self.__vdest = self.__adest = 0
-
-        self.__video = sources.Input(kind=base.VIDEO)
-        self.__audio = sources.Input(kind=base.AUDIO)
-
-        if isinstance(inputfile, sources.BaseSource):
-            self.add_input(inputfile)
-        elif isinstance(inputfile, (list, tuple)):
-            for i in inputfile:
-                self.add_input(i)
-        else:
-            assert inputfile is None, "invalid inputfile type"
+        self.__input_list = inputs.InputList(
+            *(
+                inputs.Input(input_file=src)
+                if isinstance(src, str) else src
+                for src in sources
+            ))
 
     def init_filter_complex(self) -> FilterComplex:
-        assert self.__inputs, "no inputs defined yet"
+        # TODO #9 refactor filter complex initialization
         assert not self.__outputs, "outputs already defined"
-        self._args['filter_complex'] = fc = FilterComplex(
-            video=self.__video,
-            audio=self.__audio
-        )
+        fc = FilterComplex(self.__input_list)
+        self._args['filter_complex'] = fc
         return fc
 
     @property
@@ -90,6 +80,7 @@ class FFMPEG(BaseWrapper):
 
     def get_args(self) -> List[bytes]:
         return (ensure_binary([self.command]) +
+                ensure_binary(self.__input_list.get_args()) +
                 super(FFMPEG, self).get_args() +
                 ensure_binary(self.get_output_args()))
 
@@ -101,64 +92,73 @@ class FFMPEG(BaseWrapper):
             result.extend(args + muxer.get_args() + output)
         return result
 
-    def add_input(self, inputfile: sources.BaseSource) -> None:
+    def add_input(self, input_file: inputs.Input) -> None:
         """ Adds new source to ffmpeg.
         """
-        assert not self.filter_complex, "filter complex already initialized"
-        assert isinstance(inputfile, sources.BaseSource)
-        self.__inputs.append(inputfile)
-
-        for _ in range(inputfile.video_streams):
-            i = len(self.__video.streams)
-            self.__video < base.Source('%s:v' % i, base.VIDEO)
-        if not inputfile.video_streams:
-            self.__video < base.Source(None, base.VIDEO)
-
-        for _ in range(inputfile.audio_streams):
-            i = len(self.__audio.streams)
-            self.__audio < base.Source('%s:a' % i, base.AUDIO)
-        if not inputfile.audio_streams:
-            self.__audio < base.Source(None, base.AUDIO)
+        assert isinstance(input_file, inputs.Input)
+        self.__input_list.append(input_file)
 
-    def add_output(self, muxer: Muxer, *codecs: codec.BaseCodec) -> None:
-        assert isinstance(muxer, Muxer)
-        for c in codecs:
-            assert isinstance(c, codec.BaseCodec)
-            fc = self.filter_complex
-            if not fc or getattr(c, 'map', None):
-                # If filter_complex is not present or codec has source set,
-                # connect codec to inputs directly.
-                if c.codec_type == base.VIDEO:
-                    self.__video | c
-                if c.codec_type == base.AUDIO:
-                    self.__audio | c
+    def get_free_source(self, kind: base.StreamType) -> base.Source:
+        """
+        :param kind: stream type
+        :return: first stream of this kind not connected to destination
+        """
+        for stream in self.__input_list.streams:
+            if stream.kind != kind or stream.edge is not None:
                 continue
-            if c.codec_type == base.VIDEO:
-                try:
-                    c.connect(
-                        fc.get_video_dest(self.__vdest, create=False))
+            return stream
+        else:
+            raise RuntimeError("no free streams")
+
+    def add_codec(self, c: codec.BaseCodec) -> None:
+        """ Connect codec to filter graph output or input stream."""
+        # TODO: #14 refactor connecting codec while merging Dest and BaseCodec
+        node: Union[base.Source, base.Dest]
+        if c.map:
+            try:
+                node = next(filter(lambda s: s.name == c.map,
+                                   self.__input_list.streams))
+            except StopIteration:
+                raise RuntimeError("No stream for map")
+        else:
+            try:
+                node = self.get_free_source(c.codec_type)
+            except RuntimeError:
+                # no free sources, search for free dest in fc
+                fc = self.filter_complex
+                if fc is None:
+                    raise
+                if c.codec_type == base.VIDEO:
+                    node = fc.get_video_dest(self.__vdest, create=False)
                     self.__vdest += 1
-                except IndexError:
-                    self.__video | c
-            if c.codec_type == base.AUDIO:
-                try:
-                    c.connect(fc.get_audio_dest(self.__adest, create=False))
+                else:
+                    node = fc.get_audio_dest(self.__adest, create=False)
                     self.__adest += 1
-                except IndexError:
-                    self.__audio | c
+        if c.codec_type != node.kind:
+            raise RuntimeError("stream and codec type mismatch")
+        node | c
 
+    def add_output(self, muxer: Muxer, *codecs: codec.BaseCodec) -> None:
+        # TODO: #14 muxer should contain codecs and codecs should connect
+        #  directly to input stream or graph filter.
+        assert isinstance(muxer, Muxer)
+        for c in codecs:
+            self.add_codec(c)
         self.__outputs.append((codecs, muxer))
 
     @property
     def outputs(self) -> List[Tuple[Tuple[codec.BaseCodec, ...], Muxer]]:
         return list(self.__outputs)
 
-    def __lt__(self, other: sources.BaseSource) -> None:
+    def __lt__(self, other: inputs.Input) -> None:
         """ Adds new source file.
         """
+        if not isinstance(other, inputs.Input):
+            return NotImplemented
         self.add_input(other)
 
     def __setattr__(self, key: str, value: Any) -> None:
+        # TODO: #9 refactor working with args
         if key == 'filter_complex':
             raise NotImplementedError("use init_filter_complex instead")
         if key == 'inputfile':
diff --git a/fffw/graph/__init__.py b/fffw/graph/__init__.py
index ab43b65..34b6153 100644
--- a/fffw/graph/__init__.py
+++ b/fffw/graph/__init__.py
@@ -1,11 +1,11 @@
 from .complex import *
 from .filters import *
 from .base import *
-from .sources import *
+from .inputs import *
 
 __all__ = (
         complex.__all__ +  # type: ignore
         filters.__all__ +  # type: ignore
         base.__all__ +  # type: ignore
-        sources.__all__  # type: ignore
+        inputs.__all__  # type: ignore
 )
diff --git a/fffw/graph/base.py b/fffw/graph/base.py
index 95ab4b9..0244603 100644
--- a/fffw/graph/base.py
+++ b/fffw/graph/base.py
@@ -7,7 +7,10 @@ __all__ = [
 import abc
 from collections import Counter
 from enum import Enum
-from typing import Optional, List, Union, Dict, Any
+from typing import Dict, Any
+from typing import Optional, List, Union
+
+from fffw.graph.meta import Meta
 
 
 class StreamType(Enum):
@@ -54,7 +57,11 @@ class Namer:
         return self._cache[id(edge)]
 
 
-class Renderable(metaclass=abc.ABCMeta):
+InputType = Union["Source", "Node"]
+OutputType = Union["Dest", "Node"]
+
+
+class Traversable(metaclass=abc.ABCMeta):
     """
     Abstract class base for filter graph edges/nodes traversing and rendering.
     """
@@ -71,8 +78,16 @@ class Renderable(metaclass=abc.ABCMeta):
         """
         raise NotImplementedError()
 
+    @abc.abstractmethod
+    def get_meta_data(self, dst: OutputType) -> Optional[Meta]:
+        """
+        :param dst: destination node
+        :return: metadata passed to destination node after transformation
+        """
+        raise NotImplementedError()
+
 
-class Dest(Renderable):
+class Dest(Traversable):
     """
     Audio/video output stream node.
 
@@ -99,6 +114,15 @@ class Dest(Renderable):
     def kind(self) -> StreamType:
         return self._kind
 
+    @property
+    def meta(self) -> Optional[Meta]:
+        return self.get_meta_data(self)
+
+    def get_meta_data(self, dst: OutputType) -> Optional[Meta]:
+        if self._edge is None:
+            raise RuntimeError("Dest not connected")
+        return self._edge.get_meta_data(self)
+
     def connect_edge(self, edge: "Edge") -> "Edge":
         """ Connects and edge to output stream.
 
@@ -120,11 +144,7 @@ class Dest(Renderable):
         return []
 
 
-InputType = Union["Source", "Node"]
-OutputType = Union["Dest", "Node"]
-
-
-class Edge(Renderable):
+class Edge(Traversable):
     """ Internal ffmpeg data stream graph."""
 
     # noinspection PyShadowingBuiltins
@@ -197,6 +217,9 @@ class Edge(Renderable):
         self.__output = dest
         return dest
 
+    def get_meta_data(self, dst: OutputType) -> Optional[Meta]:
+        return self.__input.get_meta_data(dst)
+
     def render(self, partial: bool = False) -> List[str]:
         if not self.__output:
             if partial:
@@ -205,7 +228,7 @@ class Edge(Renderable):
         return self.__output.render(partial=partial)
 
 
-class Node(Renderable):
+class Node(Traversable):
     """ Graph node describing ffmpeg filter."""
 
     kind: StreamType  # filter type (VIDEO/AUDIO)
@@ -214,10 +237,7 @@ class Node(Renderable):
     output_count: int = 1  # number of outputs
 
     def __init__(self, enabled: bool = True):
-        if not enabled:
-            assert self.input_count == 1
-            assert self.output_count == 1
-        self.__enabled = enabled
+        self.enabled = enabled
         self.inputs: List[Optional[Edge]] = [None] * self.input_count
         self.outputs: List[Optional[Edge]] = [None] * self.output_count
 
@@ -241,6 +261,9 @@ class Node(Renderable):
 
     @enabled.setter
     def enabled(self, value: bool) -> None:
+        if not value:
+            assert self.input_count == 1
+            assert self.output_count == 1
         self.__enabled = value
 
     @property
@@ -250,6 +273,31 @@ class Node(Renderable):
         """
         return ''
 
+    # noinspection PyMethodMayBeStatic
+    def transform(self, *metadata: Meta) -> Meta:
+        """ Apply filter changes to stream metadata."""
+        return metadata[0]
+
+    def get_meta_data(self, dst: OutputType) -> Optional[Meta]:
+        metadata = []
+        for edge in self.inputs:
+            if edge is None:
+                raise RuntimeError("Input not connected")
+            meta = edge.get_meta_data(self)
+            if meta is None:
+                return None
+            metadata.append(meta)
+
+        meta = self.transform(*metadata)
+
+        for edge in self.outputs:
+            if edge is None:
+                continue
+            if edge.output is dst:
+                return meta
+        else:
+            raise KeyError(dst)
+
     def render(self, partial: bool = False) -> List[str]:
         if not self.enabled:
             # filter skipped, input is connected to output directly
@@ -335,20 +383,23 @@ class Node(Renderable):
         return other
 
 
-class Source(Renderable):
+class Source(Traversable):
     """ Graph node containing audio or video input.
 
     Must connect to single graph edge only as a source
     """
 
-    def __init__(self, name: Optional[str], kind: StreamType) -> None:
+    def __init__(self, name: Optional[str], kind: StreamType,
+                 meta: Optional[Meta] = None) -> None:
         """
         :param name: ffmpeg internal input stream name ("v:0", "a:1")
         :param kind: stream type (VIDEO/AUDIO)
+        :param meta: stream metadata
         """
         self._edge: Optional[Edge] = None
         self._kind = kind
-        self.__name = name
+        self._name = name
+        self._meta = meta
 
     def __repr__(self) -> str:
         return f"Source('[{self.name}]')"
@@ -364,22 +415,31 @@ class Source(Renderable):
 
     @property
     def name(self) -> str:
-        if self.__name is None:
+        if self._name is None:
             raise RuntimeError("Source name not set")
-        return self.__name
+        return self._name
 
     @property
     def edge(self) -> Optional["Edge"]:
-        """ Returns an edge connected to current source.
-        :rtype: fffw.graph.base.Edge|NoneType
+        """
+        :returns: an edge connected to current source.
         """
         return self._edge
 
     @property
     def kind(self) -> StreamType:
-        """ Returns stream type."""
+        """
+        :returns: stream type
+        """
         return self._kind
 
+    @property
+    def meta(self) -> Optional[Meta]:
+        """
+        :returns: stream metadata
+        """
+        return self._meta
+
     def connect(self, other: Node) -> Node:
         """ Connects a source to a filter or output
 
@@ -395,11 +455,12 @@ class Source(Renderable):
         self._edge = self._edge or other.connect_edge(edge)
         return other
 
+    def get_meta_data(self, dst: OutputType) -> Optional[Meta]:
+        return self._meta
+
     def render(self, partial: bool = False) -> List[str]:
         if self._edge is None:
-            if partial:
-                return []
-            raise RuntimeError("Source is not ready for render")
+            return []
 
         node = self._edge.output
         # if output node is disabled, use next edge identifier.
diff --git a/fffw/graph/complex.py b/fffw/graph/complex.py
index d3b3fc2..67f75e6 100644
--- a/fffw/graph/complex.py
+++ b/fffw/graph/complex.py
@@ -1,7 +1,7 @@
 import collections
-from typing import Optional, Dict
+from typing import Dict
 
-from fffw.graph import base, sources
+from fffw.graph import base, inputs
 
 __all__ = [
     'FilterComplex'
@@ -11,17 +11,39 @@ __all__ = [
 class FilterComplex:
     """ ffmpeg filter graph wrapper."""
 
-    def __init__(self, video: Optional[sources.Input] = None,
-                 audio: Optional[sources.Input] = None):
+    def __init__(self, input_list: inputs.InputList):
         """
-        :param video: input video streams set
-        :param audio: input audio streams set
+        :param input_list: list of input files, containing video and audio
+        streams.
         """
-        self.video = video or sources.Input(kind=base.VIDEO)
-        self.audio = audio or sources.Input(kind=base.AUDIO)
+        self.__input_list = input_list
         self.__video_outputs: Dict[int, base.Dest] = {}
         self.__audio_outputs: Dict[int, base.Dest] = {}
 
+    @property
+    def video(self) -> base.Source:
+        """ Returns first free video stream."""
+        # TODO: #9 replace with __lt__ method
+        return self.get_free_source(base.VIDEO)
+
+    @property
+    def audio(self) -> base.Source:
+        """ Returns first free audio stream."""
+        # TODO: #9 replace with __lt__ method
+        return self.get_free_source(base.AUDIO)
+
+    def get_free_source(self, kind: base.StreamType) -> base.Source:
+        """
+        :param kind: stream type
+        :return: first stream of this kind not connected to filter graph
+        """
+        for stream in self.__input_list.streams:
+            if stream.kind != kind or stream.edge is not None:
+                continue
+            return stream
+        else:
+            raise RuntimeError("No free streams")
+
     def get_video_dest(self, index: int = 0, create: bool = True) -> base.Dest:
         """ Returns video output by index.
         :param index: video output index.
@@ -64,15 +86,7 @@ class FilterComplex:
             # same edges will receive same names and different edges will
             # receive unique names. This includes idempotent results for
             # subsequent render() calls for outer Namer context.
-            for src in self.video.streams:
-                # noinspection PyProtectedMember
-                if not src._edge:
-                    continue
-                result.extend(src.render(partial=partial))
-            for src in self.audio.streams:
-                # noinspection PyProtectedMember
-                if not src._edge:
-                    continue
+            for src in self.__input_list.streams:
                 result.extend(src.render(partial=partial))
 
         # There are no visit checks in recurse graph traversing, so remove
diff --git a/fffw/graph/inputs.py b/fffw/graph/inputs.py
new file mode 100644
index 0000000..29451a2
--- /dev/null
+++ b/fffw/graph/inputs.py
@@ -0,0 +1,145 @@
+from typing import Optional, List, Tuple, TypeVar, Type, cast, Any
+
+from fffw.graph import base
+from fffw.graph.meta import Meta
+from fffw.wrapper import BaseWrapper, ensure_binary
+
+__all__ = [
+    'Input',
+    'InputList',
+    'Stream',
+    'Input',
+]
+
+Obj = TypeVar('Obj')
+
+
+class Once:
+    """ Property that must be set exactly once."""
+
+    def __init__(self, attr_name: str) -> None:
+        """
+        :param attr_name: instance attribute name
+        """
+        self.attr_name = attr_name
+
+    def __get__(self, instance: Obj, owner: Type[Obj]) -> Any:
+        try:
+            return instance.__dict__[self.attr_name]
+        except KeyError:
+            raise RuntimeError(f"{self.attr_name} is not initialized")
+
+    def __set__(self, instance: Obj, value: Any) -> None:
+        if self.attr_name in instance.__dict__:
+            raise RuntimeError(f"{self.attr_name} already initialized")
+        instance.__dict__[self.attr_name] = value
+
+
+class Stream(base.Source):
+    """ Video or audio stream in input file."""
+    source = cast("Input", Once('source'))
+    """ Source file that contains current stream."""
+    index = cast(int, Once('index'))
+    """ Index of current stream in source file."""
+
+    def __init__(self, kind: base.StreamType, meta: Optional[Meta] = None):
+        """
+        :param kind: stream kind, video or audio
+        :param meta: stream metadata
+        """
+        super().__init__(name=None, kind=kind, meta=meta)
+
+    @property
+    def name(self) -> str:
+        if self.index == 0:
+            return f'{self.source.index}:{self._kind.value}'
+        return f'{self.source.index}:{self._kind.value}:{self.index}'
+
+
+class Input(BaseWrapper):
+    """
+    Input command line params generator for FFMPEG.
+    """
+    """ Filename or url, value for `-i` argument."""
+    streams = cast(Tuple[Stream, ...], Once("streams"))
+    """ List of audio and video streams for input file."""
+    index = cast(int, Once("index"))
+    """ Internal ffmpeg source file index."""
+
+    def __init__(self, *streams: Stream, input_file: str = ''):
+        super().__init__()
+        self.streams = streams or (Stream(base.VIDEO), Stream(base.AUDIO))
+        self.input_file = input_file
+        self.__link_streams_to_input()
+
+    def __link_streams_to_input(self) -> None:
+        """
+        Add a link to self to input streams and enumerate streams to get
+        proper stream index for input.
+        """
+        video_streams = 0
+        audio_streams = 0
+        if self.streams is None:
+            raise RuntimeError("Streams not initialized")
+        for stream in self.streams:
+            if stream.kind == base.VIDEO:
+                stream.index = video_streams
+                video_streams += 1
+            elif stream.kind == base.AUDIO:
+                stream.index = audio_streams
+                audio_streams += 1
+            else:
+                raise ValueError(stream.kind)
+            stream.source = self
+
+    def get_args(self) -> List[bytes]:
+        return ensure_binary(["-i", self.input_file])
+
+
+class InputList:
+    """ List of inputs in FFMPEG."""
+
+    def __init__(self, *sources: Input) -> None:
+        """
+        :param sources: list of input files
+        """
+        self.__inputs: List[Input] = []
+        self.extend(*sources)
+
+    @property
+    def inputs(self) -> Tuple[Input, ...]:
+        return tuple(self.__inputs)
+
+    @property
+    def streams(self) -> List[Stream]:
+        result: List[Stream] = []
+        for source in self.__inputs:
+            if source.streams is None:
+                raise RuntimeError("Source streams not initialized")
+            result.extend(source.streams)
+        return result
+
+    def append(self, source: Input) -> None:
+        """
+        Adds new source file to input list.
+
+        :param source: input file
+        """
+        source.index = len(self.__inputs)
+        self.__inputs.append(source)
+
+    def extend(self, *sources: Input) -> None:
+        """
+        Adds multiple source files to input list.
+
+        :param sources: list of input files
+        """
+        for i, source in enumerate(sources, start=len(self.__inputs)):
+            source.index = i
+        self.__inputs.extend(sources)
+
+    def get_args(self) -> List[bytes]:
+        result = []
+        for source in self.__inputs:
+            result.extend(source.get_args())
+        return result
diff --git a/fffw/graph/meta.py b/fffw/graph/meta.py
new file mode 100644
index 0000000..adba8f9
--- /dev/null
+++ b/fffw/graph/meta.py
@@ -0,0 +1,155 @@
+from dataclasses import dataclass
+from datetime import timedelta
+from typing import List, Union
+
+from pymediainfo import MediaInfo  # type: ignore
+
+
+class TS(timedelta):
+    """
+    Timestamp data type.
+
+    Accepts common timestamp formats like '123:45:56.1234'.
+    Integer values are parsed as milliseconds.
+    """
+
+    def __new__(cls, value: Union[int, float, str]) -> "TS":
+        if isinstance(value, int):
+            value = value / 1000.0
+        elif isinstance(value, str):
+            if '.' in value:
+                value, rest = value.split('.')
+                fractional = float(f'0.{rest}')
+            else:
+                fractional = 0
+            seconds = 0
+            for part in map(int, value.rsplit(':', 2)):
+                seconds *= 60
+                seconds += part
+            value = seconds + fractional
+        return super().__new__(cls, seconds=value)  # type: ignore
+
+
+@dataclass
+class Meta:
+    """
+    Stream metadata.
+
+    Describes common stream characteristics like bitrate and duration.
+    """
+    duration: TS
+    """ Stream duration."""
+    start: TS
+    """ First frame/sample timestamp for stream."""
+    bitrate: int
+    """ Stream bitrate in bits per second."""
+
+
+@dataclass
+class VideoMeta(Meta):
+    """
+    Video stream metadata.
+
+    Describes video stream characteristics.
+    """
+    width: int
+    height: int
+    par: float
+    """ Pixel aspect ratio."""
+    dar: float
+    """ Display aspect ratio."""
+    frame_rate: float
+    """ Frames per second."""
+    frames: int
+
+    def __post_init__(self) -> None:
+        self.validate()
+
+    def validate(self) -> None:
+        if self.height != 0:
+            assert abs(self.dar - self.width / self.height * self.par) <= 0.001
+        else:
+            assert str(self.dar) == 'nan'
+        duration = self.duration.total_seconds()
+        if duration != 0:
+            assert abs(self.frame_rate - self.frames / duration) < 0.001
+        else:
+            assert self.frame_rate == 0
+
+
+@dataclass
+class AudioMeta(Meta):
+    """
+    Audio stream metadata.
+
+    Describes audio stream characteristics.
+    """
+    sampling_rate: int
+    """ Samples per second."""
+    channels: int
+    """ Number of audio channels."""
+    samples: int
+    """ Samples count."""
+
+    def __post_init__(self) -> None:
+        self.validate()
+
+    def validate(self) -> None:
+        duration = self.duration.total_seconds()
+        if duration != 0:
+            assert abs(self.sampling_rate - self.samples / duration) < 0.001
+        else:
+            assert self.sampling_rate == 0
+
+
+def audio_meta_data(**kwargs: str) -> AudioMeta:
+    return AudioMeta(
+        duration=TS(kwargs.get('duration', 0)),
+        start=TS(kwargs.get('start', 0)),
+        bitrate=int(kwargs.get('bit_rate', 0)),
+        channels=int(kwargs.get('channel_s', 0)),
+        sampling_rate=int(kwargs.get('sampling_rate', 0)),
+        samples=int(kwargs.get('samples_count', 0)),
+    )
+
+
+def video_meta_data(**kwargs: str) -> VideoMeta:
+    duration = TS(kwargs.get('duration', 0))
+    width = int(kwargs.get('width', 0))
+    height = int(kwargs.get('height', 0))
+    par = float(kwargs.get('pixel_aspect_ratio', 1.0))
+    try:
+        dar = float(kwargs['display_aspect_ratio'])
+    except KeyError:
+        if height == 0:
+            dar = float('nan')
+        else:
+            dar = width / height * par
+    frames = int(kwargs.get('frame_count', 0))
+    try:
+        frame_rate = float(kwargs['frame_rate'])
+    except KeyError:
+        if duration.total_seconds() == 0:
+            frame_rate = 0
+        else:
+            frame_rate = frames / duration.total_seconds()
+    return VideoMeta(
+        duration=duration,
+        start=TS(kwargs.get('start', 0)),
+        bitrate=int(kwargs.get('bit_rate', 0)),
+        width=width,
+        height=height,
+        par=par,
+        dar=dar,
+        frame_rate=frame_rate,
+        frames=frames)
+
+
+def from_media_info(mi: MediaInfo) -> List[Meta]:
+    streams: List[Meta] = []
+    for track in mi.tracks:
+        if track.track_type == 'Video':
+            streams.append(video_meta_data(**track.__dict__))
+        elif track.track_type == 'Audio':
+            streams.append(audio_meta_data(**track.__dict__))
+    return streams
diff --git a/fffw/graph/sources.py b/fffw/graph/sources.py
index 9385edf..e69de29 100644
--- a/fffw/graph/sources.py
+++ b/fffw/graph/sources.py
@@ -1,112 +0,0 @@
-from typing import Iterable, Optional, List, Any
-
-from fffw.graph.base import VIDEO, AUDIO
-from fffw.graph import base
-
-__all__ = [
-    'SourceFile',
-    'LavfiSource',
-]
-
-
-class BaseSource:
-    """ Source signal base class."""
-
-    def __init__(self, video_streams: int = 1, audio_streams: int = 1) -> None:
-        self.video_streams = video_streams
-        self.audio_streams = audio_streams
-
-
-class SourceFile(BaseSource):
-    """ Describes video/audio streams in an input file."""
-
-    def __init__(self,
-                 filename: str,
-                 video_streams: int = 1,
-                 audio_streams: int = 1) -> None:
-        super(SourceFile, self).__init__(video_streams=video_streams,
-                                         audio_streams=audio_streams)
-        self.filename = filename
-
-    def __str__(self) -> str:
-        return self.filename
-
-    def get_args(self) -> List[str]:
-        return ['-i', str(self)]
-
-
-class LavfiSource(BaseSource):
-    """ Describes video or audio stream generated by lavfi library."""
-
-    def __init__(self, name: str, kind: base.StreamType,
-                 video_streams: Optional[int] = None,
-                 audio_streams: Optional[int] = None,
-                 **opts: Any) -> None:
-        if video_streams is None:
-            video_streams = int(kind == VIDEO)
-        if audio_streams is None:
-            audio_streams = int(kind == AUDIO)
-        super(LavfiSource, self).__init__(video_streams, audio_streams)
-        self.name = name
-        self.opts = opts
-
-    def __str__(self) -> str:
-        if not self.opts:
-            return self.name
-        opts = ':'.join('%s=%s' % t for t in sorted(self.opts.items()))
-        return '%s=%s' % (self.name, opts)
-
-    def get_args(self) -> List[str]:
-        return ['-f', 'lavfi', '-i', str(self)]
-
-
-class Input:
-    """ Input streams grouper helpers."""
-
-    def __init__(self,
-                 streams: Iterable[base.Source] = (),
-                 kind: Optional[base.StreamType] = None) -> None:
-        assert kind in (VIDEO, AUDIO)
-        self.streams = list(streams) or []
-        self.kind = kind
-
-    def connect_source(self, other: base.Source) -> None:
-        """ Add and input stream to streams."""
-        assert isinstance(other, base.Source)
-        self.streams.append(other)
-
-    def connect_dest(self, other: base.Node) -> base.Node:
-        """ Connects first free input to a filter
-        :param other: filter
-        :return filter connected to an input
-        """
-        assert isinstance(other, base.Node)
-        input_map = getattr(other, 'map', None)
-        for stream in self.streams:
-            try:
-                stream_name = stream.name
-            except RuntimeError:
-                # skip streams not present in input file
-                continue
-
-            if input_map and input_map == stream_name:
-                return stream.connect(other)
-            if stream.edge is None:
-                return stream.connect(other)
-        raise IndexError("No free sources")
-
-    def __or__(self, other: base.Node) -> base.Node:
-        """
-        Connects first free input to a filter.
-        """
-        if not isinstance(other, base.Node):
-            return NotImplemented
-        return self.connect_dest(other)
-
-    def __lt__(self, other: base.Source) -> None:
-        """
-        Adds an input stream to input
-        """
-        if not isinstance(other, base.Source):
-            return NotImplemented
-        return self.connect_source(other)
diff --git a/setup.py b/setup.py
index 8f7b7ec..72a02d1 100644
--- a/setup.py
+++ b/setup.py
@@ -10,7 +10,7 @@ with open('README.md') as f:
     long_description = f.read()
 
 version_re = re.compile('^Version: (.+)$', re.M)
-package_name = 'django_sphinxsearch'
+package_name = 'fffw'
 
 
 def get_version() -> Optional[str]:
@@ -62,10 +62,10 @@ def get_version() -> Optional[str]:
 
 
 setup(
-    name='fffw',
+    name=package_name,
     version=get_version() or 'dev',
     packages=find_packages(exclude=["tests"]),
-    url='http://github.com/tumb1er/fffw',
+    url='http://github.com/just-work/fffw',
     license='MIT',
     author='tumb1er',
     author_email='zimbler@gmail.com',
@@ -73,6 +73,9 @@ setup(
     long_description=long_description,
     long_description_content_type='text/markdown',
     test_suite="tests",
+    install_requires=[
+        'pymediainfo',
+    ],
     classifiers=[
         'Development Status :: 4 - Beta',
         'Environment :: Console',


Test patch:
diff --git a/tests/test_encoding.py b/tests/test_encoding.py
new file mode 100644
index 0000000..d86f302
--- /dev/null
+++ b/tests/test_encoding.py
@@ -0,0 +1,59 @@
+from unittest import TestCase
+
+from fffw.graph import StreamType, inputs
+
+
+class InputsTestCase(TestCase):
+    """ Checks ffmpeg inputs configuration."""
+
+    def setUp(self) -> None:
+        self.v1 = inputs.Stream(StreamType.VIDEO)
+        self.v2 = inputs.Stream(StreamType.VIDEO)
+        self.a1 = inputs.Stream(StreamType.AUDIO)
+        self.a2 = inputs.Stream(StreamType.AUDIO)
+        self.a3 = inputs.Stream(StreamType.AUDIO)
+        self.i1 = inputs.Input(self.v1, self.a1)
+        self.i2 = inputs.Input(self.a2, self.v2, self.a3)
+
+    def test_input_list(self):
+        """ Inputs and streams are properly enumerated."""
+        il = inputs.InputList(self.i1, self.i2)
+        self.assertEqual(il.inputs[0].index, 0)
+        self.assertEqual(il.inputs[1].index, 1)
+        self.assertEqual(self.v1.name, '0:v')
+        self.assertEqual(self.a1.name, '0:a')
+        self.assertEqual(self.v2.name, '1:v')
+        self.assertEqual(self.a2.name, '1:a')
+        self.assertEqual(self.a3.name, '1:a:1')
+
+    def test_default_input(self):
+        """
+        By default each input has a video and an audio stream without meta.
+        """
+        source = inputs.Input()
+        self.assertEqual(len(source.streams), 2)
+        v = source.streams[0]
+        self.assertEqual(v.kind, StreamType.VIDEO)
+        self.assertIsNone(v._meta)
+        a = source.streams[1]
+        self.assertEqual(a.kind, StreamType.AUDIO)
+        self.assertIsNone(a._meta)
+
+    def test_append_source(self):
+        """
+        Source file streams receive indices when appended to input list.
+        """
+        il = inputs.InputList()
+        v3 = inputs.Stream(StreamType.VIDEO)
+
+        il.append(inputs.Input(v3))
+
+        self.assertEqual(v3.name, '0:v')
+
+    def test_validate_stream_kind(self):
+        """
+        Stream without proper StreamType can't be added to input.
+        """
+        # noinspection PyTypeChecker
+        self.assertRaises(ValueError, inputs.Input, inputs.Stream(kind=None),
+                          input_file='input.mp4')
diff --git a/tests/test_ffmpeg.py b/tests/test_ffmpeg.py
index 331538d..b4ea39b 100644
--- a/tests/test_ffmpeg.py
+++ b/tests/test_ffmpeg.py
@@ -10,7 +10,7 @@ class FFMPEGTestCase(TestCase):
     def test_ffmpeg(self):
         """ Smoke test and feature demo."""
         ff = FFMPEG()
-        ff < SourceFile('/tmp/input.mp4')
+        ff < Input(input_file='/tmp/input.mp4')
 
         fc = ff.init_filter_complex()
 
@@ -51,7 +51,7 @@ class FFMPEGTestCase(TestCase):
 
     def test_bypass_with_filter_complex(self):
         """ Audio stream bypass mode."""
-        ff = FFMPEG(inputfile=SourceFile('/tmp/input.mp4'))
+        ff = FFMPEG('/tmp/input.mp4')
 
         fc = ff.init_filter_complex()
         fc.video | Scale(640, 360) | fc.get_video_dest(0)
@@ -75,7 +75,7 @@ class FFMPEGTestCase(TestCase):
 
     def test_bypass_without_filter_complex(self):
         """ Stream bypass with filter_complex missing."""
-        ff = FFMPEG(inputfile=SourceFile('/tmp/input.mp4'))
+        ff = FFMPEG('/tmp/input.mp4')
 
         cv0 = VideoCodec(vcodec='libx264', vbitrate='700000', size='640x360')
         ca0 = AudioCodec(acodec='aac', abitrate='128000')
@@ -96,8 +96,8 @@ class FFMPEGTestCase(TestCase):
         """ Input stream naming test."""
 
         ff = FFMPEG()
-        ff < SourceFile('/tmp/input.jpg', audio_streams=0)
-        ff < SourceFile('/tmp/input.mp4')
+        ff < Input(Stream(VIDEO), input_file='/tmp/input.jpg')
+        ff < Input(input_file='/tmp/input.mp4')
         fc = ff.init_filter_complex()
         overlay = Overlay(0, 0)
         fc.video | Scale(640, 360) | overlay
@@ -129,7 +129,7 @@ class FFMPEGTestCase(TestCase):
 
     def test_handle_codec_copy(self):
         """ vcodec=copy connects source directly to muxer."""
-        ff = FFMPEG(inputfile=SourceFile('/tmp/input.mp4'))
+        ff = FFMPEG('/tmp/input.mp4')
 
         fc = ff.init_filter_complex()
 
@@ -155,7 +155,7 @@ class FFMPEGTestCase(TestCase):
 
     def test_reuse_input_files(self):
         """ Reuse input files multiple times."""
-        ff = FFMPEG(inputfile=SourceFile('/tmp/input.mp4'))
+        ff = FFMPEG('/tmp/input.mp4')
         cv0 = VideoCodec(map='0:v', vcodec='copy')
         ca0 = AudioCodec(map='0:a', acodec='copy')
         out0 = FLVMuxer('/tmp/out0.flv')
@@ -185,7 +185,7 @@ class FFMPEGTestCase(TestCase):
 
     def test_handle_codec_copy_with_other_filters(self):
         """ vcodec=copy with separate transcoded output."""
-        ff = FFMPEG(inputfile=SourceFile('/tmp/input.mp4'))
+        ff = FFMPEG('/tmp/input.mp4')
 
         fc = ff.init_filter_complex()
 
@@ -222,18 +222,14 @@ class FFMPEGTestCase(TestCase):
         ]
         self.assertEqual(ff.get_args(), ensure_binary(expected))
 
-    def test_lavfi_sources(self):
-        """ lavfi generated sources support."""
-        vsrc = LavfiSource('testsrc', VIDEO, duration=5.3, rate=10)
-        asrc = LavfiSource('sine', AUDIO, d=5, b=4)
-        ff = FFMPEG(inputfile=[vsrc, asrc])
+    def test_transcoding_without_graph(self):
+        """ Transcoding works without filter graph."""
+        ff = FFMPEG()
+        ff < Input(input_file='input.mp4')
         ff.add_output(NullMuxer('/dev/null'))
         expected = [
             'ffmpeg',
-            '-f', 'lavfi',
-            '-i', 'testsrc=duration=5.3:rate=10',
-            '-f', 'lavfi',
-            '-i', 'sine=b=4:d=5',
+            '-i', 'input.mp4',
             '-f', 'null',
             '/dev/null'
         ]
@@ -241,7 +237,7 @@ class FFMPEGTestCase(TestCase):
 
     def test_hls_muxer(self):
         """ hls muxer args."""
-        ff = FFMPEG(inputfile=SourceFile('/tmp/input.mp4'))
+        ff = FFMPEG('/tmp/input.mp4')
 
         cv0 = VideoCodec(vcodec='libx264')
         ca0 = AudioCodec(acodec='aac')
@@ -265,7 +261,7 @@ class FFMPEGTestCase(TestCase):
 
     def test_tee_muxer(self):
         """ tee muxer args."""
-        ff = FFMPEG(inputfile=SourceFile('/tmp/input.mp4'))
+        ff = FFMPEG('/tmp/input.mp4')
 
         cv0 = VideoCodec(vcodec='libx264')
         ca0 = AudioCodec(acodec='aac')
@@ -290,8 +286,8 @@ class FFMPEGTestCase(TestCase):
     def test_concat(self):
         """ Concat source files."""
         ff = FFMPEG()
-        ff < SourceFile('preroll.mp4')
-        ff < SourceFile('input.mp4')
+        ff < Input(input_file='preroll.mp4')
+        ff < Input(input_file='input.mp4')
 
         fc = ff.init_filter_complex()
 
diff --git a/tests/test_graph.py b/tests/test_graph.py
index ca7202c..fa845e5 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -1,7 +1,7 @@
 from unittest import TestCase
 
 from fffw.graph import *
-from fffw.graph import base, sources
+from fffw.graph import meta, inputs
 
 
 class FilterGraphTestCase(TestCase):
@@ -15,14 +15,11 @@ class FilterGraphTestCase(TestCase):
                                             |
                                             ------<Scale>--[O/720p]
         """
+        logo = inputs.Input(inputs.Stream(VIDEO), input_file='logo.png')
+        main = inputs.Input(input_file='main.mp4')
+        il = inputs.InputList(main, logo)
 
-        inputs = 2  # number of input files - logo + video
-
-        video_streams = [base.Source("%i:v" % i, VIDEO) for i in range(inputs)]
-        audio_streams = [base.Source("0:a", AUDIO)]
-
-        fc = FilterComplex(video=sources.Input(video_streams, VIDEO),
-                           audio=sources.Input(audio_streams, AUDIO))
+        fc = FilterComplex(il)
 
         deint = Deint(enabled=False)  # deinterlace is disabled
 
@@ -73,11 +70,11 @@ class FilterGraphTestCase(TestCase):
             '[v:split0]scale=640x480[vout0]',
             '[v:split1]scale=1280x720[vout1]',
 
+            # split audio to two streams
+            '[0:a]asplit[aout0][aout1]',
+
             # logo scaling
             '[1:v]scale=200x50[v:scale0]',
-
-            # split audio to two streams
-            '[0:a]asplit[aout0][aout1]'
         ])
 
         self.assertEqual(expected.replace(';', ';\n'),
@@ -85,30 +82,32 @@ class FilterGraphTestCase(TestCase):
 
     def test_disabled_filters(self):
         """ Filter skipping."""
-        fc = FilterComplex(video=sources.Input(
-            [base.Source("0:v", VIDEO)], VIDEO))
+        source = inputs.Input(inputs.Stream(VIDEO), input_file="input.mp4")
+        fc = FilterComplex(inputs.InputList(source))
 
         dest = fc.get_video_dest(0)
         fc.video | Scale(640, 360) | Deint(enabled=False) | dest
         self.assertEqual(fc.render(), '[0:v]scale=640x360[vout0]')
 
-        fc = FilterComplex(video=sources.Input(
-            [base.Source("0:v", VIDEO)], VIDEO))
+        source = inputs.Input(inputs.Stream(VIDEO), input_file="input.mp4")
+        fc = FilterComplex(inputs.InputList(source))
 
         dest = fc.get_video_dest(0)
         fc.video | Deint(enabled=False) | Scale(640, 360) | dest
         self.assertEqual(fc.render(), '[0:v]scale=640x360[vout0]')
 
-        fc = FilterComplex(video=sources.Input(
-            [base.Source("0:v", VIDEO)], VIDEO))
+        source = inputs.Input(inputs.Stream(VIDEO), input_file="input.mp4")
+        fc = FilterComplex(inputs.InputList(source))
+
         dest = fc.get_video_dest(0)
         tmp = fc.video | Deint(enabled=False)
         tmp = tmp | Deint(enabled=False)
         tmp | Scale(640, 360) | dest
         self.assertEqual(fc.render(), '[0:v]scale=640x360[vout0]')
 
-        fc = FilterComplex(video=sources.Input(
-            [base.Source("0:v", VIDEO)], VIDEO))
+        source = inputs.Input(inputs.Stream(VIDEO), input_file="input.mp4")
+        fc = FilterComplex(inputs.InputList(source))
+
         dest = fc.get_video_dest(0)
         tmp = fc.video | Scale(640, 360)
         tmp = tmp | Deint(enabled=False)
@@ -118,12 +117,26 @@ class FilterGraphTestCase(TestCase):
     def test_skip_not_connected_sources(self):
         """ Skip unused sources in filter complex.
         """
-        fc = FilterComplex(
-            video=sources.Input(
-                [base.Source("0:v", VIDEO)], VIDEO),
-            audio=sources.Input(
-                [base.Source("0:a", AUDIO)], AUDIO))
+        source = inputs.Input(input_file='input.mp4')
+        il = inputs.InputList(source)
+        # passing only video to FilterComplex
+        fc = FilterComplex(il)
         dest = fc.get_video_dest(0)
         fc.video | Scale(640, 360) | dest
 
         self.assertEqual(fc.render(), '[0:v]scale=640x360[vout0]')
+
+    def test_pass_metadata(self):
+        """
+        stream metadata is passed from source to destination
+        """
+        metadata = meta.video_meta_data()
+
+        source = inputs.Input(inputs.Stream(VIDEO, meta=metadata),
+                              input_file='input.mp4')
+        il = inputs.InputList(source)
+        fc = FilterComplex(il)
+        dest = fc.get_video_dest(0)
+        fc.video | Scale(640, 360) | dest
+
+        self.assertIs(dest.get_meta_data(dest), metadata)
diff --git a/tests/test_meta_data.py b/tests/test_meta_data.py
new file mode 100644
index 0000000..804e63e
--- /dev/null
+++ b/tests/test_meta_data.py
@@ -0,0 +1,235 @@
+from unittest import TestCase
+
+from pymediainfo import MediaInfo  # type: ignore
+
+from fffw.graph import meta
+
+SAMPLE = '''<?xml version="1.0" encoding="UTF-8"?>
+<Mediainfo version="19.09">
+<File>
+<track type="General">
+<Count>331</Count>
+<Count_of_stream_of_this_kind>1</Count_of_stream_of_this_kind>
+<Kind_of_stream>General</Kind_of_stream>
+<Kind_of_stream>General</Kind_of_stream>
+<Stream_identifier>0</Stream_identifier>
+<Count_of_video_streams>1</Count_of_video_streams>
+<Count_of_audio_streams>1</Count_of_audio_streams>
+<Video_Format_List>AVC</Video_Format_List>
+<Video_Format_WithHint_List>AVC</Video_Format_WithHint_List>
+<Codecs_Video>AVC</Codecs_Video>
+<Audio_Format_List>AAC LC</Audio_Format_List>
+<Audio_Format_WithHint_List>AAC LC</Audio_Format_WithHint_List>
+<Audio_codecs>AAC LC</Audio_codecs>
+<Complete_name>/home/tumbler/Dropbox/Video/test_hd.mp4</Complete_name>
+<Folder_name>/home/tumbler/Dropbox/Video</Folder_name>
+<File_name_extension>test_hd.mp4</File_name_extension>
+<File_name>test_hd</File_name>
+<File_extension>mp4</File_extension>
+<Format>MPEG-4</Format>
+<Format>MPEG-4</Format>
+<Format_Extensions_usually_used>braw mov mp4 m4v m4a m4b m4p m4r 3ga 3gpa 3gpp 3gp 3gpp2 3g2 k3g jpm jpx mqv ismv isma ismt f4a f4b f4v</Format_Extensions_usually_used>
+<Commercial_name>MPEG-4</Commercial_name>
+<Format_profile>Base Media</Format_profile>
+<Internet_media_type>video/mp4</Internet_media_type>
+<Codec_ID>isom</Codec_ID>
+<Codec_ID>isom (isom/iso2/avc1/mp41)</Codec_ID>
+<Codec_ID_Url>http://www.apple.com/quicktime/download/standalone.html</Codec_ID_Url>
+<CodecID_Compatible>isom/iso2/avc1/mp41</CodecID_Compatible>
+<File_size>3812658</File_size>
+<File_size>3.64 MiB</File_size>
+<File_size>4 MiB</File_size>
+<File_size>3.6 MiB</File_size>
+<File_size>3.64 MiB</File_size>
+<File_size>3.636 MiB</File_size>
+<Duration>6742</Duration>
+<Duration>6 s 742 ms</Duration>
+<Duration>6 s 742 ms</Duration>
+<Duration>6 s 742 ms</Duration>
+<Duration>00:00:06.742</Duration>
+<Duration>00:00:06:37</Duration>
+<Duration>00:00:06.742 (00:00:06:37)</Duration>
+<Overall_bit_rate>4524068</Overall_bit_rate>
+<Overall_bit_rate>4 524 kb/s</Overall_bit_rate>
+<Frame_rate>50.000</Frame_rate>
+<Frame_rate>50.000 FPS</Frame_rate>
+<Frame_count>337</Frame_count>
+<Stream_size>9605</Stream_size>
+<Stream_size>9.38 KiB (0%)</Stream_size>
+<Stream_size>9 KiB</Stream_size>
+<Stream_size>9.4 KiB</Stream_size>
+<Stream_size>9.38 KiB</Stream_size>
+<Stream_size>9.380 KiB</Stream_size>
+<Stream_size>9.38 KiB (0%)</Stream_size>
+<Proportion_of_this_stream>0.00252</Proportion_of_this_stream>
+<HeaderSize>40</HeaderSize>
+<DataSize>3803061</DataSize>
+<FooterSize>9557</FooterSize>
+<IsStreamable>No</IsStreamable>
+<File_last_modification_date>UTC 2014-11-10 11:57:35</File_last_modification_date>
+<File_last_modification_date__local_>2014-11-10 14:57:35</File_last_modification_date__local_>
+<Writing_application>Lavf53.32.100</Writing_application>
+<Writing_application>Lavf53.32.100</Writing_application>
+</track>
+<track type="Video">
+<Count>378</Count>
+<Count_of_stream_of_this_kind>1</Count_of_stream_of_this_kind>
+<Kind_of_stream>Video</Kind_of_stream>
+<Kind_of_stream>Video</Kind_of_stream>
+<Stream_identifier>0</Stream_identifier>
+<StreamOrder>0</StreamOrder>
+<ID>1</ID>
+<ID>1</ID>
+<Format>AVC</Format>
+<Format>AVC</Format>
+<Format_Info>Advanced Video Codec</Format_Info>
+<Format_Url>http://developers.videolan.org/x264.html</Format_Url>
+<Commercial_name>AVC</Commercial_name>
+<Format_profile>High@L4.2</Format_profile>
+<Format_settings>CABAC / 4 Ref Frames</Format_settings>
+<Format_settings__CABAC>Yes</Format_settings__CABAC>
+<Format_settings__CABAC>Yes</Format_settings__CABAC>
+<Format_settings__Reference_frames>4</Format_settings__Reference_frames>
+<Format_settings__Reference_frames>4 frames</Format_settings__Reference_frames>
+<Internet_media_type>video/H264</Internet_media_type>
+<Codec_ID>avc1</Codec_ID>
+<Codec_ID_Info>Advanced Video Coding</Codec_ID_Info>
+<Duration>6740</Duration>
+<Duration>6 s 740 ms</Duration>
+<Duration>6 s 740 ms</Duration>
+<Duration>6 s 740 ms</Duration>
+<Duration>00:00:06.740</Duration>
+<Duration>00:00:06:37</Duration>
+<Duration>00:00:06.740 (00:00:06:37)</Duration>
+<Bit_rate>4321426</Bit_rate>
+<Bit_rate>4 321 kb/s</Bit_rate>
+<Width>1920</Width>
+<Width>1 920 pixels</Width>
+<Height>1080</Height>
+<Height>1 080 pixels</Height>
+<Stored_Height>1088</Stored_Height>
+<Sampled_Width>1920</Sampled_Width>
+<Sampled_Height>1080</Sampled_Height>
+<Pixel_aspect_ratio>1.000</Pixel_aspect_ratio>
+<Display_aspect_ratio>1.778</Display_aspect_ratio>
+<Display_aspect_ratio>16:9</Display_aspect_ratio>
+<Rotation>0.000</Rotation>
+<Frame_rate_mode>CFR</Frame_rate_mode>
+<Frame_rate_mode>Constant</Frame_rate_mode>
+<FrameRate_Mode_Original>VFR</FrameRate_Mode_Original>
+<Frame_rate>50.000</Frame_rate>
+<Frame_rate>50.000 FPS</Frame_rate>
+<Frame_count>337</Frame_count>
+<Color_space>YUV</Color_space>
+<Chroma_subsampling>4:2:0</Chroma_subsampling>
+<Chroma_subsampling>4:2:0</Chroma_subsampling>
+<Bit_depth>8</Bit_depth>
+<Bit_depth>8 bits</Bit_depth>
+<Scan_type>Progressive</Scan_type>
+<Scan_type>Progressive</Scan_type>
+<Bits__Pixel_Frame_>0.042</Bits__Pixel_Frame_>
+<Stream_size>3640801</Stream_size>
+<Stream_size>3.47 MiB (95%)</Stream_size>
+<Stream_size>3 MiB</Stream_size>
+<Stream_size>3.5 MiB</Stream_size>
+<Stream_size>3.47 MiB</Stream_size>
+<Stream_size>3.472 MiB</Stream_size>
+<Stream_size>3.47 MiB (95%)</Stream_size>
+<Proportion_of_this_stream>0.95492</Proportion_of_this_stream>
+<Writing_library>x264 - core 120 r2151 a3f4407</Writing_library>
+<Writing_library>x264 core 120 r2151 a3f4407</Writing_library>
+<Encoded_Library_Name>x264</Encoded_Library_Name>
+<Encoded_Library_Version>core 120 r2151 a3f4407</Encoded_Library_Version>
+<Encoding_settings>cabac=1 / ref=3 / deblock=1:0:0 / analyse=0x3:0x113 / me=hex / subme=7 / psy=1 / psy_rd=1.00:0.00 / mixed_ref=1 / me_range=16 / chroma_me=1 / trellis=1 / 8x8dct=1 / cqm=0 / deadzone=21,11 / fast_pskip=1 / chroma_qp_offset=-2 / threads=3 / sliced_threads=0 / nr=0 / decimate=1 / interlaced=0 / bluray_compat=0 / constrained_intra=0 / bframes=3 / b_pyramid=2 / b_adapt=1 / b_bias=0 / direct=1 / weightb=1 / open_gop=0 / weightp=2 / keyint=250 / keyint_min=25 / scenecut=40 / intra_refresh=0 / rc_lookahead=40 / rc=crf / mbtree=1 / crf=23.0 / qcomp=0.60 / qpmin=0 / qpmax=69 / qpstep=4 / ip_ratio=1.40 / aq=1:1.00</Encoding_settings>
+<Codec_configuration_box>avcC</Codec_configuration_box>
+</track>
+<track type="Audio">
+<Count>280</Count>
+<Count_of_stream_of_this_kind>1</Count_of_stream_of_this_kind>
+<Kind_of_stream>Audio</Kind_of_stream>
+<Kind_of_stream>Audio</Kind_of_stream>
+<Stream_identifier>0</Stream_identifier>
+<StreamOrder>1</StreamOrder>
+<ID>2</ID>
+<ID>2</ID>
+<Format>AAC</Format>
+<Format>AAC LC</Format>
+<Format_Info>Advanced Audio Codec Low Complexity</Format_Info>
+<Commercial_name>AAC</Commercial_name>
+<Format_AdditionalFeatures>LC</Format_AdditionalFeatures>
+<Codec_ID>mp4a-40-2</Codec_ID>
+<Duration>6742</Duration>
+<Duration>6 s 742 ms</Duration>
+<Duration>6 s 742 ms</Duration>
+<Duration>6 s 742 ms</Duration>
+<Duration>00:00:06.742</Duration>
+<Duration>00:00:06:34</Duration>
+<Duration>00:00:06.742 (00:00:06:34)</Duration>
+<Bit_rate_mode>CBR</Bit_rate_mode>
+<Bit_rate_mode>Constant</Bit_rate_mode>
+<Bit_rate>192000</Bit_rate>
+<Bit_rate>192 kb/s</Bit_rate>
+<Channel_s_>6</Channel_s_>
+<Channel_s_>6 channels</Channel_s_>
+<Channel_positions>Front: L C R, Side: L R, LFE</Channel_positions>
+<Channel_positions>3/2/0.1</Channel_positions>
+<Channel_layout>C L R Ls Rs LFE</Channel_layout>
+<Samples_per_frame>1024</Samples_per_frame>
+<Sampling_rate>48000</Sampling_rate>
+<Sampling_rate>48.0 kHz</Sampling_rate>
+<Samples_count>323616</Samples_count>
+<Frame_rate>46.875</Frame_rate>
+<Frame_rate>46.875 FPS (1024 SPF)</Frame_rate>
+<Frame_count>316</Frame_count>
+<Compression_mode>Lossy</Compression_mode>
+<Compression_mode>Lossy</Compression_mode>
+<Stream_size>162252</Stream_size>
+<Stream_size>158 KiB (4%)</Stream_size>
+<Stream_size>158 KiB</Stream_size>
+<Stream_size>158 KiB</Stream_size>
+<Stream_size>158 KiB</Stream_size>
+<Stream_size>158.4 KiB</Stream_size>
+<Stream_size>158 KiB (4%)</Stream_size>
+<Proportion_of_this_stream>0.04256</Proportion_of_this_stream>
+<Default>Yes</Default>
+<Default>Yes</Default>
+<Alternate_group>1</Alternate_group>
+<Alternate_group>1</Alternate_group>
+</track>
+</File>
+</Mediainfo>
+'''
+
+
+class MetaDataTestCase(TestCase):
+    def setUp(self) -> None:
+        self.media_info = MediaInfo(SAMPLE)
+
+    def test_parse_streams(self):
+        streams = meta.from_media_info(self.media_info)
+        self.assertEqual(len(streams), 2)
+        video = streams[0]
+        self.assertIsInstance(video, meta.VideoMeta)
+        expected = meta.VideoMeta(
+            duration=meta.TS(6.740),
+            start=meta.TS(0),
+            bitrate=4321426,
+            width=1920,
+            height=1080,
+            par=1.0,
+            dar=1.778,
+            frame_rate=50.0,
+            frames=337,
+        )
+        self.assertEqual(expected, video)
+        audio = streams[1]
+        self.assertIsInstance(audio, meta.AudioMeta)
+        expected = meta.AudioMeta(
+            duration=meta.TS(6.742),
+            start=meta.TS(0),
+            bitrate=192000,
+            sampling_rate=48000,
+            channels=6,
+            samples=323616,
+        )
+        self.assertEqual(expected, audio)