Repository: just-work/fffw

Issue:
Prevent buffering
Need some way to detect possible frame buffering and automatic input file cloning as a workaround.

Discussion:

Patch:
diff --git a/fffw/encoding/codecs.py b/fffw/encoding/codecs.py
index 29c0437..bc4814d 100644
--- a/fffw/encoding/codecs.py
+++ b/fffw/encoding/codecs.py
@@ -11,7 +11,7 @@ class VideoCodec(outputs.Codec):
     """
     Base class for describing video codecs.
 
-    See `fffw.graph.outputs.Codec` for params definition.
+    See `fffw.encoding.outputs.Codec` for params definition.
     >>> from dataclasses import dataclass
     >>> from fffw.wrapper import param
     >>> @dataclass
@@ -29,7 +29,7 @@ class AudioCodec(outputs.Codec):
     """
     Base class for describing audio codecs.
 
-    See `fffw.graph.outputs.Codec` for params definition.
+    See `fffw.encoding.outputs.Codec` for params definition.
     >>> from dataclasses import dataclass
     >>> from fffw.wrapper import param
     >>> @dataclass
diff --git a/fffw/encoding/ffmpeg.py b/fffw/encoding/ffmpeg.py
index 6bd7be2..ec118c0 100644
--- a/fffw/encoding/ffmpeg.py
+++ b/fffw/encoding/ffmpeg.py
@@ -1,11 +1,11 @@
 from dataclasses import dataclass
 from typing import List, Optional, Literal, Union
 
-from fffw.graph import base
 from fffw.encoding import inputs
 from fffw.encoding.complex import FilterComplex
 from fffw.encoding.inputs import InputList, Input
 from fffw.encoding.outputs import OutputList, Output, Codec
+from fffw.graph import base
 from fffw.wrapper import BaseWrapper, ensure_binary, param
 
 __all__ = ['FFMPEG']
@@ -22,7 +22,7 @@ class FFMPEG(BaseWrapper):
 
     >>> from fffw.encoding.codecs import VideoCodec, AudioCodec
     >>> from fffw.encoding.filters import Scale
-    >>> from fffw.graph.outputs import output_file
+    >>> from fffw.encoding.outputs import output_file
     >>> ff = FFMPEG('/tmp/input.mp4', overwrite=True)
     >>> c = VideoCodec('libx264', bitrate=4_000_000)
     >>> ff.video | Scale(1280, 720) > c
@@ -206,3 +206,27 @@ class FFMPEG(BaseWrapper):
             if marker in line:
                 return super().handle_stderr(line)
         return ''
+
+    def check_buffering(self) -> None:
+        """
+        Checks that ffmpeg command will not cause frame buffering and
+        out of memory errors.
+
+        Each input file must be read simultaneously be all codecs in outputs,
+        or some streams will be buffered until requested by output codecs.
+        """
+        chains = []
+        for output in self.__outputs:
+            for codec in output.codecs:
+                streams = codec.check_buffering()
+                if streams is None:
+                    # Streams can't be computed because of missing metadata.
+                    raise ValueError(streams)
+                chains.append(streams)
+        for chunk in zip(*chains):
+            # Check that every codec reads same input stream
+            if len(set(chunk)) > 1:
+                # some codec read different file at this step, so one of input
+                # stream will be buffered until this file is read by another
+                # codec.
+                raise BufferError(chunk)
diff --git a/fffw/encoding/filters.py b/fffw/encoding/filters.py
index a514d41..1915933 100644
--- a/fffw/encoding/filters.py
+++ b/fffw/encoding/filters.py
@@ -1,8 +1,8 @@
-from dataclasses import dataclass, replace, asdict
-from typing import Union, List
+from dataclasses import dataclass, replace, asdict, MISSING, field
+from typing import Union, List, cast
 
 from fffw.graph import base
-from fffw.graph.meta import Meta, VideoMeta, TS
+from fffw.graph.meta import Meta, VideoMeta, TS, Scene
 from fffw.wrapper.params import Params, param
 
 __all__ = [
@@ -127,7 +127,13 @@ class AutoFilter(Filter):
     Base class for stream kind autodetect.
     """
 
-    kind: base.StreamType
+    kind: base.StreamType = field(metadata={'skip': True})
+    """ 
+    Stream kind used to generate filter name. Required. Not used as filter 
+    parameter.
+    """
+    # `field` is used here to tell MyPy that there is no default for `kind`
+    # because `default=MISSING` is valuable for MyPY.
 
     def __post_init__(self) -> None:
         """ Adds audio prefix to filter name for audio filters."""
@@ -202,11 +208,44 @@ class Trim(AutoFilter):
     start: Union[int, float, str, TS]
     end: Union[int, float, str, TS]
 
+    def __post_init__(self) -> None:
+        if not isinstance(self.start, (TS, type(None))):
+            self.start = TS(self.start)
+        if not isinstance(self.end, (TS, type(None))):
+            self.end = TS(self.end)
+        super().__post_init__()
+
     def transform(self, *metadata: Meta) -> Meta:
+        """
+        Computes metadata for trimmed stream.
+
+        :param metadata: single incoming stream metadata.
+        :returns: metadata with initial start (this is fixed with SetPTS) and
+            duration set to trim end. Scenes list is intersected with trim
+            interval, scene borders are aligned to trim borders.
+        """
         meta = metadata[0]
-        start = self.start if isinstance(self.start, TS) else TS(self.start)
-        end = self.end if isinstance(self.end, TS) else TS(self.end)
-        return replace(meta, start=start, duration=end)
+        scenes = []
+        streams: List[str] = []
+        for scene in meta.scenes:
+            if scene.stream and (not streams or streams[0] != scene.stream):
+                # Adding an input stream without contiguous duplicates.
+                streams.append(scene.stream)
+
+            # intersect scene with trim interval
+            start = cast(TS, max(self.start, scene.start))
+            end = cast(TS, min(self.end, scene.end))
+
+            if start < end:
+                # If intersection is not empty, add intersection to resulting
+                # scenes list.
+                # This will allow to detect buffering when multiple scenes are
+                # reordered in same file: input[3:4] + input[1:2]
+                scenes.append(Scene(stream=scene.stream, start=start,
+                                    duration=end - start))
+
+        return replace(meta, start=self.start, duration=self.end,
+                       scenes=scenes, streams=streams)
 
 
 @dataclass
@@ -270,10 +309,26 @@ class Concat(Filter):
         return 'v=0:a=1:n=%s' % self.input_count
 
     def transform(self, *metadata: Meta) -> Meta:
+        """
+        Compute metadata for concatenated streams.
+
+        :param metadata: concatenated streams metadata
+        :returns: Metadata for resulting stream with duration set to a sum of
+            stream durations. Scenes and streams are also concatenated.
+        """
         duration = TS(0)
+        scenes = []
+        streams: List[str] = []
         for meta in metadata:
             duration += meta.duration
-        return replace(metadata[0], duration=duration)
+            scenes.extend(meta.scenes)
+            for stream in meta.streams:
+                if not streams or streams[-1] != stream:
+                    # Add all streams for each concatenated metadata and remove
+                    # contiguous duplicates.
+                    streams.append(stream)
+        return replace(metadata[0], duration=duration,
+                       scenes=scenes, streams=streams)
 
 
 @dataclass
diff --git a/fffw/encoding/inputs.py b/fffw/encoding/inputs.py
index 77a7614..795a69c 100644
--- a/fffw/encoding/inputs.py
+++ b/fffw/encoding/inputs.py
@@ -47,6 +47,20 @@ class Stream(base.Source):
         self.connect_dest(split)
         return [split] * count
 
+    def connect_input(self, source: str) -> None:
+        """
+        Marks current stream metadata that it belongs to some input file.
+
+        :param source: source filename
+        """
+        if self.meta is None:
+            return
+        if self.meta.streams:
+            return
+        self.meta.streams = [source]
+        for scene in self.meta.scenes:
+            scene.stream = source
+
 
 def default_streams() -> Tuple[Stream, ...]:
     """
@@ -75,9 +89,9 @@ class Input(BaseWrapper):
     """ List of audio and video streams for input file."""
 
     fast_seek: Union[TS, float, int] = param(name='ss')
+    duration: Union[TS, float, int] = param(name='t')
     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:
         """
@@ -110,6 +124,8 @@ class Input(BaseWrapper):
 def input_file(filename: str, *streams: Stream, **kwargs: Any) -> Input:
     kwargs['input_file'] = filename
     if streams:
+        for stream in streams:
+            stream.connect_input(filename)
         kwargs['streams'] = streams
     return Input(**kwargs)
 
diff --git a/fffw/encoding/outputs.py b/fffw/encoding/outputs.py
index 60c41a0..9ebf0d9 100644
--- a/fffw/encoding/outputs.py
+++ b/fffw/encoding/outputs.py
@@ -1,8 +1,7 @@
-from dataclasses import dataclass, asdict
+from dataclasses import dataclass
 from itertools import chain
 from typing import List, cast, Optional, Iterable, Any
 
-from fffw.encoding import filters
 from fffw.graph import base
 from fffw.wrapper import BaseWrapper, ensure_binary, param
 
@@ -72,6 +71,27 @@ class Codec(base.Dest, BaseWrapper):
             return [self]
         raise RuntimeError("Trying to clone codec, is this intended?")
 
+    def check_buffering(self) -> Optional[List[str]]:
+        """
+        Check that scenes read from input stream are ordered with ascending
+        timestamps.
+
+        :returns: A list of streams needed for this codec or None if metadata
+            for codec can't be computed.
+        """
+        meta = self.get_meta_data(None)
+        if not meta:
+            return None
+        prev = meta.scenes[0]
+        for scene in meta.scenes[1:]:
+            if prev.stream == scene.stream and prev.end > scene.start:
+                # Previous scene in same stream is located after current, so
+                # current decoded scene will be buffered until previous scene is
+                # decoded.
+                raise BufferError(prev, scene)
+            prev = scene
+        return meta.streams
+
 
 @dataclass
 class Output(BaseWrapper):
@@ -84,6 +104,8 @@ class Output(BaseWrapper):
     :arg output_file: output file name.
     """
     codecs: List[Codec] = param(skip=True)
+    no_video: Optional[bool] = param(default=None, name='vn')
+    no_audio: Optional[bool] = param(default=None, name='an')
     format: str = param(name="f")
     output_file: str = param(name="")
 
@@ -140,10 +162,22 @@ class Output(BaseWrapper):
         """
         :returns: codec args and output file parameters for ffmpeg
         """
-        args = (
-                list(chain(*(codec.get_args() for codec in self.codecs))) +
-                super().get_args()
-        )
+        args = []
+        # Check if we need to disable audio or video for output file because no
+        # corresponding codecs are found.
+        # Skipping `-an` / `-vn` parameters is still supported by  manually
+        # setting `no_audio` / `no_video` parameters to `False`.
+        for codec in self.codecs:
+            if codec.kind == base.VIDEO:
+                self.no_video = False
+            if codec.kind == base.AUDIO:
+                self.no_audio = False
+            args.extend(codec.get_args())
+        if self.no_video is None:
+            self.no_video = True
+        if self.no_audio is None:
+            self.no_audio = True
+        args.extend(super().get_args())
         return args
 
 
diff --git a/fffw/encoding/vector.py b/fffw/encoding/vector.py
index 6b690d7..0e844a3 100644
--- a/fffw/encoding/vector.py
+++ b/fffw/encoding/vector.py
@@ -241,7 +241,7 @@ class Vector(tuple):
             iterable = source
         return tuple.__new__(cls, iterable)  # noqa
 
-    def __or__(self, other: filters.Filter) -> "Vector":
+    def __or__(self, other: Union["Vector", filters.Filter]) -> "Vector":
         """ A shortcut to connect vector to another filter."""
         return self.connect(other)
 
diff --git a/fffw/graph/meta.py b/fffw/graph/meta.py
index 0189c70..0a3edb9 100644
--- a/fffw/graph/meta.py
+++ b/fffw/graph/meta.py
@@ -1,16 +1,17 @@
 from dataclasses import dataclass
 from datetime import timedelta
-from typing import List, Union, Any
+from typing import List, Union, Any, Optional
 
 from pymediainfo import MediaInfo  # type: ignore
 
 __all__ = [
-    'Meta',
-    'VideoMeta',
     'AudioMeta',
+    'VideoMeta',
+    'Meta',
+    'Scene',
+    'TS',
     'video_meta_data',
     'audio_meta_data',
-    'TS'
 ]
 
 
@@ -47,9 +48,9 @@ class TS(timedelta):
         """
         Removes non-valuable zeros from fractional part.
 
-        :returns: ffmpeg interval definition (123:59:59.999).
+        :returns: ffmpeg seconds definition (123456.999).
         """
-        v = super().__str__()
+        v = str(self.total_seconds())
         if '.' in v:
             v = v.rstrip('0')
         return v
@@ -59,6 +60,33 @@ class TS(timedelta):
             return NotImplemented
         return TS(self.total_seconds() + other.total_seconds())
 
+    def __sub__(self, other: timedelta) -> "TS":
+        if not isinstance(other, timedelta):
+            return NotImplemented
+        return TS(self.total_seconds() - other.total_seconds())
+
+    def __lt__(self, other: Union[int, float, str, timedelta, "TS"]) -> bool:
+        if not isinstance(other, timedelta):
+            other = TS(other)
+        return self.total_seconds() < other.total_seconds()
+
+
+@dataclass
+class Scene:
+    """
+    Continuous part of stream used in transcoding graph.
+    """
+    stream: Optional[str]
+    """ Stream identifier."""
+    duration: TS
+    """ Stream duration."""
+    start: TS
+    """ First frame/sample timestamp for stream."""
+
+    @property
+    def end(self) -> TS:
+        return self.start + self.duration
+
 
 @dataclass
 class Meta:
@@ -68,11 +96,27 @@ class Meta:
     Describes common stream characteristics like bitrate and duration.
     """
     duration: TS
-    """ Stream duration."""
+    """ Resulting stream duration."""
     start: TS
-    """ First frame/sample timestamp for stream."""
+    """ First frame/sample timestamp for resulting stream."""
     bitrate: int
-    """ Stream bitrate in bits per second."""
+    """ Input stream bitrate in bits per second."""
+    scenes: List[Scene]
+    """ 
+    List of continuous stream fragments (maybe from different files), that need
+    to be read to get a result with current metadata.
+    """
+    streams: List[str]
+    """
+    List of streams (maybe from different files), that need to be read to get
+    a result with current metadata."""
+
+    @property
+    def end(self) -> TS:
+        """
+        :return: Timestamp of last frame resulting stream.
+        """
+        return self.start + self.duration
 
 
 @dataclass
@@ -90,7 +134,6 @@ class VideoMeta(Meta):
     """ Display aspect ratio."""
     frame_rate: float
     """ Frames per second."""
-    frames: int
 
     def __post_init__(self) -> None:
         self.validate()
@@ -100,11 +143,6 @@ class VideoMeta(Meta):
             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
@@ -133,9 +171,20 @@ class AudioMeta(Meta):
 
 
 def audio_meta_data(**kwargs: Any) -> AudioMeta:
+    stream = kwargs.get('stream')
+    duration = TS(kwargs.get('duration', 0))
+    start = TS(kwargs.get('start', 0))
+    scene = Scene(
+        stream=stream,
+        duration=duration,
+        start=start,
+    )
+
     return AudioMeta(
-        duration=TS(kwargs.get('duration', 0)),
-        start=TS(kwargs.get('start', 0)),
+        scenes=[scene],
+        streams=[stream] if stream else [],
+        duration=duration,
+        start=start,
         bitrate=int(kwargs.get('bit_rate', 0)),
         channels=int(kwargs.get('channel_s', 0)),
         sampling_rate=int(kwargs.get('sampling_rate', 0)),
@@ -163,16 +212,25 @@ def video_meta_data(**kwargs: Any) -> VideoMeta:
             frame_rate = 0
         else:
             frame_rate = frames / duration.total_seconds()
+
+    stream = kwargs.get('stream')
+    start = TS(kwargs.get('start', 0))
+    scene = Scene(
+        stream=stream,
+        start=start,
+        duration=duration,
+    )
     return VideoMeta(
+        scenes=[scene],
+        streams=[stream] if stream else [],
         duration=duration,
-        start=TS(kwargs.get('start', 0)),
+        start=start,
         bitrate=int(kwargs.get('bit_rate', 0)),
         width=width,
         height=height,
         par=par,
         dar=dar,
-        frame_rate=frame_rate,
-        frames=frames)
+        frame_rate=frame_rate)
 
 
 def from_media_info(mi: MediaInfo) -> List[Meta]:
diff --git a/fffw/wrapper/params.py b/fffw/wrapper/params.py
index 5fe4554..4558be1 100644
--- a/fffw/wrapper/params.py
+++ b/fffw/wrapper/params.py
@@ -1,9 +1,10 @@
 from dataclasses import field, dataclass, Field, fields
-from typing import Any, Optional, Tuple, cast, List
+from typing import Any, Optional, Tuple, cast, List, Callable
 
 
 def param(default: Any = None, name: Optional[str] = None,
-          stream_suffix: bool = False, init: bool = True, skip: bool = False
+          stream_suffix: bool = False, init: bool = True, skip: bool = False,
+          render: Callable[[Any], Any] = None
           ) -> Any:
     """
     Command line and filter parameters constructor.
@@ -15,6 +16,7 @@ def param(default: Any = None, name: Optional[str] = None,
         (like '-b:v' or '-c:a')
     :param init: add parameter to dataclass constructor.
     :param skip: skip parameter while generating command line args list.
+    :param render: a callable used to format output value.
     :return: dataclass field definition with extra metadata.
 
     >>> from fffw.encoding.filters import VideoFilter
@@ -29,6 +31,7 @@ def param(default: Any = None, name: Optional[str] = None,
         'name': name,
         'stream_suffix': stream_suffix,
         'skip': skip,
+        'render': render
     }
     if callable(default):
         return field(default_factory=default, init=init, metadata=metadata)
@@ -91,6 +94,7 @@ class Params:
             name = meta.get('name')
             stream_suffix = meta.get('stream_suffix')
             skip = meta.get('skip')
+            render = meta.get('render')
             if skip:
                 # if field metadata is marked as `skip`
                 continue
@@ -100,7 +104,8 @@ class Params:
             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(render):
+                value = render(value)
 
             if callable(value):
                 # get lazy parameter value


Test patch:
diff --git a/tests/base.py b/tests/base.py
new file mode 100644
index 0000000..cfaa0a4
--- /dev/null
+++ b/tests/base.py
@@ -0,0 +1,27 @@
+from unittest import TestCase
+
+from fffw.encoding import FFMPEG
+from fffw.wrapper.helpers import ensure_text
+
+
+class BaseTestCase(TestCase):
+    ffmpeg: FFMPEG
+
+    def assert_ffmpeg_args(self, *arguments: str):
+
+        expected = list(arguments)
+        args = ensure_text(self.ffmpeg.get_args())
+        try:
+            idx = expected.index('-filter_complex')
+            expected_fc = expected[idx + 1].split(';')
+            expected[idx: idx + 2] = []
+        except ValueError:
+            expected_fc = []
+        try:
+            idx = args.index('-filter_complex')
+            real_fc = args[idx + 1].split(';')
+            args[idx:idx + 2] = []
+        except ValueError:
+            real_fc = []
+        self.assertSetEqual(set(expected_fc), set(real_fc))
+        self.assertListEqual(expected, args)
diff --git a/tests/test_ffmpeg.py b/tests/test_ffmpeg.py
index 759880a..275959b 100644
--- a/tests/test_ffmpeg.py
+++ b/tests/test_ffmpeg.py
@@ -1,9 +1,10 @@
 from dataclasses import dataclass
-from unittest import TestCase, expectedFailure
+from unittest import expectedFailure
 
-from fffw.graph import *
 from fffw.encoding import filters, codecs, ffmpeg, inputs, outputs
+from fffw.graph import *
 from fffw.wrapper import ensure_binary, param
+from tests.base import BaseTestCase
 
 
 @dataclass
@@ -41,17 +42,48 @@ class Volume(filters.AudioFilter):
         return "%.2f" % self.volume
 
 
-class FFMPEGTestCase(TestCase):
+class FFMPEGTestCase(BaseTestCase):
+    def setUp(self) -> None:
+        super().setUp()
+        vm = video_meta_data(duration=3600.0)
+        am = audio_meta_data(duration=3600.0)
+        self.source = inputs.input_file(
+            'source.mp4',
+            inputs.Stream(VIDEO, vm),
+            inputs.Stream(AUDIO, am))
+
+        self.logo = inputs.input_file(
+            'logo.png',
+            inputs.Stream(VIDEO, video_meta_data()))
+
+        vm = video_meta_data(duration=10.0)
+        am = audio_meta_data(duration=10.0)
+        self.preroll = inputs.input_file(
+            'preroll.mp4',
+            inputs.Stream(VIDEO, vm),
+            inputs.Stream(AUDIO, am))
+
+        self.video_codec = X264(bitrate=3600000)
+        self.audio_codec = AAC(bitrate=192000)
+
+        self.output = outputs.output_file(
+            'output.mp4',
+            self.video_codec,
+            self.audio_codec)
+
+        self.ffmpeg = FFMPEG()
 
     def test_ffmpeg(self):
         """ Smoke test and feature demo."""
-        ff = FFMPEG(loglevel='info', realtime=True)
-        ff < inputs.input_file('/tmp/input.mp4',
-                               fast_seek=123.2,
-                               duration=TS(123.2))
-
-        cv0 = X264(bitrate=700000)
-        ca0 = AAC(bitrate=128000)
+        ff = self.ffmpeg
+        ff.loglevel = 'info'
+        ff.realtime = True
+        self.source.fast_seek = 123.2
+        self.source.duration = TS(321.2)
+        ff < self.source
+
+        cv0 = self.video_codec
+        ca0 = self.audio_codec
         ca1 = codecs.AudioCodec('libmp3lame', bitrate=394000)
 
         asplit = ff.audio | filters.Split(AUDIO)
@@ -61,80 +93,93 @@ class FFMPEGTestCase(TestCase):
         asplit.connect_dest(ca0)
         asplit.connect_dest(ca1)
 
-        out0 = outputs.output_file('/tmp/out.flv', cv0, ca0)
+        out0 = self.output
         out1 = outputs.output_file('/tmp/out.mp3', ca1)
 
-        ff.add_output(out0)
-        ff.add_output(out1)
+        ff > out0
+        ff > out1
 
-        expected = [
+        self.assert_ffmpeg_args(
             'ffmpeg',
             '-loglevel', 'info',
             '-re',
             '-ss', '123.2',
-            '-i', '/tmp/input.mp4',
-            '-t', '0:02:03.2',
+            '-t', '321.2',
+            '-i', 'source.mp4',
             '-filter_complex',
             '[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',
+            '-map', '[vout0]', '-c:v', 'libx264', '-b:v', '3600000',
+            '-map', '[aout0]', '-c:a', 'aac', '-b:a', '192000',
 
-            '/tmp/out.flv',
+            'output.mp4',
 
             '-map', '[aout1]', '-c:a', 'libmp3lame', '-b:a', '394000',
+            '-vn',
             '/tmp/out.mp3'
-        ]
-        self.assertEqual(ff.get_args(), ensure_binary(expected))
+        )
 
     def test_bypass_with_filter_complex(self):
         """ Audio stream bypass mode."""
-        ff = FFMPEG('/tmp/input.mp4')
-        cv0 = X264(bitrate=700000)
-        ca0 = AAC(bitrate=128000)
+        ff = self.ffmpeg
+        ff < self.source
 
-        ff.video | filters.Scale(640, 360) > cv0
+        ff.video | filters.Scale(640, 360) > self.video_codec
 
-        ff > outputs.output_file('/tmp/out.flv', cv0, ca0)
+        ff > self.output
 
-        expected = [
+        self.assert_ffmpeg_args(
             'ffmpeg',
-            '-i', '/tmp/input.mp4',
+            '-i', 'source.mp4',
             '-filter_complex',
             '[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',
-            '/tmp/out.flv'
-        ]
-        self.assertEqual(ff.get_args(), ensure_binary(expected))
+            '-map', '[vout0]', '-c:v', 'libx264', '-b:v', '3600000',
+            '-map', '0:a', '-c:a', 'aac', '-b:a', '192000',
+            'output.mp4'
+        )
+
+    def test_no_audio_if_no_codecs_found(self):
+        """ If no audio codecs specified, set -an flag for an output."""
+        ff = self.ffmpeg
+        ff < self.source
+
+        output = outputs.output_file('out.mp4', codecs.VideoCodec('libx264'))
+        ff.video | filters.Scale(640, 360) > output
+        ff > output
+
+        self.assert_ffmpeg_args(
+            'ffmpeg',
+            '-i', 'source.mp4',
+            '-filter_complex',
+            '[0:v]scale=w=640:h=360[vout0]',
+            '-map', '[vout0]', '-c:v', 'libx264',
+            '-an',
+            'out.mp4'
+        )
 
     def test_bypass_without_filter_complex(self):
         """ inputs.Stream bypass with filter_complex missing."""
-        ff = FFMPEG('/tmp/input.mp4')
+        ff = self.ffmpeg
+        ff < self.source
+        ff > self.output
 
-        cv0 = X264(bitrate=700000)
-        ca0 = AAC(bitrate=128000)
-        ff > outputs.output_file('/tmp/out.flv', cv0, ca0)
-
-        expected = [
+        self.assert_ffmpeg_args(
             'ffmpeg',
-            '-i', '/tmp/input.mp4',
-            '-map', '0:v', '-c:v', 'libx264', '-b:v', '700000',
-            '-map', '0:a', '-c:a', 'aac', '-b:a', '128000',
-            '/tmp/out.flv'
-        ]
-        self.assertEqual(ff.get_args(), ensure_binary(expected))
+            '-i', 'source.mp4',
+            '-map', '0:v', '-c:v', 'libx264', '-b:v', '3600000',
+            '-map', '0:a', '-c:a', 'aac', '-b:a', '192000',
+            'output.mp4'
+        )
 
     def test_input_stream_naming(self):
         """ inputs.Input stream naming test."""
 
-        ff = FFMPEG()
-        ff < inputs.Input(input_file='/tmp/input.jpg',
-                          streams=(inputs.Stream(VIDEO),))
-        ff < inputs.Input(input_file='/tmp/input.mp4')
+        ff = self.ffmpeg
+        ff < self.logo
+        ff < self.source
 
-        cv0 = X264(bitrate=700000)
-        ca0 = AAC(bitrate=128000)
+        cv0 = self.video_codec
+        ca0 = self.audio_codec
 
         overlay = filters.Overlay(0, 0)
         ff.video | filters.Scale(640, 360) | overlay
@@ -142,39 +187,36 @@ class FFMPEGTestCase(TestCase):
         ff.audio | Volume(-20) > ca0
         overlay > cv0
 
-        out0 = outputs.output_file('/tmp/out.flv', cv0, ca0)
-        ff.add_output(out0)
+        ff > self.output
 
-        expected = [
+        self.assert_ffmpeg_args(
             'ffmpeg',
-            '-i', '/tmp/input.jpg',
-            '-i', '/tmp/input.mp4',
+            '-i', 'logo.png',
+            '-i', 'source.mp4',
             '-filter_complex',
-            (
-                '[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',
-            '/tmp/out.flv'
-        ]
-        self.assertEqual(ff.get_args(), ensure_binary(expected))
+            '[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', '3600000',
+            '-map', '[aout0]', '-c:a', 'aac', '-b:a', '192000',
+            'output.mp4'
+        )
 
     def test_handle_codec_copy(self):
         """ vcodec=copy connects source directly to muxer."""
-        ff = FFMPEG('/tmp/input.mp4')
+        ff = self.ffmpeg
+        ff < self.source
 
         cv0 = codecs.VideoCodec('copy')
         ca0 = codecs.AudioCodec('aac', bitrate=128000)
 
         ff.audio | Volume(20) > ca0
 
-        out0 = outputs.output_file('/tmp/out.flv', cv0, ca0)
-        ff.add_output(out0)
-        expected = [
+        ff > outputs.output_file('/tmp/out.flv', cv0, ca0)
+        self.assert_ffmpeg_args(
             'ffmpeg',
-            '-i', '/tmp/input.mp4',
+            '-i', 'source.mp4',
             '-filter_complex',
             '[0:a]volume=20.00[aout0]',
             '-map', '0:v',
@@ -182,18 +224,16 @@ class FFMPEGTestCase(TestCase):
             '-map', '[aout0]',
             '-c:a', 'aac', '-b:a', '128000',
             '/tmp/out.flv'
-        ]
-        self.assertEqual(ff.get_args(), ensure_binary(expected))
+        )
 
     def test_reuse_input_files(self):
         """ Reuse input files multiple times."""
-        v = inputs.Stream(VIDEO)
-        a = inputs.Stream(AUDIO)
-        ff = FFMPEG(inputs.Input(input_file='/tmp/input.mp4', streams=(v, a)))
-        cv0 = codecs.VideoCodec('copy')
-        ca0 = codecs.AudioCodec('copy')
-        out0 = outputs.output_file('/tmp/out0.flv', cv0, ca0)
-        ff > out0
+        ff = self.ffmpeg
+        ff < self.source
+        v = self.source.streams[0]
+        a = self.source.streams[1]
+
+        ff > self.output
 
         cv1 = codecs.VideoCodec('copy')
         ca1 = codecs.AudioCodec('copy')
@@ -201,46 +241,40 @@ class FFMPEGTestCase(TestCase):
         v > cv1
         a > ca1
         ff > out1
-        expected = [
+        self.assert_ffmpeg_args(
             'ffmpeg',
-            '-i', '/tmp/input.mp4',
+            '-i', 'source.mp4',
             '-map', '0:v',
-            '-c:v', 'copy',
+            '-c:v', 'libx264', '-b:v', '3600000',
             '-map', '0:a',
-            '-c:a', 'copy',
-            '/tmp/out0.flv',
+            '-c:a', 'aac', '-b:a', '192000',
+            'output.mp4',
             '-map', '0:v',
             '-c:v', 'copy',
             '-map', '0:a',
             '-c:a', 'copy',
             '/tmp/out1.flv',
-        ]
-        self.assertEqual(ff.get_args(), ensure_binary(expected))
+        )
 
     def test_handle_codec_copy_with_other_filters(self):
         """ vcodec=copy with separate transcoded output."""
-        v = inputs.Stream(VIDEO)
-        a = inputs.Stream(AUDIO)
-        ff = FFMPEG(inputs.Input(input_file='/tmp/input.mp4', streams=(v, a)))
+        ff = self.ffmpeg
+        ff < self.source
 
         cv0 = codecs.VideoCodec('copy')
         ca0 = codecs.AudioCodec('copy')
-        out0 = outputs.output_file('/tmp/copy.flv', cv0, ca0)
-
-        ff > out0
+        ff > outputs.output_file('/tmp/copy.flv', cv0, ca0)
 
         cv1 = codecs.VideoCodec('libx264')
         ca1 = codecs.AudioCodec('aac')
-        out1 = outputs.output_file('/tmp/out.flv', cv1, ca1)
-
-        v | filters.Scale(640, 360) > cv1
-        a > ca1
+        self.source.streams[0] | filters.Scale(640, 360) > cv1
+        self.source.streams[1] > ca1
 
-        ff.add_output(out1)
+        ff > outputs.output_file('/tmp/out.flv', cv1, ca1)
 
         expected = [
             'ffmpeg',
-            '-i', '/tmp/input.mp4',
+            '-i', 'source.mp4',
             '-filter_complex',
             '[0:v]scale=w=640:h=360[vout0]',
             '-map', '0:v',
@@ -259,16 +293,16 @@ class FFMPEGTestCase(TestCase):
 
     def test_transcoding_without_graph(self):
         """ Transcoding works without filter graph."""
-        ff = FFMPEG()
-        ff < inputs.Input(input_file='input.mp4')
-        ff.add_output(outputs.output_file('/dev/null', format='null'))
-        expected = [
+        ff = self.ffmpeg
+        ff < self.source
+        ff > outputs.output_file('/dev/null', format='null')
+        self.assert_ffmpeg_args(
             'ffmpeg',
-            '-i', 'input.mp4',
+            '-i', 'source.mp4',
+            '-vn', '-an',
             '-f', 'null',
             '/dev/null'
-        ]
-        self.assertEqual(ff.get_args(), ensure_binary(expected))
+        )
 
     # TODO #19 reimplement TeeMuxer
     @expectedFailure
@@ -298,40 +332,167 @@ class FFMPEGTestCase(TestCase):
 
     def test_concat(self):
         """ Concat source files."""
-        ff = FFMPEG()
-        ff < inputs.Input(input_file='preroll.mp4')
-        ff < inputs.Input(input_file='input.mp4')
-
-        cv0 = codecs.VideoCodec('libx264')
-        ca0 = codecs.AudioCodec('aac')
+        ff = self.ffmpeg
+        ff < self.preroll
+        ff < self.source
 
         preroll_ready = ff.video | filters.Scale(640, 480) | SetSAR(1)
         concat = filters.Concat(VIDEO)
         preroll_ready | concat
         ff.video | concat
 
-        concat > cv0
+        concat > self.video_codec
 
         aconcat = filters.Concat(AUDIO)
         ff.audio | aconcat
         ff.audio | aconcat
 
-        aconcat > ca0
-        ff.add_output(outputs.output_file('output.mp4', cv0, ca0))
+        aconcat > self.audio_codec
+        ff > self.output
 
-        expected = [
+        self.assert_ffmpeg_args(
             'ffmpeg',
             '-i', 'preroll.mp4',
-            '-i', 'input.mp4',
+            '-i', 'source.mp4',
             '-filter_complex',
             "[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]",
-            '-map', '[vout0]',
-            '-c:v', 'libx264',
-            '-map', '[aout0]',
-            '-c:a', 'aac',
+            '-map', '[vout0]', '-c:v', 'libx264', '-b:v', '3600000',
+            '-map', '[aout0]', '-c:a', 'aac', '-b:a', '192000',
             'output.mp4'
+        )
+
+    def test_detect_trim_buffering(self):
+        """
+        When trim and concat filters are used for editing timeline, buffering
+        may occur if order of scenes in output file does not match order of same
+        scenes in input file.
+        """
+        cases = [
+            (False, [1.0, 2.0], [2.0, 3.0]),
+            (True, [2.0, 3.0], [1.0, 2.0]),
+            (True, [2.0, 3.0], [2.0, 4.0]),
         ]
-        self.assertEqual(ff.get_args(), ensure_binary(expected))
+        for case in cases:
+            with self.subTest(case):
+                raises, first, second = case
+                ff = FFMPEG()
+                s1 = inputs.Stream(VIDEO, self.source.streams[0].meta)
+                s2 = inputs.Stream(VIDEO, self.source.streams[1].meta)
+
+                ff < inputs.input_file('input.mp4', s1, s2)
+                split = ff.video | filters.Split(VIDEO)
+                t1 = split | filters.Trim(VIDEO, *first)
+                p1 = t1 | filters.SetPTS(VIDEO)
+                t2 = split | filters.Trim(VIDEO, *second)
+                p2 = t2 | filters.SetPTS(VIDEO)
+
+                concat = p1 | filters.Concat(VIDEO)
+                output = outputs.output_file('output.mp4',
+                                             codecs.VideoCodec('libx264'))
+                p2 | concat > output
+
+                ff > output
+                try:
+                    ff.check_buffering()
+                except BufferError as e:
+                    self.assertTrue(raises, e)
+                else:
+                    self.assertFalse(raises)
+
+    def test_detect_concat_buffering(self):
+        """
+        When single source is used for multiple outputs, and one of outputs
+        has a preroll, buffering occurs, because to output first frame for a
+        non-preroll output, we need to buffer all preroll frames.
+        """
+
+        cases = [
+            (False, True, True),  # preroll + source / preroll + source
+            (False, True, False),  # preroll + source / preroll
+            (True, False, True),  # preroll + source / source
+        ]
+        for case in cases:
+            with self.subTest(case):
+                raises, split_pre, split_src = case
+                ff = FFMPEG()
+                v1 = inputs.Stream(VIDEO, self.preroll.streams[0].meta)
+                a1 = inputs.Stream(AUDIO, self.preroll.streams[1].meta)
+                v2 = inputs.Stream(VIDEO, self.source.streams[0].meta)
+                a2 = inputs.Stream(VIDEO, self.source.streams[1].meta)
+                ff < inputs.input_file('preroll.mp4', v1, a1)
+                ff < inputs.input_file('source.mp4', v2, a2)
+                vf1 = v1 | filters.Split(VIDEO, output_count=int(split_pre) + 1)
+                vf2 = v2 | filters.Split(VIDEO, output_count=int(split_src) + 1)
+                af1 = v1 | filters.Split(AUDIO, output_count=int(split_pre) + 1)
+                af2 = v2 | filters.Split(AUDIO, output_count=int(split_src) + 1)
+
+                vc1 = vf1 | filters.Concat(VIDEO, input_count=2)
+                vf2 | vc1
+                ac1 = af1 | filters.Concat(AUDIO, input_count=2)
+                af2 | ac1
+
+                vc2 = filters.Concat(VIDEO, int(split_pre) + int(split_src))
+                if split_pre:
+                    vf1 | vc2
+                if split_src:
+                    vf2 | vc2
+
+                ac2 = filters.Concat(AUDIO, int(split_pre) + int(split_src))
+                if split_pre:
+                    af1 | ac2
+                if split_src:
+                    af2 | ac2
+
+                o1 = outputs.output_file("o1.mp4", X264(), AAC())
+                o2 = outputs.output_file("o2.mp4", X264(), AAC())
+
+                vc1 > o1
+                ac1 > o1
+                vc2 > o2
+                ac2 > o2
+
+                ff > o1
+                ff > o2
+                try:
+                    ff.check_buffering()
+                except BufferError as e:
+                    self.assertTrue(raises, e)
+                else:
+                    self.assertFalse(raises)
+
+    def test_fix_preroll_buffering_with_trim(self):
+        """
+        We can fix buffering occured from preroll by using trim filter.
+        """
+        ff = self.ffmpeg
+        ff < self.preroll
+        ff < self.source
+
+        output = outputs.output_file('original.mp4',
+                                     codecs.VideoCodec("libx264"))
+        original = outputs.output_file('original.mp4',
+                                       codecs.VideoCodec("libx264"))
+
+        preroll_stream = self.preroll.streams[0]
+        source_stream = self.source.streams[0]
+
+        concat = preroll_stream | filters.Concat(VIDEO)
+        source_stream | concat
+
+        split = concat | filters.Split(VIDEO)
+
+        split > output
+
+        pd = preroll_stream.meta.duration
+        sd = source_stream.meta.duration
+        trim = split | filters.Trim(VIDEO, start=pd, end=pd + sd)
+
+        trim | filters.SetPTS(VIDEO) > original
+
+        ff > original
+        ff > output
+
+        ff.check_buffering()
diff --git a/tests/test_graph.py b/tests/test_graph.py
index 23f402c..17a2296 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -28,7 +28,7 @@ class FilterGraphTestCase(TestCase):
             height=1080,
             dar=1.777777778,
             par=1.0,
-            duration=300,
+            duration=300.0,
         )
         self.audio_metadata = audio_meta_data()
 
diff --git a/tests/test_meta_data.py b/tests/test_meta_data.py
index 804e63e..9952d34 100644
--- a/tests/test_meta_data.py
+++ b/tests/test_meta_data.py
@@ -210,7 +210,12 @@ class MetaDataTestCase(TestCase):
         self.assertEqual(len(streams), 2)
         video = streams[0]
         self.assertIsInstance(video, meta.VideoMeta)
+        scene = meta.Scene(stream=None,
+                           start=meta.TS(0),
+                           duration=meta.TS(6.740))
         expected = meta.VideoMeta(
+            scenes=[scene],
+            streams=[],
             duration=meta.TS(6.740),
             start=meta.TS(0),
             bitrate=4321426,
@@ -219,12 +224,16 @@ class MetaDataTestCase(TestCase):
             par=1.0,
             dar=1.778,
             frame_rate=50.0,
-            frames=337,
         )
         self.assertEqual(expected, video)
         audio = streams[1]
         self.assertIsInstance(audio, meta.AudioMeta)
+        scene = meta.Scene(stream=None,
+                           start=meta.TS(0),
+                           duration=meta.TS(6.742))
         expected = meta.AudioMeta(
+            scenes=[scene],
+            streams=[],
             duration=meta.TS(6.742),
             start=meta.TS(0),
             bitrate=192000,
diff --git a/tests/test_vector.py b/tests/test_vector.py
index 88848f5..1f8b286 100644
--- a/tests/test_vector.py
+++ b/tests/test_vector.py
@@ -1,12 +1,11 @@
 from dataclasses import dataclass
 from typing import cast, Tuple
-from unittest import TestCase
 
 from fffw.encoding import *
 from fffw.encoding.vector import SIMD, Vector
 from fffw.graph import *
 from fffw.wrapper import ensure_binary, param
-from fffw.wrapper.helpers import ensure_text
+from tests.base import BaseTestCase
 from tests.test_ffmpeg import Volume
 
 
@@ -26,7 +25,7 @@ class AnotherFilter(VideoFilter):
     filter = 'another'
 
 
-class VectorTestCase(TestCase):
+class VectorTestCase(BaseTestCase):
     def setUp(self) -> None:
         super().setUp()
         self.video_meta = video_meta_data()
@@ -43,22 +42,11 @@ class VectorTestCase(TestCase):
         self.simd = SIMD(self.source, self.output1, self.output2)
 
     def assert_simd_args(self, *arguments: str):
-        expected = list(arguments)
-        args = ensure_text(self.simd.ffmpeg.get_args())
-        try:
-            idx = expected.index('-filter_complex')
-            expected_fc = expected[idx + 1].split(';')
-            expected[idx: idx + 2] = []
-        except ValueError:
-            expected_fc = []
-        try:
-            idx = args.index('-filter_complex')
-            real_fc = args[idx + 1].split(';')
-            args[idx:idx + 2] = []
-        except ValueError:
-            real_fc = []
-        self.assertSetEqual(set(expected_fc), set(real_fc))
-        self.assertListEqual(expected, args)
+        self.assert_ffmpeg_args(*arguments)
+
+    @property
+    def ffmpeg(self):
+        return self.simd.ffmpeg
 
     def test_no_filter_graph(self):
         """ Checks that vector works correctly without filter graph."""