diff --git a/fffw/encoding/__init__.py b/fffw/encoding/__init__.py
index fa90361..e8b628c 100644
--- a/fffw/encoding/__init__.py
+++ b/fffw/encoding/__init__.py
@@ -1,5 +1,4 @@
+from .codecs import *
 from .ffmpeg import *
+from .filters import *
 
-__all__ = (
-        ffmpeg.__all__  # type: ignore
-)
diff --git a/fffw/encoding/codecs.py b/fffw/encoding/codecs.py
new file mode 100644
index 0000000..b961dfd
--- /dev/null
+++ b/fffw/encoding/codecs.py
@@ -0,0 +1,43 @@
+from fffw.graph import base, outputs
+
+
+__all__ = [
+    'AudioCodec',
+    'VideoCodec',
+]
+
+
+class VideoCodec(outputs.Codec):
+    """
+    Base class for describing video codecs.
+
+    See `fffw.graph.outputs.Codec` for params definition.
+    >>> from dataclasses import dataclass
+    >>> from fffw.wrapper import param
+    >>> @dataclass
+    ... class X264(VideoCodec):
+    ...     codec = 'libx264'
+    ...     gop: int = param(name='g')
+    ...
+    >>> codec = X264(bitrate=4000000, gop=25)
+    >>> copy = VideoCodec('copy')
+    """
+    kind = base.VIDEO
+
+
+class AudioCodec(outputs.Codec):
+    """
+    Base class for describing audio codecs.
+
+    See `fffw.graph.outputs.Codec` for params definition.
+    >>> from dataclasses import dataclass
+    >>> from fffw.wrapper import param
+    >>> @dataclass
+    ... class FdkAAC(AudioCodec):
+    ...     codec = 'libfdk_aac'
+    ...     rate: int = param(default=48000, name='-r')
+    ...
+    >>> codec = FdkAAC(bitrate=192000, rate=44100)
+    >>> copy = AudioCodec('copy')
+    """
+    kind = base.AUDIO
diff --git a/fffw/encoding/ffmpeg.py b/fffw/encoding/ffmpeg.py
index a82c289..210afc3 100644
--- a/fffw/encoding/ffmpeg.py
+++ b/fffw/encoding/ffmpeg.py
@@ -1,115 +1,136 @@
-from typing import List, Any, Union, Optional
+from dataclasses import dataclass
+from typing import List, Optional, Literal, Union
 
-from fffw.graph import base, inputs, outputs
+from fffw.graph import base
 from fffw.graph.complex import FilterComplex
-from fffw.wrapper import BaseWrapper, ensure_binary
+from fffw.graph.inputs import InputList, Input
+from fffw.graph.outputs import OutputList, Output, Codec
+from fffw.wrapper import BaseWrapper, ensure_binary, param
 
 __all__ = ['FFMPEG']
 
-
-class InputList(list):
-    def __call__(self) -> List[str]:
-        """ Delegates arguments formatting to Source objects."""
-        result: List[str] = []
-        for src in self:
-            if hasattr(src, 'get_args') and callable(src.get_args):
-                result.extend(src.get_args())
-            else:
-                result.append(str(src))
-        return result
+LogLevel = Literal['quiet', 'panic', 'fatal', 'error', 'warning',
+                   'info', 'verbose', 'debug', 'trace']
+""" Allowed values for ffmpeg log level."""
 
 
+@dataclass
 class FFMPEG(BaseWrapper):
+    """
+    ffmpeg command line basic wrapper.
+
+    >>> from fffw.encoding.codecs import VideoCodec, AudioCodec
+    >>> from fffw.encoding.filters import Scale
+    >>> from fffw.graph.outputs import output_file
+    >>> ff = FFMPEG('/tmp/input.mp4', overwrite=True)
+    >>> c = VideoCodec('libx264', bitrate=4_000_000)
+    >>> ff.video | Scale(1280, 720) > c
+    VideoCodec(codec='libx264', bitrate=4000000)
+    >>> ff.overwrite = True
+    >>> ff > output_file('/tmp/output.mp4', c,
+    ...                  AudioCodec('libfdk_aac', bitrate=192_000))
+    >>> b' '.join(ff.get_args()).decode('utf-8')
+    'ffmpeg -y -i /tmp/input.mp4
+    -filter_complex [0:v]scale=w=1280:h=720[vout0]
+    -map [vout0] -c:v libx264 -b:v 4000000
+    -map 0:a -c:a libfdk_aac -b:a 192000
+    /tmp/output.mp4'
+    >>>
+    """
     command = 'ffmpeg'
     stderr_markers = ['[error]', '[fatal]']
+    input: Union[str, Input] = param(skip=True)
+    output: Union[str, Output] = param(skip=True)
+
+    loglevel: LogLevel = param()
+    overwrite: bool = param(name='y')
+
+    def __post_init__(self) -> None:
+        """
+        Fills internal shared structures for input and output files, and
+        initializes filter graph.
+        """
+
+        self.__inputs = InputList()
+        if self.input:
+            if not isinstance(self.input, Input):
+                self.__inputs.append(Input(input_file=self.input))
+            else:
+                self.__inputs.append(self.input)
 
-    arguments = [
-        ('loglevel', '-loglevel '),
-        ('strict', '-strict '),
-        ('realtime', '-re '),
-        ('threads', '-threads '),
-        ('time_offset', '-ss '),
-        ('no_autorotate', '-noautorotate'),
-        ('inputformat', '-f '),
-        ('pix_fmt', '-pix_fmt '),
-        ('presize_offset', '-ss '),
-        ('filter_complex', '-filter_complex '),
-        ('time_limit', '-t '),
-        ('vframes', '-vframes '),
-        ('overwrite', '-y '),
-        ('verbose', '-v '),
-        ('novideo', '-vn '),
-        ('noaudio', '-an '),
-        ('vfilter', '-vf '),
-        ('afilter', '-af '),
-        ('metadata', '-metadata '),
-        ('map_chapters', '-map_chapters '),
-        ('map_metadata', '-map_metadata '),
-        ('vbsf', '-bsf:v '),
-        ('absf', '-bsf:a '),
-        ('format', '-f '),
-        ('segment_list_flags', '-segment_list_flags '),
-    ]
-
-    def __init__(self, *sources: Union[inputs.Input, str],
-                 output: Union[None, outputs.Output, str] = None, **kw: Any):
-        """
-        :param sources: list of input files (or another ffmpeg sources)
-        :param kw: ffmpeg command line arguments
-        """
-        super(FFMPEG, self).__init__(**kw)
-        self.__input_list = inputs.InputList(
-            *(
-                inputs.Input(input_file=src)
-                if isinstance(src, str) else src
-                for src in sources
-            ))
-        self.__output_list = outputs.OutputList()
-        if output:
-            self.__output_list.append(
-                outputs.Output(output_file=output)
-                if isinstance(output, str) else output
-            )
-
-    def __lt__(self, other: inputs.Input) -> None:
+        self.__outputs = OutputList()
+        if self.output:
+            if not isinstance(self.output, Output):
+                self.__outputs.append(Output(output_file=self.output))
+            else:
+                self.__outputs.append(self.output)
+
+        self.__filter_complex = FilterComplex(self.__inputs, self.__outputs)
+
+        # calling super() to freeze params.
+        super().__post_init__()
+
+    def __lt__(self, other: Input) -> None:
         """ Adds new source file.
+
+        >>> ff = FFMPEG()
+        >>> ff < Input(input_file='/tmp/input.mp4')
+        >>>
         """
-        if not isinstance(other, inputs.Input):
+        if not isinstance(other, Input):
             return NotImplemented
         self.add_input(other)
 
-    def __gt__(self, other: outputs.Output) -> None:
-        """ Adds new output file."""
-        if not isinstance(other, outputs.Output):
+    def __gt__(self, other: Output) -> None:
+        """ Adds new output file.
+
+        >>> ff = FFMPEG()
+        >>> ff > Output(output_file='/tmp/output.mp4')
+        >>>
+        """
+        if not isinstance(other, Output):
             return NotImplemented
         self.add_output(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':
-            raise NotImplementedError("use add_input instead")
-        return super(FFMPEG, self).__setattr__(key, value)
+    @property
+    def video(self) -> base.Source:
+        """
+        :returns: first video stream not yet connected to filter graph or codec.
+
+        >>> from fffw.encoding.filters import Scale
+        >>> ff = FFMPEG('/tmp/input.mp4')
+        >>> ff.video | Scale(1280, 720)
+        >>>
+        """
+        return self._get_free_source(base.VIDEO)
 
     @property
-    def filter_complex(self) -> FilterComplex:
-        # TODO #9 refactor filter complex initialization
-        return self._args['filter_complex']
+    def audio(self) -> base.Source:
+        """
+
+        :returns: first audio stream not yet connected to filter graph or codec.
+
+        >>> from fffw.encoding.codecs import AudioCodec
+        >>> ff = FFMPEG('/tmp/input.mp4')
+        >>> ac = AudioCodec('aac')
+        >>> ff.audio > ac
+        >>>
+        """
+        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 destination
         """
-        for stream in self.__input_list.streams:
+        for stream in self.__inputs.streams:
             if stream.kind != kind or stream.connected:
                 continue
             return stream
         else:
             raise RuntimeError("no free streams")
 
-    def _add_codec(self, c: outputs.Codec) -> Optional[outputs.Codec]:
+    def _add_codec(self, c: Codec) -> Optional[Codec]:
         """ Connect codec to filter graph output or input stream.
 
         :param c: codec to connect to free source
@@ -122,39 +143,59 @@ class FFMPEG(BaseWrapper):
         node.connect_dest(c)
         return c
 
-    def init_filter_complex(self) -> FilterComplex:
-        # TODO #9 refactor filter complex initialization
-        fc = FilterComplex(self.__input_list, self.__output_list)
-        self._args['filter_complex'] = fc
-        return fc
-
     def get_args(self) -> List[bytes]:
+        """
+        :returns: command line arguments for ffmpeg.
+
+        This includes:
+        - ffmpeg executable name
+        - ffmpeg parameters
+        - input list args
+        - filter_graph definition
+        - output list args
+        """
+
         with base.Namer():
+            fc = str(self.__filter_complex)
+            fc_args = ['-filter_complex', fc] if fc else []
+
             # Namer context is used to generate unique output stream names
             return (ensure_binary([self.command]) +
-                    self.__input_list.get_args() +
-                    super(FFMPEG, self).get_args() +
-                    self.__output_list.get_args())
+                    super().get_args() +
+                    self.__inputs.get_args() +
+                    ensure_binary(fc_args) +
+                    self.__outputs.get_args())
 
-    def add_input(self, input_file: inputs.Input) -> None:
+    def add_input(self, input_file: Input) -> None:
         """ Adds new source to ffmpeg.
+
+        >>> ff = FFMPEG()
+        >>> ff.add_input(Input(input_file="/tmp/input.mp4"))
+        >>>
         """
-        assert isinstance(input_file, inputs.Input)
-        self.__input_list.append(input_file)
+        assert isinstance(input_file, Input)
+        self.__inputs.append(input_file)
 
-    def add_output(self, output: outputs.Output) -> None:
+    def add_output(self, output: Output) -> None:
         """
         Adds output file to ffmpeg and connect it's codecs to free sources.
+
+        >>> ff = FFMPEG()
+        >>> ff.add_output(Output(output_file='/tmp/output.mp4'))
+        >>>
         """
-        self.__output_list.append(output)
+        self.__outputs.append(output)
         for codec in output.codecs:
             self._add_codec(codec)
 
-    def handle_stderr(self, line: str) -> None:
+    def handle_stderr(self, line: str) -> str:
         """
         Handle ffmpeg output.
 
-        Capture only lines containing one of `stderr_markers`
+        Capture only lines containing one of `stderr_markers`.
+
+        :param line: ffmpeg output line
+        :returns: line to be appended to whole ffmpeg output.
         """
         if not self.stderr_markers:
             # if no markers are defined, handle each line
@@ -162,4 +203,5 @@ class FFMPEG(BaseWrapper):
         # capture only lines containing markers
         for marker in self.stderr_markers:
             if marker in line:
-                super().handle_stderr(line)
+                return super().handle_stderr(line)
+        return ''
diff --git a/fffw/encoding/filters.py b/fffw/encoding/filters.py
new file mode 100644
index 0000000..bff1511
--- /dev/null
+++ b/fffw/encoding/filters.py
@@ -0,0 +1,156 @@
+from dataclasses import dataclass
+from typing import Tuple, List, Optional
+
+from fffw.graph import base
+from fffw.wrapper.params import Params, param
+
+__all__ = [
+    'AudioFilter',
+    'VideoFilter',
+    'Scale',
+    'Split',
+    'Concat',
+    'Overlay',
+]
+
+
+@dataclass
+class Filter(base.Node, Params):
+    """
+    Base class for ffmpeg filter definitions.
+
+    `VideoFilter` and `AudioFilter` are used to define new filters.
+    """
+    ALLOWED = ('enabled',)
+    """ fields that are allowed to be modified after filter initialization."""
+
+    @property
+    def args(self) -> str:
+        """ Formats filter args as k=v pairs separated by colon."""
+        args = self.as_pairs()
+        result = []
+        for key, value in args:
+            if key and value:
+                result.append(f'{key}={value}')
+        return ':'.join(result)
+
+
+class VideoFilter(Filter):
+    """
+    Base class for video filters.
+
+    >>> @dataclass
+    ... class Deinterlace(VideoFilter):
+    ...     filter = 'yadif'
+    ...     mode: int = param(default=0)
+    ...
+    >>>
+    """
+    kind = base.VIDEO
+
+
+class AudioFilter(Filter):
+    """
+    Base class for audio filters.
+
+    >>> @dataclass
+    ... class Volume(AudioFilter):
+    ...     filter = 'volume'
+    ...     volume: float = param(default=1.0)
+    ...
+    >>>
+    """
+    kind = base.AUDIO
+
+
+@dataclass
+class Scale(VideoFilter):
+    """ Video scaling filter."""
+    filter = "scale"
+
+    width: int = param(name='w')
+    height: int = param(name='h')
+
+    def as_pairs(self) -> List[Tuple[Optional[str], Optional[str]]]:
+        return super().as_pairs()
+
+
+@dataclass
+class Split(Filter):
+    # noinspection PyUnresolvedReferences
+    """
+    Audio or video split filter.
+
+    Splits audio or video stream to multiple output streams (2 by default).
+    Unlike ffmpeg `split` filter this one does not allow to pass multiple
+    inputs.
+
+    :arg output_count: number of output streams.
+    """
+    kind: base.StreamType
+    output_count: int = 2
+
+    def __post_init__(self) -> None:
+        """
+        Sets filter name from stream kind and disables filter if `output_count`
+        is set to 1 (no real split is preformed).
+        """
+        self.enabled = self.output_count > 1
+        self.filter = 'split' if self.kind == base.VIDEO else 'asplit'
+        super().__post_init__()
+
+    @property
+    def args(self) -> str:
+        """
+        :returns: split/asplit filter parameters
+        """
+        if self.output_count == 2:
+            return ''
+        return str(self.output_count)
+
+
+@dataclass
+class Concat(Filter):
+    # noinspection PyUnresolvedReferences
+    """
+    Concatenates multiple input stream to a single one.
+
+    :arg kind: stream kind (for proper concatenated streams definitions).
+    :arg input_count: number of input streams.
+    """
+    filter = 'concat'
+
+    kind: base.StreamType
+    input_count: int = 2
+
+    def __post_init__(self) -> None:
+        """
+        Disables filter if input_count is 1 (no actual concatenation is
+        performed).
+        """
+        self.enabled = self.input_count > 1
+        super().__post_init__()
+
+    @property
+    def args(self) -> str:
+        if self.kind == base.VIDEO:
+            if self.input_count == 2:
+                return ''
+            return 'n=%s' % self.input_count
+        return 'v=0:a=1:n=%s' % self.input_count
+
+
+@dataclass
+class Overlay(VideoFilter):
+    # noinspection PyUnresolvedReferences
+    """
+    Combines two video streams one on top of another.
+
+    :arg x: horizontal position of top image in bottom one.
+    :arg x: vertical position of top image in bottom one.
+    """
+    input_count = 2
+    filter = "overlay"
+
+    x: int
+    y: int
diff --git a/fffw/graph/__init__.py b/fffw/graph/__init__.py
index 8838a4b..84dd335 100644
--- a/fffw/graph/__init__.py
+++ b/fffw/graph/__init__.py
@@ -1,15 +1,5 @@
 from .base import *
 from .complex import *
-from .filters import *
 from .inputs import *
 from .meta import *
 from .outputs import *
-
-__all__ = (
-        base.__all__ +  # type: ignore
-        complex.__all__ +  # type: ignore
-        filters.__all__ +  # type: ignore
-        inputs.__all__ +  # type: ignore
-        meta.__all__ +  # type: ignore
-        outputs.__all__  # type: ignore
-)
diff --git a/fffw/graph/base.py b/fffw/graph/base.py
index 9300564..368e0bd 100644
--- a/fffw/graph/base.py
+++ b/fffw/graph/base.py
@@ -57,13 +57,8 @@ class Dest(Traversable):
 
     Must connect to single filter output only.
     """
-
-    def __init__(self, kind: StreamType) -> None:
-        """
-        :param kind: stream kind (VIDEO/AUDIO)
-        """
-        self._edge: Optional[Edge] = None
-        self._kind = kind
+    kind: StreamType
+    _edge: Optional["Edge"] = None
 
     def __repr__(self) -> str:
         return f"Dest('{self.name}')"
@@ -77,10 +72,6 @@ class Dest(Traversable):
             raise RuntimeError("Dest not connected")
         return f'[{self._edge.name}]'
 
-    @property
-    def kind(self) -> StreamType:
-        return self._kind
-
     @property
     def meta(self) -> Optional[Meta]:
         return self.get_meta_data(self)
@@ -209,19 +200,15 @@ class Edge(Traversable):
         return self.__output.render(partial=partial)
 
 
-class Node(Traversable):
+class Node(Traversable, abc.ABC):
     """ Graph node describing ffmpeg filter."""
-
+    # Should be overridden in derived classes
     kind: StreamType  # filter type (VIDEO/AUDIO)
     filter: str  # filter name
+
     input_count: int = 1  # number of inputs
     output_count: int = 1  # number of outputs
 
-    def __init__(self, enabled: bool = True):
-        self.enabled = enabled
-        self.inputs: List[Optional[Edge]] = [None] * self.input_count
-        self.outputs: List[Optional[Edge]] = [None] * self.output_count
-
     def __repr__(self) -> str:
         inputs = [f"[{str(i.name if i else '---')}]" for i in self.inputs]
         outputs = [f"[{str(i.name if i else '---')}]" for i in self.outputs]
@@ -246,23 +233,39 @@ class Node(Traversable):
             return NotImplemented
         return self.connect_dest(other)
 
+    @property
+    @abc.abstractmethod
+    def args(self) -> str:
+        raise NotImplementedError()
+
+    @property
+    def inputs(self) -> List[Optional[Edge]]:
+        """
+        :returns: list of placeholders for input edges.
+        """
+        if 'inputs' not in self.__dict__:
+            self.__dict__['inputs'] = [None] * self.input_count
+        return self.__dict__['inputs']
+
+    @property
+    def outputs(self) -> List[Optional[Edge]]:
+        """
+        :returns: list of placeholders for output edges.
+        """
+        if 'outputs' not in self.__dict__:
+            self.__dict__['outputs'] = [None] * self.output_count
+        return self.__dict__['outputs']
+
     @property
     def enabled(self) -> bool:
-        return self.__enabled
+        return self.__dict__.get('enabled', True)
 
     @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
-    def args(self) -> str:
-        """
-        Generates filter params as a string
-        """
-        return ''
+        self.__dict__['enabled'] = value
 
     # noinspection PyMethodMayBeStatic
     def transform(self, *metadata: Meta) -> Meta:
diff --git a/fffw/graph/complex.py b/fffw/graph/complex.py
index 9ffe1c0..0a4201b 100644
--- a/fffw/graph/complex.py
+++ b/fffw/graph/complex.py
@@ -20,18 +20,6 @@ class FilterComplex:
         self.__input_list = input_list
         self.__output_list = output_list
 
-    @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
diff --git a/fffw/graph/filters.py b/fffw/graph/filters.py
deleted file mode 100644
index c09fa11..0000000
--- a/fffw/graph/filters.py
+++ /dev/null
@@ -1,74 +0,0 @@
-from fffw.graph import base
-from fffw.graph.base import VIDEO
-
-__all__ = [
-    'Scale',
-    'Split',
-    'Concat',
-    'Overlay',
-]
-
-
-class Scale(base.Node):
-    kind = VIDEO
-    filter = "scale"
-
-    def __init__(self, width: int, height: int, enabled: bool = True):
-        super(Scale, self).__init__(enabled=enabled)
-        self.width = int(width)
-        self.height = int(height)
-
-    @property
-    def args(self) -> str:
-        return "%sx%s" % (self.width, self.height)
-
-
-class Split(base.Node):
-
-    def __init__(self, kind: base.StreamType = VIDEO, output_count: int = 2):
-        enabled = output_count > 1
-        self.output_count = output_count
-        self.kind = kind
-        self.filter = 'split' if kind == VIDEO else 'asplit'
-        super(Split, self).__init__(enabled=enabled)
-
-    @property
-    def args(self) -> str:
-        if self.output_count == 2:
-            return ''
-        return '%s' % self.output_count
-
-
-class Concat(base.Node):
-    filter = 'concat'
-
-    def __init__(self, kind: base.StreamType = VIDEO, input_count: int = 2):
-        enabled = input_count > 1
-        self.input_count = input_count
-        self.kind = kind
-        super(Concat, self).__init__(enabled=enabled)
-
-    @property
-    def args(self) -> str:
-        if self.kind == VIDEO:
-            if self.input_count == 2:
-                return ''
-            return 'n=%s' % self.input_count
-        return 'v=0:a=1:n=%s' % self.input_count
-
-
-class Overlay(base.Node):
-    kind = VIDEO
-    input_count = 2
-    filter = "overlay"
-
-    def __init__(self, left: int, top: int, enabled: bool = True):
-        super(Overlay, self).__init__(enabled=enabled)
-        self.left = int(left)
-        self.top = int(top)
-
-    @property
-    def args(self) -> str:
-        return "x=%s:y=%s" % (self.left, self.top)
-
-
diff --git a/fffw/graph/inputs.py b/fffw/graph/inputs.py
index 22628d9..fc5203a 100644
--- a/fffw/graph/inputs.py
+++ b/fffw/graph/inputs.py
@@ -1,14 +1,15 @@
-from typing import Optional, List, Tuple, cast, Any
+from dataclasses import dataclass
+from typing import Optional, List, Tuple, cast, Iterable, Union, Any
 
 from fffw.graph import base
-from fffw.graph.meta import Meta
-from fffw.wrapper import BaseWrapper, ensure_binary
+from fffw.graph.meta import Meta, TS
+from fffw.wrapper import BaseWrapper, param
 
 __all__ = [
     'Input',
     'InputList',
     'Stream',
-    'Input',
+    'input_file',
 ]
 
 
@@ -33,21 +34,43 @@ class Stream(base.Source):
         return f'{self.source.index}:{self._kind.value}:{self.index}'
 
 
+def default_streams() -> Tuple[Stream, ...]:
+    """
+    Generates default streams definition for input file
+
+    :returns: a tuple with one video and one audio stream.
+    """
+    return Stream(base.VIDEO), Stream(base.AUDIO)
+
+
+@dataclass
 class Input(BaseWrapper):
+    # noinspection PyUnresolvedReferences
     """
     Input command line params generator for FFMPEG.
+
+    :arg fast_seek: seek input file over key frames
+    :arg input_file: input file name
+    :arg slow_seek: perform whole file decoding and output frames only
+        from offset to end of file.
+    :arg duration: stop decoding frames after an interval
     """
-    """ Filename or url, value for `-i` argument."""
-    streams = cast(Tuple[Stream, ...], base.Once("streams"))
-    """ List of audio and video streams for input file."""
     index = cast(int, base.Once("index"))
     """ Internal ffmpeg source file index."""
+    streams: Tuple[Stream, ...] = param(default=default_streams, skip=True)
+    """ List of audio and video streams for input file."""
 
-    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
+    fast_seek: Union[TS, float, int] = param(name='ss')
+    input_file: str = param(name='i')
+    slow_seek: Union[TS, float, int] = param(name='ss')
+    duration: Union[TS, float, int] = param(name='t')
+
+    def __post_init__(self) -> None:
+        """
+        Enumerate streams in input file and freeze instance.
+        """
         self.__link_streams_to_input()
+        super().__post_init__()
 
     def __link_streams_to_input(self) -> None:
         """
@@ -69,29 +92,28 @@ class Input(BaseWrapper):
                 raise ValueError(stream.kind)
             stream.source = self
 
-    @ensure_binary
-    def get_args(self) -> List[Any]:
-        return ["-i", self.input_file]
 
+def input_file(filename: str, *streams: Stream, **kwargs: Any) -> Input:
+    kwargs['input_file'] = filename
+    if streams:
+        kwargs['streams'] = streams
+    return Input(**kwargs)
 
-class InputList:
+
+class InputList(list):
     """ List of inputs in FFMPEG."""
 
-    def __init__(self, *sources: Input) -> None:
+    def __init__(self, sources: Iterable[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)
+        super().__init__()
+        self.extend(sources)
 
     @property
     def streams(self) -> List[Stream]:
         result: List[Stream] = []
-        for source in self.__inputs:
+        for source in self:
             if source.streams is None:
                 raise RuntimeError("Source streams not initialized")
             result.extend(source.streams)
@@ -103,21 +125,21 @@ class InputList:
 
         :param source: input file
         """
-        source.index = len(self.__inputs)
-        self.__inputs.append(source)
+        source.index = len(self)
+        super().append(source)
 
-    def extend(self, *sources: Input) -> None:
+    def extend(self, sources: Iterable[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)):
+        for i, source in enumerate(sources, start=len(self)):
             source.index = i
-        self.__inputs.extend(sources)
+        super().extend(sources)
 
     def get_args(self) -> List[bytes]:
         result: List[bytes] = []
-        for source in self.__inputs:
+        for source in self:
             result.extend(source.get_args())
         return result
diff --git a/fffw/graph/meta.py b/fffw/graph/meta.py
index d62b8a3..9f56bf2 100644
--- a/fffw/graph/meta.py
+++ b/fffw/graph/meta.py
@@ -23,6 +23,11 @@ class TS(timedelta):
     """
 
     def __new__(cls, value: Union[int, float, str]) -> "TS":
+        """
+        :param value: integer duration in milliseconds, float duration in
+            seconds or string ffmpeg interval definition (123:59:59.999).
+        :returns new timestamp from value.
+        """
         if isinstance(value, int):
             value = value / 1000.0
         elif isinstance(value, str):
@@ -38,6 +43,17 @@ class TS(timedelta):
             value = seconds + fractional
         return super().__new__(cls, seconds=value)  # type: ignore
 
+    def __str__(self) -> str:
+        """
+        Removes non-valuable zeros from fractional part.
+
+        :returns: ffmpeg interval definition (123:59:59.999).
+        """
+        v = super().__str__()
+        if '.' in v:
+            v = v.rstrip('0')
+        return v
+
 
 @dataclass
 class Meta:
diff --git a/fffw/graph/outputs.py b/fffw/graph/outputs.py
index 2977aa4..71db3a9 100644
--- a/fffw/graph/outputs.py
+++ b/fffw/graph/outputs.py
@@ -1,31 +1,38 @@
-import os
+from dataclasses import dataclass
 from itertools import chain
-from typing import List, Tuple, cast, Optional, Any
+from typing import List, cast, Optional, Iterable, Any
 
 from fffw.graph import base
-from fffw.wrapper import BaseWrapper, ensure_binary
+from fffw.wrapper import BaseWrapper, ensure_binary, param
 
 __all__ = [
     'Codec',
     'Output',
-    'OutputList'
+    'OutputList',
+    'output_file',
 ]
 
 
+@dataclass
 class Codec(base.Dest, BaseWrapper):
+    # noinspection PyUnresolvedReferences
+    """
+    Base class for output codecs.
+
+    :arg codec: ffmpeg codec name.
+    :arg bitrate: output bitrate in bps.
+    """
+
     index = cast(int, base.Once('index'))
     """ Index of current codec in ffmpeg output streams."""
-    # TODO #9: implement single argument with stream type modifier
-    arguments = [
-        ('vbitrate', '-b:v '),
-        ('abitrate', '-b:a '),
-    ]
-
-    def __init__(self, kind: base.StreamType, codec: str = None,
-                 **kwargs: Any) -> None:
-        super().__init__(kind)
-        BaseWrapper.__init__(self, **kwargs)
-        self._codec = codec
+
+    codec: str = param(name='c', stream_suffix=True)
+    bitrate: int = param(default=0, name='b', stream_suffix=True)
+
+    def __post_init__(self) -> None:
+        if self.codec is None:
+            self.codec = self.__class__.codec
+        super().__post_init__()
 
     @property
     def map(self) -> Optional[str]:
@@ -50,22 +57,22 @@ class Codec(base.Dest, BaseWrapper):
 
     def get_args(self) -> List[bytes]:
         args = ['-map', self.map]
-        if self._codec:
-            args.extend([f'-c:{self.kind.value}', self._codec])
         return ensure_binary(args) + super().get_args()
 
 
+@dataclass
 class Output(BaseWrapper):
-    arguments = [
-        ('format', '-f '),
-    ]
-
-    def __init__(self, output_file: str, *codecs: Codec, **kwargs: Any) -> None:
-        ext = os.path.splitext(output_file)[-1].lstrip('.')
-        kwargs.setdefault('format', ext)
-        super().__init__(**kwargs)
-        self._output_file = output_file
-        self._codecs = list(codecs)
+    # noinspection PyUnresolvedReferences
+    """
+    Base class for ffmpeg output.
+
+    :arg codecs: list of codecs used in output.
+    :arg format: output file format.
+    :arg output_file: output file name.
+    """
+    codecs: List[Codec] = param(skip=True)
+    format: str = param(name="f")
+    output_file: str = param(name="")
 
     def __lt__(self, other: base.InputType) -> Codec:
         """
@@ -77,55 +84,78 @@ class Output(BaseWrapper):
         other.connect_dest(codec)
         return codec
 
-    @property
-    def codecs(self) -> Tuple[Codec, ...]:
-        return tuple(self._codecs)
-
     @property
     def video(self) -> Codec:
+        """
+        :returns: first video codec not connected to source.
+
+        If no free codecs left, new one codec stub is appended to output.
+        """
         return self.get_free_codec(base.VIDEO)
 
     @property
     def audio(self) -> Codec:
+        """
+        :returns: first audio codec not connected to source.
+
+        If no free codecs left, new one codec stub is appended to output.
+        """
         return self.get_free_codec(base.AUDIO)
 
     def get_free_codec(self, kind: base.StreamType) -> Codec:
+        """
+        Finds first codec not connected to filter graph or to an input, or
+        creates a new unnamed codec stub if no free codecs left.
+
+        :param kind: desired codec stream type
+        :return: first free codec or a new codec stub.
+        """
         try:
-            codec = next(filter(lambda c: not c.connected, self._codecs))
+            codec = next(filter(lambda c: not c.connected, self.codecs))
         except StopIteration:
-            codec = Codec(kind)
-            self._codecs.append(codec)
+            codec = Codec()
+            codec.kind = kind
+            self.codecs.append(codec)
         return codec
 
     def get_args(self) -> List[bytes]:
+        """
+        :returns: codec args and output file parameters for ffmpeg
+        """
         args = (
-                list(chain(*(codec.get_args() for codec in self._codecs))) +
-                super().get_args() +
-                ensure_binary([self._output_file])
+                list(chain(*(codec.get_args() for codec in self.codecs))) +
+                super().get_args()
         )
         return args
 
 
-class OutputList:
+def output_file(filename: str, *codecs: Codec, **kwargs: Any) -> Output:
+    """
+    A shortcut to create proper output file.
+    :param filename: output file name.
+    :param codecs: codec list for this output.
+    :param kwargs: output parameters.
+    :return: configured ffmpeg output.
+    """
+    return Output(output_file=filename, codecs=list(codecs), **kwargs)
+
+
+class OutputList(list):
     """ Supports unique output streams names generation."""
 
-    def __init__(self, *outputs: Output) -> None:
+    def __init__(self, outputs: Iterable[Output] = ()) -> None:
         """
         :param outputs: list of output files
         """
-        self.__outputs: List[Output] = []
+        super().__init__()
         self.__video_index = 0
         self.__audio_index = 0
-        self.extend(*outputs)
-
-    @property
-    def outputs(self) -> Tuple[Output, ...]:
-        return tuple(self.__outputs)
+        self.extend(outputs)
 
     @property
     def codecs(self) -> List[Codec]:
         result: List[Codec] = []
-        for output in self.__outputs:
+        for output in self:
             result.extend(output.codecs)
         return result
 
@@ -137,9 +167,9 @@ class OutputList:
         """
         for codec in output.codecs:
             self.__set_index(codec)
-        self.__outputs.append(output)
+        super().append(output)
 
-    def extend(self, *outputs: Output) -> None:
+    def extend(self, outputs: Iterable[Output]) -> None:
         """
         Adds multiple output files to output list.
 
@@ -147,11 +177,11 @@ class OutputList:
         """
         for codec in chain(*map(lambda output: output.codecs, outputs)):
             self.__set_index(codec)
-        self.__outputs.extend(outputs)
+        super().extend(outputs)
 
     def get_args(self) -> List[bytes]:
-        result = []
-        for source in self.__outputs:
+        result: List[bytes] = []
+        for source in self:
             result.extend(source.get_args())
         return result
 
diff --git a/fffw/wrapper.py b/fffw/wrapper.py
deleted file mode 100644
index 328495a..0000000
--- a/fffw/wrapper.py
+++ /dev/null
@@ -1,187 +0,0 @@
-import re
-import subprocess
-from functools import wraps
-from logging import getLogger
-from typing import Tuple, List, Any, Dict, Union, overload, IO, cast, Callable
-
-
-def quote(token: Any) -> str:
-    """ Escapes a token for command line."""
-    token = ensure_text(token)
-    if re.search(r'[ ()[\];]', token):
-        return '"%s"' % token.replace('\"', '\\"')
-    return token
-
-
-@overload
-def ensure_binary(x: Tuple[Any, ...]) -> Tuple[bytes, ...]:
-    ...
-
-
-@overload
-def ensure_binary(x: List[Any]) -> List[bytes]:
-    ...
-
-
-@overload
-def ensure_binary(x: Union[int, float, str, bytes, None]) -> bytes:
-    ...
-
-
-@overload
-def ensure_binary(x: Callable[..., Tuple[Any]]
-                  ) -> Callable[..., Tuple[bytes]]:
-    ...
-
-
-@overload
-def ensure_binary(x: Callable[..., List[Any]]
-                  ) -> Callable[..., List[bytes]]:
-    ...
-
-
-@overload
-def ensure_binary(x: Callable[..., Union[int, float, str, bytes, None]]
-                  ) -> Callable[..., bytes]:
-    ...
-
-
-def ensure_binary(x: Any) -> Any:
-    """ Recursively ensures that all values except collections are bytes.
-
-    * tuples and lists are encoded recursively
-    * strings are encoded with utf-8
-    * bytes are left intact
-    * functions are decorated with ensuring binary results
-    * all other types are converted to string and encoded
-    """
-    if isinstance(x, tuple):
-        return tuple(ensure_binary(y) for y in x)
-    if isinstance(x, list):
-        return list(ensure_binary(y) for y in x)
-    if isinstance(x, str):
-        return x.encode("utf-8")
-    if callable(x):
-        @wraps(x)
-        def wrapper(*args: Any, **kwargs: Any) -> Any:
-            return ensure_binary(x(*args, **kwargs))
-
-        return wrapper
-    if not isinstance(x, bytes):
-        return str(x).encode("utf-8")
-    return x
-
-
-@overload
-def ensure_text(x: Tuple[Any, ...]) -> Tuple[str, ...]:
-    ...
-
-
-@overload
-def ensure_text(x: List[Any]) -> List[str]:
-    ...
-
-
-@overload
-def ensure_text(x: Union[int, float, str, bytes, None]) -> str:
-    ...
-
-
-def ensure_text(x: Any) -> Any:
-    """ Recursively ensures that all values except collections are strings."""
-    if isinstance(x, tuple):
-        return tuple(ensure_text(y) for y in x)
-    if isinstance(x, list):
-        return list(ensure_text(y) for y in x)
-    if isinstance(x, bytes):
-        return x.decode("utf-8")
-    return str(x)
-
-
-class BaseWrapper:
-    """
-    Base class for generating command line arguments from params.
-
-    Values meanings:
-    * True: flag presense
-    * False/None: flag absense
-    * List/Tuple: param name is repeated multiple times with values
-    * Callable: function call result is added to result
-    * All other: param name and value are added to result
-    """
-    arguments: List[Tuple[str, str]] = []
-
-    def __init_args(self) -> None:
-        self._key_mapping = {}
-        self._args: Dict[str, Any] = {}
-        self._args_order = []
-        for (name, key) in self.arguments:
-            self._key_mapping[name] = key
-            self._args[name] = None
-            self._args_order.append(name)
-
-    def __init__(self, **kw: Any):
-        self.__init_args()
-        for k, v in kw.items():
-            setattr(self, k, v)
-        self._output = ''
-        cls = self.__class__
-        self.logger = getLogger("%s.%s" % (cls.__module__, cls.__name__))
-
-    def __setattr__(self, key: str, value: Any) -> None:
-        if key in getattr(self, '_key_mapping', {}):
-            self._args[key] = value
-        else:
-            object.__setattr__(self, key, value)
-
-    @ensure_binary
-    def get_args(self) -> List[Any]:
-        result = []
-        for k in self._args_order:
-            v = self._args[k]
-            if v is not None and v is not False:
-                param = self._key_mapping[k]
-                if callable(v):
-                    value = v()
-                    if isinstance(value, list):
-                        result.extend(value)
-                    else:
-                        result.append(value)
-                elif isinstance(v, list):
-                    for item in v:
-                        result.extend([param.strip(), item])
-                elif v is True:
-                    result.append(param.strip())
-                else:
-                    if param.endswith(' '):
-                        result.extend([param.strip(), str(v)])
-                    else:
-                        result.append("%s%s" % (param, v))
-
-        return result
-
-    def set_args(self, **kwargs: Any) -> None:
-        for k, v in kwargs.items():
-            setattr(self, k, v)
-
-    def get_cmd(self) -> str:
-        return ' '.join(map(quote, ensure_text(self.get_args())))
-
-    def handle_stderr(self, line: str) -> None:
-        self.logger.debug(line.strip())
-        self._output += line
-
-    def run(self) -> Tuple[int, str]:
-        self._output = ''
-        self.logger.info(self.get_cmd())
-        args = self.get_args()
-
-        with subprocess.Popen(args, stderr=subprocess.PIPE) as proc:
-            while True:
-                stderr = cast(IO[bytes], proc.stderr)
-                line = stderr.readline()
-                if not line:
-                    break
-                self.handle_stderr(ensure_text(line))
-        self.logger.info("%s return code is %s", args[0], proc.returncode)
-        return proc.returncode, self._output
diff --git a/fffw/wrapper/__init__.py b/fffw/wrapper/__init__.py
new file mode 100644
index 0000000..65a37e3
--- /dev/null
+++ b/fffw/wrapper/__init__.py
@@ -0,0 +1,9 @@
+from .base import BaseWrapper
+from .helpers import ensure_binary
+from .params import param
+
+__all__ = [
+    'BaseWrapper',
+    'ensure_binary',
+    'param'
+]
diff --git a/fffw/wrapper/base.py b/fffw/wrapper/base.py
new file mode 100644
index 0000000..5260baa
--- /dev/null
+++ b/fffw/wrapper/base.py
@@ -0,0 +1,58 @@
+import io
+import subprocess
+from dataclasses import dataclass
+from logging import getLogger
+from typing import Tuple, List, Any, IO, cast, Optional
+
+from fffw.wrapper.helpers import quote, ensure_binary, ensure_text
+from fffw.wrapper.params import Params
+
+
+@dataclass
+class BaseWrapper(Params):
+    """
+    Base class for generating command line arguments from params.
+
+    Values meanings:
+    * True: flag presence
+    * False/None: flag absence
+    * List/Tuple: param name is repeated multiple times with values
+    * Callable: function call result is added to result
+    * All other: param name and value are added to result
+    """
+
+    def __post_init__(self) -> None:
+        cls = self.__class__
+        self.logger = getLogger("%s.%s" % (cls.__module__, cls.__name__))
+
+    @ensure_binary
+    def get_args(self) -> List[Any]:
+        args: List[str] = []
+        for key, value in self.as_pairs():
+            if key:
+                args.append(f'-{key}')
+            if value:
+                args.append(value)
+        return args
+
+    def get_cmd(self) -> str:
+        return ' '.join(map(quote, ensure_text(self.get_args())))
+
+    def handle_stderr(self, line: str) -> str:
+        self.logger.debug(line.strip())
+        return line
+
+    def run(self) -> Tuple[int, str]:
+        output = io.StringIO()
+        self.logger.info(self.get_cmd())
+        args = self.get_args()
+
+        with subprocess.Popen(args, stderr=subprocess.PIPE) as proc:
+            while True:
+                stderr = cast(IO[bytes], proc.stderr)
+                line = stderr.readline()
+                if not line:
+                    break
+                output.write(self.handle_stderr(ensure_text(line)))
+        self.logger.info("%s return code is %s", args[0], proc.returncode)
+        return proc.returncode, output.getvalue()
diff --git a/fffw/wrapper/helpers.py b/fffw/wrapper/helpers.py
new file mode 100644
index 0000000..25caaa5
--- /dev/null
+++ b/fffw/wrapper/helpers.py
@@ -0,0 +1,91 @@
+import re
+from functools import wraps
+from typing import Any, overload, List, Union, Callable, Tuple
+
+
+def quote(token: Any) -> str:
+    """ Escapes a token for command line."""
+    token = ensure_text(token)
+    if re.search(r'[ ()[\];]', token):
+        return '"%s"' % token.replace('\"', '\\"')
+    return token
+
+
+@overload
+def ensure_binary(x: List[Any]) -> List[bytes]:
+    ...
+
+
+@overload
+def ensure_binary(x: Union[int, float, str, bytes, None]) -> bytes:
+    ...
+
+
+@overload
+def ensure_binary(x: Callable[..., Tuple[Any]]
+                  ) -> Callable[..., Tuple[bytes]]:
+    ...
+
+
+@overload
+def ensure_binary(x: Callable[..., List[Any]]
+                  ) -> Callable[..., List[bytes]]:
+    ...
+
+
+@overload
+def ensure_binary(x: Callable[..., Union[int, float, str, bytes, None]]
+                  ) -> Callable[..., bytes]:
+    ...
+
+
+def ensure_binary(x: Any) -> Any:
+    """ Recursively ensures that all values except collections are bytes.
+
+    * tuples and lists are encoded recursively
+    * strings are encoded with utf-8
+    * bytes are left intact
+    * functions are decorated with ensuring binary results
+    * all other types are converted to string and encoded
+    """
+    if isinstance(x, tuple):
+        return tuple(ensure_binary(y) for y in x)
+    if isinstance(x, list):
+        return list(ensure_binary(y) for y in x)
+    if isinstance(x, str):
+        return x.encode("utf-8")
+    if callable(x):
+        @wraps(x)
+        def wrapper(*args: Any, **kwargs: Any) -> Any:
+            return ensure_binary(x(*args, **kwargs))
+
+        return wrapper
+    if not isinstance(x, bytes):
+        return str(x).encode("utf-8")
+    return x
+
+
+@overload
+def ensure_text(x: Tuple[Any, ...]) -> Tuple[str, ...]:
+    ...
+
+
+@overload
+def ensure_text(x: List[Any]) -> List[str]:
+    ...
+
+
+@overload
+def ensure_text(x: Union[int, float, str, bytes, None]) -> str:
+    ...
+
+
+def ensure_text(x: Any) -> Any:
+    """ Recursively ensures that all values except collections are strings."""
+    if isinstance(x, tuple):
+        return tuple(ensure_text(y) for y in x)
+    if isinstance(x, list):
+        return list(ensure_text(y) for y in x)
+    if isinstance(x, bytes):
+        return x.decode("utf-8")
+    return str(x)
diff --git a/fffw/wrapper/params.py b/fffw/wrapper/params.py
new file mode 100644
index 0000000..5fe4554
--- /dev/null
+++ b/fffw/wrapper/params.py
@@ -0,0 +1,119 @@
+from dataclasses import field, dataclass, Field, fields
+from typing import Any, Optional, Tuple, cast, List
+
+
+def param(default: Any = None, name: Optional[str] = None,
+          stream_suffix: bool = False, init: bool = True, skip: bool = False
+          ) -> Any:
+    """
+    Command line and filter parameters constructor.
+
+    :param default: default value for parameter, or a callable (see dataclass
+        field default_factory)
+    :param name: command line argument name
+    :param stream_suffix: flag to add stream specifier suffix to parameter name
+        (like '-b:v' or '-c:a')
+    :param init: add parameter to dataclass constructor.
+    :param skip: skip parameter while generating command line args list.
+    :return: dataclass field definition with extra metadata.
+
+    >>> from fffw.encoding.filters import VideoFilter
+    >>> @dataclass
+    ... class Deinterlace(VideoFilter):
+    ...     filter = 'yadif'
+    ...     mode: int = param(default=0)
+    ...
+    >>>
+    """
+    metadata = {
+        'name': name,
+        'stream_suffix': stream_suffix,
+        'skip': skip,
+    }
+    if callable(default):
+        return field(default_factory=default, init=init, metadata=metadata)
+    else:
+        return field(default=default, init=init, metadata=metadata)
+
+
+_FROZEN = '__frozen__'
+
+
+@dataclass
+class Params:
+    """ Base class for parametrized objects."""
+    ALLOWED = cast(Tuple[str], tuple())
+    """ 
+    List of attributes that are allowed to be set after instance initialization.
+    """
+
+    def __post_init__(self) -> None:
+        """ Marks class instance as frozen."""
+        setattr(self, _FROZEN, True)
+
+    def __setattr__(self, key: str, value: Any) -> None:
+        """ If class is frozen, forbids instance attributes modification."""
+        frozen = getattr(self, _FROZEN, False)
+        allowed = self.ALLOWED
+        if frozen and key not in allowed:
+            raise RuntimeError("Parameters are frozen")
+        object.__setattr__(self, key, value)
+
+    def as_pairs(self) -> List[Tuple[Optional[str], Optional[str]]]:
+        """
+        :return: a list or pairs (key, value), where key is optional ffmpeg
+        parameter name and value is parameter value
+
+        * parameters are all dataclass fields without `skip` flag.
+        * by default field name is used as ffmpeg parameter name, but this
+          can be overridden by `name` metadata field.
+        * if `name` is empty, key is omitted.
+        * if `stream_suffix` metadata flag is set, `:v` or `:a` modifier is
+          appended to key depending of current object stream kind.
+        * if value is callable, real value is computed via function call
+        * if value is iterable, corresponding parameter is added to a result
+          multiple times
+        * if value is `True`, parameter is added as a flag without a value
+        """
+        args = cast(List[Tuple[Optional[str], Optional[str]]], [])
+        for f in fields(self):  # type: Field
+            key = f.name
+            value = getattr(self, key)
+            if f.default == value and f.init:
+                # if field value has default value and is configurable via
+                # __init__, we omit this field
+                continue
+            if not value:
+                # if value is not set, we omit this field
+                continue
+
+            meta = f.metadata
+            name = meta.get('name')
+            stream_suffix = meta.get('stream_suffix')
+            skip = meta.get('skip')
+            if skip:
+                # if field metadata is marked as `skip`
+                continue
+            if name is None:
+                # by default field name is used as parameter name
+                name = key
+            if stream_suffix:
+                # append stream suffix (':v' or ':a') to parameter name
+                name = f'{name}:{getattr(self, "kind").value}'
+            # support position command line arguments
+
+            if callable(value):
+                # get lazy parameter value
+                value = value()
+
+            if isinstance(value, (list, tuple)):
+                # output multiple parameter values (-i file1 -i file2 ...)
+                args.extend((name, str(v)) for v in value)
+            elif value is True:
+                # support flag without a value
+                assert name
+                args.append((name, None))
+            else:
+                # output parameter name and value converted to string
+                args.append((name, str(value)))
+        return args

Use dataclasses for parameterizable object
Filters, codecs and command line wrappers should declare params with type information and default values and should have proper constructor.  `@dataclass` is the best choice to achieve this and is supported by mypy.

- [x] Filter parameters
- [x] Codec arguments
- [x] Input arguments
- [x] Output arguments
- [x] FFMPEG arguments

diff --git a/tests/test_encoding.py b/tests/test_encoding.py
index d86f302..24a3655 100644
--- a/tests/test_encoding.py
+++ b/tests/test_encoding.py
@@ -12,14 +12,14 @@ class InputsTestCase(TestCase):
         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)
+        self.i1 = inputs.Input(streams=(self.v1, self.a1))
+        self.i2 = inputs.Input(streams=(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)
+        il = inputs.InputList((self.i1, self.i2))
+        self.assertEqual(il[0].index, 0)
+        self.assertEqual(il[1].index, 1)
         self.assertEqual(self.v1.name, '0:v')
         self.assertEqual(self.a1.name, '0:a')
         self.assertEqual(self.v2.name, '1:v')
@@ -46,7 +46,7 @@ class InputsTestCase(TestCase):
         il = inputs.InputList()
         v3 = inputs.Stream(StreamType.VIDEO)
 
-        il.append(inputs.Input(v3))
+        il.append(inputs.Input(streams=(v3,)))
 
         self.assertEqual(v3.name, '0:v')
 
@@ -55,5 +55,6 @@ class InputsTestCase(TestCase):
         Stream without proper StreamType can't be added to input.
         """
         # noinspection PyTypeChecker
-        self.assertRaises(ValueError, inputs.Input, inputs.Stream(kind=None),
+        self.assertRaises(ValueError, inputs.Input,
+                          streams=(inputs.Stream(kind=None),),
                           input_file='input.mp4')
diff --git a/tests/test_ffmpeg.py b/tests/test_ffmpeg.py
index 1ca86b3..12285fc 100644
--- a/tests/test_ffmpeg.py
+++ b/tests/test_ffmpeg.py
@@ -1,31 +1,40 @@
+from dataclasses import dataclass
 from unittest import TestCase, expectedFailure
 
-from fffw.encoding import *
 from fffw.graph import *
-from fffw.graph import base, VIDEO, AUDIO
-from fffw.wrapper import ensure_binary
+from fffw.encoding import filters, codecs, ffmpeg
+from fffw.wrapper import ensure_binary, param
 
 
-class SetSAR(base.Node):
-    kind = VIDEO
-    filter = "setsar"
+@dataclass
+class FFMPEG(ffmpeg.FFMPEG):
+    realtime: bool = param(name='re')
+
+
+@dataclass
+class X264(codecs.VideoCodec):
+    codec = 'libx264'
+
 
-    def __init__(self, sar: float, enabled: bool = True):
-        super(SetSAR, self).__init__(enabled=enabled)
-        self.sar = sar
+@dataclass
+class AAC(codecs.AudioCodec):
+    codec = 'aac'
+
+
+@dataclass
+class SetSAR(filters.VideoFilter):
+    filter = "setsar"
+    sar: float
 
     @property
     def args(self) -> str:
         return "%s" % self.sar
 
 
-class Volume(base.Node):
-    kind = AUDIO
+@dataclass
+class Volume(filters.AudioFilter):
     filter = 'volume'
-
-    def __init__(self, volume: float, enabled: bool = True):
-        super(Volume, self).__init__(enabled=enabled)
-        self.volume = volume
+    volume: float
 
     @property
     def args(self) -> str:
@@ -36,42 +45,44 @@ class FFMPEGTestCase(TestCase):
 
     def test_ffmpeg(self):
         """ Smoke test and feature demo."""
-        ff = FFMPEG()
-        ff < Input(input_file='/tmp/input.mp4')
+        ff = FFMPEG(loglevel='info', realtime=True)
+        ff < input_file('/tmp/input.mp4',
+                        fast_seek=123.2,
+                        duration=TS(123.2))
 
-        cv0 = Codec(VIDEO, 'libx264', vbitrate='700000')
-        ca0 = Codec(AUDIO, 'aac', abitrate='128000')
-        ca1 = Codec(AUDIO, 'libmp3lame', abitrate='394000')
+        cv0 = X264(bitrate=700000)
+        ca0 = AAC(bitrate=128000)
+        ca1 = codecs.AudioCodec('libmp3lame', bitrate=394000)
 
-        fc = ff.init_filter_complex()
+        asplit = ff.audio | filters.Split(AUDIO)
 
-        asplit = fc.audio | Split(AUDIO)
-
-        fc.video | Scale(640, 360) > cv0
+        ff.video | filters.Scale(640, 360) > cv0
 
         asplit.connect_dest(ca0)
         asplit.connect_dest(ca1)
 
-        out0 = Output('/tmp/out.flv', cv0, ca0)
-        out1 = Output('/tmp/out.mp3', ca1)
+        out0 = output_file('/tmp/out.flv', cv0, ca0)
+        out1 = output_file('/tmp/out.mp3', ca1)
 
         ff.add_output(out0)
         ff.add_output(out1)
 
         expected = [
             'ffmpeg',
+            '-loglevel', 'info',
+            '-re',
+            '-ss', '123.2',
             '-i', '/tmp/input.mp4',
+            '-t', '0:02:03.2',
             '-filter_complex',
-            '[0:v]scale=640x360[vout0];[0:a]asplit[aout0][aout1]',
+            '[0:v]scale=w=640:h=360[vout0];[0:a]asplit[aout0][aout1]',
 
             '-map', '[vout0]', '-c:v', 'libx264', '-b:v', '700000',
             '-map', '[aout0]', '-c:a', 'aac', '-b:a', '128000',
 
-            '-f', 'flv',
             '/tmp/out.flv',
 
             '-map', '[aout1]', '-c:a', 'libmp3lame', '-b:a', '394000',
-            '-f', 'mp3',
             '/tmp/out.mp3'
         ]
         self.assertEqual(ff.get_args(), ensure_binary(expected))
@@ -79,22 +90,20 @@ class FFMPEGTestCase(TestCase):
     def test_bypass_with_filter_complex(self):
         """ Audio stream bypass mode."""
         ff = FFMPEG('/tmp/input.mp4')
-        cv0 = Codec(VIDEO, 'libx264', vbitrate='700000', size='640x360')
-        ca0 = Codec(AUDIO, 'aac', abitrate='128000')
+        cv0 = X264(bitrate=700000)
+        ca0 = AAC(bitrate=128000)
 
-        fc = ff.init_filter_complex()
-        fc.video | Scale(640, 360) > cv0
+        ff.video | filters.Scale(640, 360) > cv0
 
-        ff > Output('/tmp/out.flv', cv0, ca0)
+        ff > output_file('/tmp/out.flv', cv0, ca0)
 
         expected = [
             'ffmpeg',
             '-i', '/tmp/input.mp4',
             '-filter_complex',
-            '[0:v]scale=640x360[vout0]',
+            '[0:v]scale=w=640:h=360[vout0]',
             '-map', '[vout0]', '-c:v', 'libx264', '-b:v', '700000',
             '-map', '0:a', '-c:a', 'aac', '-b:a', '128000',
-            '-f', 'flv',
             '/tmp/out.flv'
         ]
         self.assertEqual(ff.get_args(), ensure_binary(expected))
@@ -103,16 +112,15 @@ class FFMPEGTestCase(TestCase):
         """ Stream bypass with filter_complex missing."""
         ff = FFMPEG('/tmp/input.mp4')
 
-        cv0 = Codec(VIDEO, 'libx264', vbitrate='700000')
-        ca0 = Codec(AUDIO, 'aac', abitrate='128000')
-        ff > Output('/tmp/out.flv', cv0, ca0)
+        cv0 = X264(bitrate=700000)
+        ca0 = AAC(bitrate=128000)
+        ff > output_file('/tmp/out.flv', cv0, ca0)
 
         expected = [
             'ffmpeg',
             '-i', '/tmp/input.mp4',
             '-map', '0:v', '-c:v', 'libx264', '-b:v', '700000',
             '-map', '0:a', '-c:a', 'aac', '-b:a', '128000',
-            '-f', 'flv',
             '/tmp/out.flv'
         ]
         self.assertEqual(ff.get_args(), ensure_binary(expected))
@@ -121,20 +129,19 @@ class FFMPEGTestCase(TestCase):
         """ Input stream naming test."""
 
         ff = FFMPEG()
-        ff < Input(Stream(VIDEO), input_file='/tmp/input.jpg')
+        ff < Input(input_file='/tmp/input.jpg', streams=(Stream(VIDEO),))
         ff < Input(input_file='/tmp/input.mp4')
 
-        cv0 = Codec(VIDEO, 'libx264', vbitrate='700000')
-        ca0 = Codec(AUDIO, 'aac', abitrate='128000')
+        cv0 = X264(bitrate=700000)
+        ca0 = AAC(bitrate=128000)
 
-        fc = ff.init_filter_complex()
-        overlay = Overlay(0, 0)
-        fc.video | Scale(640, 360) | overlay
-        fc.video | Scale(1280, 720) | overlay
-        fc.audio | Volume(-20) > ca0
+        overlay = filters.Overlay(0, 0)
+        ff.video | filters.Scale(640, 360) | overlay
+        ff.video | filters.Scale(1280, 720) | overlay
+        ff.audio | Volume(-20) > ca0
         overlay > cv0
 
-        out0 = Output('/tmp/out.flv', cv0, ca0)
+        out0 = output_file('/tmp/out.flv', cv0, ca0)
         ff.add_output(out0)
 
         expected = [
@@ -143,13 +150,12 @@ class FFMPEGTestCase(TestCase):
             '-i', '/tmp/input.mp4',
             '-filter_complex',
             (
-                '[0:v]scale=640x360[v:scale0];'
-                '[v:scale0][v:scale1]overlay=x=0:y=0[vout0];'
-                '[1:v]scale=1280x720[v:scale1];'
+                '[0:v]scale=w=640:h=360[v:scale0];'
+                '[v:scale0][v:scale1]overlay[vout0];'
+                '[1:v]scale=w=1280:h=720[v:scale1];'
                 '[1:a]volume=-20.00[aout0]'),
             '-map', '[vout0]', '-c:v', 'libx264', '-b:v', '700000',
             '-map', '[aout0]', '-c:a', 'aac', '-b:a', '128000',
-            '-f', 'flv',
             '/tmp/out.flv'
         ]
         self.assertEqual(ff.get_args(), ensure_binary(expected))
@@ -158,14 +164,12 @@ class FFMPEGTestCase(TestCase):
         """ vcodec=copy connects source directly to muxer."""
         ff = FFMPEG('/tmp/input.mp4')
 
-        fc = ff.init_filter_complex()
-
-        cv0 = Codec(VIDEO, 'copy')
-        ca0 = Codec(AUDIO, 'aac', abitrate='128000')
+        cv0 = codecs.VideoCodec('copy')
+        ca0 = codecs.AudioCodec('aac', bitrate=128000)
 
-        fc.audio | Volume(20) > ca0
+        ff.audio | Volume(20) > ca0
 
-        out0 = Output('/tmp/out.flv', cv0, ca0)
+        out0 = output_file('/tmp/out.flv', cv0, ca0)
         ff.add_output(out0)
         expected = [
             'ffmpeg',
@@ -176,7 +180,6 @@ class FFMPEGTestCase(TestCase):
             '-c:v', 'copy',
             '-map', '[aout0]',
             '-c:a', 'aac', '-b:a', '128000',
-            '-f', 'flv',
             '/tmp/out.flv'
         ]
         self.assertEqual(ff.get_args(), ensure_binary(expected))
@@ -185,15 +188,15 @@ class FFMPEGTestCase(TestCase):
         """ Reuse input files multiple times."""
         v = Stream(VIDEO)
         a = Stream(AUDIO)
-        ff = FFMPEG(Input(v, a, input_file='/tmp/input.mp4'))
-        cv0 = Codec(VIDEO, 'copy')
-        ca0 = Codec(AUDIO, 'copy')
-        out0 = Output('/tmp/out0.flv', cv0, ca0)
+        ff = FFMPEG(Input(input_file='/tmp/input.mp4', streams=(v, a)))
+        cv0 = codecs.VideoCodec('copy')
+        ca0 = codecs.AudioCodec('copy')
+        out0 = output_file('/tmp/out0.flv', cv0, ca0)
         ff > out0
 
-        cv1 = Codec(VIDEO, 'copy')
-        ca1 = Codec(AUDIO, 'copy')
-        out1 = Output('/tmp/out1.flv', cv1, ca1)
+        cv1 = codecs.VideoCodec('copy')
+        ca1 = codecs.AudioCodec('copy')
+        out1 = output_file('/tmp/out1.flv', cv1, ca1)
         v > cv1
         a > ca1
         ff > out1
@@ -204,13 +207,11 @@ class FFMPEGTestCase(TestCase):
             '-c:v', 'copy',
             '-map', '0:a',
             '-c:a', 'copy',
-            '-f', 'flv',
             '/tmp/out0.flv',
             '-map', '0:v',
             '-c:v', 'copy',
             '-map', '0:a',
             '-c:a', 'copy',
-            '-f', 'flv',
             '/tmp/out1.flv',
         ]
         self.assertEqual(ff.get_args(), ensure_binary(expected))
@@ -219,20 +220,19 @@ class FFMPEGTestCase(TestCase):
         """ vcodec=copy with separate transcoded output."""
         v = Stream(VIDEO)
         a = Stream(AUDIO)
-        ff = FFMPEG(Input(v, a, input_file='/tmp/input.mp4'))
-        ff.init_filter_complex()
+        ff = FFMPEG(Input(input_file='/tmp/input.mp4', streams=(v, a)))
 
-        cv0 = Codec(VIDEO, 'copy')
-        ca0 = Codec(AUDIO, 'copy')
-        out0 = Output('/tmp/copy.flv', cv0, ca0)
+        cv0 = codecs.VideoCodec('copy')
+        ca0 = codecs.AudioCodec('copy')
+        out0 = output_file('/tmp/copy.flv', cv0, ca0)
 
         ff > out0
 
-        cv1 = Codec(VIDEO, 'libx264')
-        ca1 = Codec(AUDIO, 'aac')
-        out1 = Output('/tmp/out.flv', cv1, ca1)
+        cv1 = codecs.VideoCodec('libx264')
+        ca1 = codecs.AudioCodec('aac')
+        out1 = output_file('/tmp/out.flv', cv1, ca1)
 
-        v | Scale(640, 360) > cv1
+        v | filters.Scale(640, 360) > cv1
         a > ca1
 
         ff.add_output(out1)
@@ -241,18 +241,16 @@ class FFMPEGTestCase(TestCase):
             'ffmpeg',
             '-i', '/tmp/input.mp4',
             '-filter_complex',
-            '[0:v]scale=640x360[vout0]',
+            '[0:v]scale=w=640:h=360[vout0]',
             '-map', '0:v',
             '-c:v', 'copy',
             '-map', '0:a',
             '-c:a', 'copy',
-            '-f', 'flv',
             '/tmp/copy.flv',
             '-map', '[vout0]',
             '-c:v', 'libx264',
             '-map', '0:a',
             '-c:a', 'aac',
-            '-f', 'flv',
             '/tmp/out.flv'
 
         ]
@@ -262,7 +260,7 @@ class FFMPEGTestCase(TestCase):
         """ Transcoding works without filter graph."""
         ff = FFMPEG()
         ff < Input(input_file='input.mp4')
-        ff.add_output(Output('/dev/null', format='null'))
+        ff.add_output(output_file('/dev/null', format='null'))
         expected = [
             'ffmpeg',
             '-i', 'input.mp4',
@@ -277,8 +275,8 @@ class FFMPEGTestCase(TestCase):
         """ tee muxer args."""
         ff = FFMPEG('/tmp/input.mp4')
 
-        cv0 = Codec(VIDEO, 'libx264')
-        ca0 = Codec(AUDIO, 'aac')
+        cv0 = codecs.VideoCodec('libx264')
+        ca0 = codecs.AudioCodec('aac')
         out0 = HLSMuxer('http://ya.ru/1.m3u8', segment_size=2)
 
         out1 = HLSMuxer('http://ya.ru/2.m3u8', manifest_size=5)
@@ -303,31 +301,29 @@ class FFMPEGTestCase(TestCase):
         ff < Input(input_file='preroll.mp4')
         ff < Input(input_file='input.mp4')
 
-        cv0 = Codec(VIDEO, 'libx264')
-        ca0 = Codec(AUDIO, 'aac')
-
-        fc = ff.init_filter_complex()
+        cv0 = codecs.VideoCodec('libx264')
+        ca0 = codecs.AudioCodec('aac')
 
-        preroll_ready = fc.video | Scale(640, 480) | SetSAR(1)
-        concat = Concat()
+        preroll_ready = ff.video | filters.Scale(640, 480) | SetSAR(1)
+        concat = filters.Concat(VIDEO)
         preroll_ready | concat
-        fc.video | concat
+        ff.video | concat
 
         concat > cv0
 
-        aconcat = Concat(AUDIO)
-        fc.audio | aconcat
-        fc.audio | aconcat
+        aconcat = filters.Concat(AUDIO)
+        ff.audio | aconcat
+        ff.audio | aconcat
 
         aconcat > ca0
-        ff.add_output(Output('output.mp4', cv0, ca0))
+        ff.add_output(output_file('output.mp4', cv0, ca0))
 
         expected = [
             'ffmpeg',
             '-i', 'preroll.mp4',
             '-i', 'input.mp4',
             '-filter_complex',
-            "[0:v]scale=640x480[v:scale0];"
+            "[0:v]scale=w=640:h=480[v:scale0];"
             "[v:scale0]setsar=1[v:setsar0];"
             "[v:setsar0][1:v]concat[vout0];"
             "[0:a][1:a]concat=v=0:a=1:n=2[aout0]",
@@ -335,7 +331,6 @@ class FFMPEGTestCase(TestCase):
             '-c:v', 'libx264',
             '-map', '[aout0]',
             '-c:a', 'aac',
-            '-f', 'mp4',
             'output.mp4'
         ]
         self.assertEqual(ff.get_args(), ensure_binary(expected))
diff --git a/tests/test_graph.py b/tests/test_graph.py
index 189b042..400fece 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -1,16 +1,15 @@
+from dataclasses import dataclass
 from unittest import TestCase
 
+from fffw.encoding.filters import *
 from fffw.graph import *
-from fffw.graph import base, VIDEO
+from fffw.graph import VIDEO
 
 
-class Deint(base.Node):
-    kind = VIDEO
+@dataclass
+class Deint(VideoFilter):
     filter = 'yadif'
-
-    def __init__(self, mode: str = '0', enabled: bool = True):
-        super(Deint, self).__init__(enabled=enabled)
-        self.mode = mode
+    mode: str = '0'
 
     @property
     def args(self) -> str:
@@ -28,19 +27,21 @@ class FilterGraphTestCase(TestCase):
                                             |
                                             ------<Scale>--[O/720p]
         """
-        logo = Input(Stream(VIDEO), input_file='logo.png')
-        main = Input(input_file='main.mp4')
-        il = InputList(main, logo)
-        out0 = Output('out0.mp4')
-        out1 = Output('out1.mp4')
-        ol = OutputList(out0, out1)
+        vs = Stream(VIDEO)
+        main = input_file('main.mp4')
+        logo = input_file('logo.png', vs)
+        il = InputList((main, logo))
+        out0 = output_file('out0.mp4')
+        out1 = output_file('out1.mp4')
+        ol = OutputList((out0, out1))
 
         fc = FilterComplex(il, ol)
 
-        deint = Deint(enabled=False)  # deinterlace is disabled
+        deint = Deint()
+        deint.enabled = False  # deinterlace is skipped
 
         # first video stream is deinterlaced
-        next_node = fc.video | deint
+        next_node = main.streams[0] | deint
 
         left, top = 20, 20  # logo position
 
@@ -52,26 +53,26 @@ class FilterGraphTestCase(TestCase):
 
         # second input stream is connected to logo scaler, followed by overlay
         # filter
-        next_node = fc.video | Scale(logo_width, logo_height) | over
+        next_node = vs | Scale(logo_width, logo_height) | over
 
         # audio is split to two streams
-        asplit = fc.audio | Split(AUDIO)
+        asplit = main.streams[1] | Split(AUDIO)
 
-        for out in ol.outputs:
+        for out in ol:
             asplit > out
 
         # video split to two steams
 
         # connect split filter to overlayed video stream
-        split = next_node | Split()
+        split = next_node | Split(VIDEO)
 
         # intermediate video stream scaling
         sizes = [(640, 480), (1280, 720)]
 
-        for out, size in zip(ol.outputs, sizes):
+        for out, size in zip(ol, sizes):
             # add scale filters to video streams
             w, h = size
-            scale = Scale(w, h, enabled=True)
+            scale = Scale(w, h)
             # connect scaled streams to video destinations
             split | scale > out
 
@@ -83,14 +84,14 @@ class FilterGraphTestCase(TestCase):
             # split video to two streams
             '[v:overlay0]split[v:split0][v:split1]',
             # each video is scaled to own size
-            '[v:split0]scale=640x480[vout0]',
-            '[v:split1]scale=1280x720[vout1]',
+            '[v:split0]scale=w=640:h=480[vout0]',
+            '[v:split1]scale=w=1280:h=720[vout1]',
 
             # split audio to two streams
             '[0:a]asplit[aout0][aout1]',
 
             # logo scaling
-            '[1:v]scale=200x50[v:scale0]',
+            '[1:v]scale=w=200:h=50[v:scale0]',
         ])
 
         self.assertEqual(expected.replace(';', ';\n'),
@@ -101,47 +102,52 @@ class FilterGraphTestCase(TestCase):
 
         # noinspection PyShadowingNames
         def fc_factory():
-            src = Input(Stream(VIDEO), input_file="input.mp4")
-            dst = Output('output.mp4')
-            fc = FilterComplex(InputList(src), OutputList(dst))
-            return fc, dst
+            src = input_file("input.mp4", Stream(VIDEO))
+            dst = output_file('output.mp4')
+            fc = FilterComplex(InputList((src,)), OutputList((dst,)))
+            return fc, src, dst
+
+        def deint_factory():
+            d = Deint()
+            d.enabled = False
+            return d
 
-        fc, dst = fc_factory()
+        fc, src, dst = fc_factory()
 
-        fc.video | Scale(640, 360) | Deint(enabled=False) > dst.video
-        self.assertEqual('[0:v]scale=640x360[vout0]', fc.render())
+        src.streams[0] | Scale(640, 360) | deint_factory() > dst.video
+        self.assertEqual('[0:v]scale=w=640:h=360[vout0]', fc.render())
 
-        fc, dst = fc_factory()
+        fc, src, dst = fc_factory()
 
-        fc.video | Deint(enabled=False) | Scale(640, 360) > dst.video
-        self.assertEqual('[0:v]scale=640x360[vout0]', fc.render())
+        src.streams[0] | deint_factory() | Scale(640, 360) > dst.video
+        self.assertEqual('[0:v]scale=w=640:h=360[vout0]', fc.render())
 
-        fc, dst = fc_factory()
+        fc, src, dst = fc_factory()
 
-        tmp = fc.video | Deint(enabled=False)
-        tmp = tmp | Deint(enabled=False)
+        tmp = src.streams[0] | deint_factory()
+        tmp = tmp | deint_factory()
         tmp | Scale(640, 360) > dst.video
-        self.assertEqual('[0:v]scale=640x360[vout0]', fc.render())
+        self.assertEqual('[0:v]scale=w=640:h=360[vout0]', fc.render())
 
-        fc, dst = fc_factory()
+        fc, src, dst = fc_factory()
 
-        tmp = fc.video | Scale(640, 360)
-        tmp = tmp | Deint(enabled=False)
-        tmp | Deint(enabled=False) > dst.video
-        self.assertEqual('[0:v]scale=640x360[vout0]', fc.render())
+        tmp = src.streams[0] | Scale(640, 360)
+        tmp = tmp | deint_factory()
+        tmp | deint_factory() > dst.video
+        self.assertEqual('[0:v]scale=w=640:h=360[vout0]', fc.render())
 
     def test_skip_not_connected_sources(self):
         """ Skip unused sources in filter complex.
         """
         source = Input(input_file='input.mp4')
-        output = Output('output.mp4')
-        il = InputList(source)
-        ol = OutputList(output)
+        output = output_file('output.mp4')
+        il = InputList((source,))
+        ol = OutputList((output,))
         # passing only video to FilterComplex
         fc = FilterComplex(il, ol)
-        fc.video | Scale(640, 360) > output
+        source.streams[0] | Scale(640, 360) > output
 
-        self.assertEqual('[0:v]scale=640x360[vout0]', fc.render())
+        self.assertEqual('[0:v]scale=w=640:h=360[vout0]', fc.render())
 
     def test_pass_metadata(self):
         """
@@ -149,13 +155,13 @@ class FilterGraphTestCase(TestCase):
         """
         metadata = video_meta_data()
 
-        source = Input(Stream(VIDEO, meta=metadata),
-                       input_file='input.mp4')
-        output = Output('output.mp4')
-        il = InputList(source)
-        ol = OutputList(output)
+        source = Input(input_file='input.mp4',
+                       streams=(Stream(VIDEO, meta=metadata),))
+        output = output_file('output.mp4')
+        il = InputList((source,))
+        ol = OutputList((output,))
         fc = FilterComplex(il, ol)
         dest = output.video
-        fc.video | Scale(640, 360) > dest
+        fc.get_free_source(VIDEO) | Scale(640, 360) > dest
 
         self.assertIs(dest.get_meta_data(dest), metadata)

pip install -e .
parse_log_pytest
pymediainfo
pytest
3.8
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
exceptiongroup==1.2.2
-e git+https://github.com/just-work/fffw.git@ee0869266f4dfc0716c90f9c70a94536175c8771#egg=fffw
iniconfig==2.1.0
packaging==24.2
pluggy==1.5.0
pymediainfo @ file:///home/conda/feedstock_root/build_artifacts/pymediainfo_1698611835601/work
pytest==8.3.5
tomli==2.2.1

name: fffw
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - c-ares=1.19.1=h5eee18b_0
  - ca-certificates=2025.2.25=h06a4308_0
  - krb5=1.20.1=h143b758_1
  - ld_impl_linux-64=2.40=h12ee557_0
  - libcurl=8.12.1=hc9e6f67_0
  - libedit=3.1.20230828=h5eee18b_0
  - libev=4.33=h7f8727e_1
  - libffi=3.4.4=h6a678d5_1
  - libgcc=14.2.0=h767d61c_2
  - libgcc-ng=14.2.0=h69a702a_2
  - libgomp=14.2.0=h767d61c_2
  - libmediainfo=24.05=hb0e0bff_0
  - libnghttp2=1.57.0=h2d74bed_0
  - libssh2=1.11.1=h251f7ec_0
  - libstdcxx=14.2.0=h8f9b012_2
  - libstdcxx-ng=14.2.0=h4852527_2
  - libzen=0.4.41=h5888daf_1
  - libzlib=1.2.13=h4ab18f5_6
  - lz4-c=1.9.4=h6a678d5_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=24.2=py38h06a4308_0
  - pymediainfo=6.1.0=py38h578d9bd_0
  - python=3.8.20=he870216_0
  - python_abi=3.8=2_cp38
  - readline=8.2=h5eee18b_0
  - setuptools=75.1.0=py38h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tinyxml2=10.0.0=h3f2d84a_2
  - tk=8.6.14=h39e8969_0
  - wheel=0.44.0=py38h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h4ab18f5_6
  - zstd=1.5.6=hc292b87_0
  - pip:
      - exceptiongroup==1.2.2
      - iniconfig==2.1.0
      - packaging==24.2
      - pluggy==1.5.0
      - pytest==8.3.5
      - tomli==2.2.1
prefix: /opt/conda/envs/fffw

tests/test_encoding.py::InputsTestCase::test_append_source tests/test_encoding.py::InputsTestCase::test_default_input tests/test_encoding.py::InputsTestCase::test_input_list tests/test_encoding.py::InputsTestCase::test_validate_stream_kind tests/test_ffmpeg.py::FFMPEGTestCase::test_bypass_with_filter_complex tests/test_ffmpeg.py::FFMPEGTestCase::test_bypass_without_filter_complex tests/test_ffmpeg.py::FFMPEGTestCase::test_concat tests/test_ffmpeg.py::FFMPEGTestCase::test_ffmpeg tests/test_ffmpeg.py::FFMPEGTestCase::test_handle_codec_copy tests/test_ffmpeg.py::FFMPEGTestCase::test_handle_codec_copy_with_other_filters tests/test_ffmpeg.py::FFMPEGTestCase::test_input_stream_naming tests/test_ffmpeg.py::FFMPEGTestCase::test_reuse_input_files tests/test_ffmpeg.py::FFMPEGTestCase::test_transcoding_without_graph tests/test_graph.py::FilterGraphTestCase::test_disabled_filters tests/test_graph.py::FilterGraphTestCase::test_filter_graph tests/test_graph.py::FilterGraphTestCase::test_pass_metadata tests/test_graph.py::FilterGraphTestCase::test_skip_not_connected_sources
fffw/encoding/__init__.py fffw/encoding/filters.py fffw/graph/complex.py fffw/graph/filters.py fffw/encoding/ffmpeg.py fffw/graph/__init__.py fffw/graph/base.py fffw/graph/meta.py fffw/graph/outputs.py fffw/wrapper/helpers.py fffw/encoding/codecs.py fffw/graph/inputs.py fffw/wrapper/__init__.py fffw/wrapper/base.py fffw/wrapper.py fffw/wrapper/params.py
fffw/encoding/__init__.py fffw/encoding/filters.py fffw/graph/complex.py fffw/graph/filters.py fffw/encoding/ffmpeg.py fffw/graph/__init__.py fffw/graph/base.py fffw/graph/meta.py fffw/graph/outputs.py fffw/wrapper/helpers.py fffw/encoding/codecs.py fffw/graph/inputs.py fffw/wrapper/__init__.py fffw/wrapper/base.py fffw/wrapper.py fffw/wrapper/params.py