Repository: just-work/fffw

Issue:
Support computing frames count for video metadata
It's useful to track encoding progress and to restore partial video metadata

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/just-work/fffw/pull/80?src=pr&el=h1) Report
> Merging [#80](https://codecov.io/gh/just-work/fffw/pull/80?src=pr&el=desc) into [master](https://codecov.io/gh/just-work/fffw/commit/4c54624b5c4b7f707fb53c6cfd9166f83680fc52?el=desc) will **increase** coverage by `0.01%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/just-work/fffw/pull/80/graphs/tree.svg?width=650&height=150&src=pr&token=iRRZ8FxDw1)](https://codecov.io/gh/just-work/fffw/pull/80?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master      #80      +/-   ##
==========================================
+ Coverage   90.75%   90.76%   +0.01%     
==========================================
  Files          25       25              
  Lines        2400     2404       +4     
==========================================
+ Hits         2178     2182       +4     
  Misses        222      222              
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `90.76% <100.00%> (+0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/just-work/fffw/pull/80?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [tests/test\_meta\_data.py](https://codecov.io/gh/just-work/fffw/pull/80/diff?src=pr&el=tree#diff-dGVzdHMvdGVzdF9tZXRhX2RhdGEucHk=) | `100.00% <ø> (ø)` | |
| [fffw/graph/meta.py](https://codecov.io/gh/just-work/fffw/pull/80/diff?src=pr&el=tree#diff-ZmZmdy9ncmFwaC9tZXRhLnB5) | `92.98% <100.00%> (+0.12%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/just-work/fffw/pull/80?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/just-work/fffw/pull/80?src=pr&el=footer). Last update [4c54624...b899797](https://codecov.io/gh/just-work/fffw/pull/80?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/fffw/encoding/filters.py b/fffw/encoding/filters.py
index f42e5b2..194424c 100644
--- a/fffw/encoding/filters.py
+++ b/fffw/encoding/filters.py
@@ -278,8 +278,18 @@ class Trim(AutoFilter):
                 scenes.append(Scene(stream=scene.stream, start=start,
                                     duration=end - start))
 
-        return replace(meta, start=self.start, duration=self.end,
-                       scenes=scenes, streams=streams)
+        kwargs = {
+            'start': self.start,
+            'duration': self.end,
+            'scenes': scenes,
+            'streams': streams
+        }
+        interval = cast(TS, self.end) - cast(TS, self.start)
+        if isinstance(meta, AudioMeta):
+            kwargs['samples'] = round(meta.sampling_rate * interval)
+        if isinstance(meta, VideoMeta):
+            kwargs['frames'] = round(meta.frame_rate * interval)
+        return replace(meta, **kwargs)
 
 
 @dataclass
@@ -353,6 +363,7 @@ class Concat(Filter):
         duration = TS(0)
         scenes = []
         streams: List[str] = []
+        frames: int = 0
         for meta in metadata:
             duration += meta.duration
             scenes.extend(meta.scenes)
@@ -361,12 +372,17 @@ class Concat(Filter):
                     # Add all streams for each concatenated metadata and remove
                     # contiguous duplicates.
                     streams.append(stream)
+            if isinstance(meta, VideoMeta):
+                frames += meta.frames
         kwargs = dict(duration=duration, scenes=scenes, streams=streams)
         meta = metadata[0]
         if isinstance(meta, AudioMeta):
             # Recompute samples and sampling rate: sampling rate from first
             # input, samples count corresponds duration.
             kwargs['samples'] = round(meta.sampling_rate * duration)
+        if isinstance(meta, VideoMeta):
+            # Sum frames count from all input streams
+            kwargs['frames'] = frames
         return replace(metadata[0], **kwargs)
 
 
diff --git a/fffw/graph/meta.py b/fffw/graph/meta.py
index c767955..29beac6 100644
--- a/fffw/graph/meta.py
+++ b/fffw/graph/meta.py
@@ -330,6 +330,8 @@ class VideoMeta(Meta):
     """ Display aspect ratio."""
     frame_rate: float
     """ Frames per second."""
+    frames: int
+    """ Number of frames."""
     device: Optional[Device]
     """ Hardware device asociated with current stream."""
 
@@ -346,6 +348,9 @@ class VideoMeta(Meta):
         else:
             assert str(self.dar) == 'nan'
 
+        interval = float(self.duration - self.start)
+        assert abs(self.frames - interval * self.frame_rate) <= 1
+
 
 @dataclass
 class AudioMeta(Meta):
@@ -369,11 +374,8 @@ class AudioMeta(Meta):
         return AUDIO
 
     def validate(self) -> None:
-        duration = self.duration.total_seconds()
-        if duration != 0:
-            assert abs(self.sampling_rate - self.samples / duration) < 0.001
-        else:
-            assert self.sampling_rate == 0
+        interval = float(self.duration - self.start)
+        assert abs(self.samples - interval * self.sampling_rate) <= 1
 
 
 def audio_meta_data(**kwargs: Any) -> AudioMeta:
@@ -437,6 +439,7 @@ def video_meta_data(**kwargs: Any) -> VideoMeta:
         par=par,
         dar=dar,
         frame_rate=frame_rate,
+        frames=frames,
         device=None,
     )
 


Test patch:
diff --git a/tests/test_graph.py b/tests/test_graph.py
index e8c25d9..b85e73b 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -2,7 +2,7 @@ from dataclasses import dataclass
 from typing import cast
 from unittest import TestCase
 
-from fffw.encoding import inputs, outputs
+from fffw.encoding import inputs, outputs, codecs
 from fffw.encoding.complex import FilterComplex
 from fffw.encoding.filters import *
 from fffw.graph import *
@@ -34,17 +34,22 @@ class FilterGraphTestCase(TestCase):
             dar=1.777777778,
             par=1.0,
             duration=300.0,
+            frame_rate=10.0,
+            frame_count=3000
         )
         self.audio_metadata = audio_meta_data(
             duration=200.0,
             sampling_rate=48000,
-            samples_count=200*48000)
+            samples_count=200 * 48000)
 
         self.source = inputs.Input(
             input_file='input.mp4',
             streams=(inputs.Stream(VIDEO, meta=self.video_metadata),
                      inputs.Stream(AUDIO, meta=self.audio_metadata)))
-        self.output = outputs.output_file('output.mp4')
+        self.output = outputs.output_file(
+            'output.mp4',
+            codecs.VideoCodec('libx264'),
+            codecs.AudioCodec('libfdk_aac'))
         self.input_list = inputs.InputList((self.source,))
         self.output_list = outputs.OutputList((self.output,))
         self.fc = FilterComplex(self.input_list, self.output_list)
@@ -237,13 +242,16 @@ class FilterGraphTestCase(TestCase):
         self.assertEqual(vm.width, self.video_metadata.width)
         self.assertEqual(vm.height, self.video_metadata.height)
 
-    def test_concat_metadata(self):
+    def test_concat_video_metadata(self):
         """
         Concat filter sums stream duration
 
         $ ffmpeg -y -i first.mp4 -i second.mp4 -filter_complex concat test.mp4
         """
-        vs = inputs.Stream(VIDEO, meta=video_meta_data(duration=1000.0))
+        video_meta = video_meta_data(duration=1000.0,
+                                     frame_count=10000,
+                                     frame_rate=10.0)
+        vs = inputs.Stream(VIDEO, meta=video_meta)
         self.input_list.append(inputs.input_file('second.mp4', vs))
         concat = vs | Concat(VIDEO)
         self.source | concat
@@ -253,12 +261,15 @@ class FilterGraphTestCase(TestCase):
         vm = cast(VideoMeta, self.output.codecs[0].get_meta_data())
         self.assertEqual(self.video_metadata.duration + vs.meta.duration,
                          vm.duration)
+        self.assertEqual(self.video_metadata.frames + video_meta.frames,
+                         vm.frames)
 
     def test_concat_audio_metadata(self):
         """
         Concat filter sums samples count for audio streams.
         """
-        audio_meta = audio_meta_data(duration=1000.0, sampling_rate=24000,
+        audio_meta = audio_meta_data(duration=1000.0,
+                                     sampling_rate=24000,
                                      samples_count=24000 * 1000)
         a = inputs.Stream(AUDIO, meta=audio_meta)
         self.input_list.append(inputs.input_file('second.mp4', a))
@@ -273,7 +284,7 @@ class FilterGraphTestCase(TestCase):
         self.assertEqual(round(am.duration * audio_meta.sampling_rate),
                          am.samples)
 
-    def test_trim_metadata(self):
+    def test_video_trim_metadata(self):
         """
         Trim filter sets start and changes stream duration.
         $ ffmpeg -y -i source.mp4 -vf trim=start=3:end=4 -an test.mp4
@@ -285,6 +296,21 @@ class FilterGraphTestCase(TestCase):
         vm = cast(VideoMeta, self.output.codecs[0].get_meta_data())
         self.assertEqual(vm.start, TS(3.0))
         self.assertEqual(vm.duration, TS(4.0))
+        self.assertEqual(vm.frames, 1.0 * vm.frame_rate)
+
+    def test_audio_trim_metadata(self):
+        """
+        Trim filter sets start and changes stream duration.
+        $ ffmpeg -y -i source.mp4 -af atrim=start=3:end=4 -vn test.mp4
+
+        Note that resulting video has 3 seconds of frozen frame at 00:00:03.000,
+        total duration is 4.
+        """
+        self.source | Trim(AUDIO, start=3.0, end=4.0) > self.output
+        am = cast(AudioMeta, self.output.codecs[1].get_meta_data())
+        self.assertEqual(am.start, TS(3.0))
+        self.assertEqual(am.duration, TS(4.0))
+        self.assertEqual(am.samples, 1.0 * am.sampling_rate)
 
     def test_setpts_metadata(self):
         """
diff --git a/tests/test_meta_data.py b/tests/test_meta_data.py
index 8f63ce8..801af85 100644
--- a/tests/test_meta_data.py
+++ b/tests/test_meta_data.py
@@ -229,6 +229,7 @@ class MetaDataTestCase(TestCase):
             par=1.0,
             dar=1.778,
             frame_rate=50.0,
+            frames=337,
             device=None,
         )
         self.assertEqual(expected, video)
@@ -266,7 +267,6 @@ class MetaDataTestCase(TestCase):
         self.assertTrue(fields(ExtendedVideoMeta))
 
 
-
 class TimeStampTestCase(TestCase):
     td: timedelta
     ts: meta.TS