Repository: just-work/fffw

Issue:
Filter._clone changes attribute types
`Upload._clone()` method changes `device` type from `Device` to `dict`. After that upload instance become unusable.

Comments:

Patch:
diff --git a/fffw/encoding/filters.py b/fffw/encoding/filters.py
index 1f49262..70c8d93 100644
--- a/fffw/encoding/filters.py
+++ b/fffw/encoding/filters.py
@@ -269,16 +269,20 @@ class Trim(AutoFilter):
                 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))
+            start = cast(TS, max(self.start, scene.position))
+            end = cast(TS, min(self.end, scene.position + scene.duration))
 
             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))
+                offset = start - scene.position
+                scenes.append(Scene(
+                    stream=scene.stream,
+                    start=scene.start + offset,
+                    position=scene.position + offset,
+                    duration=end - start))
 
         kwargs = {
             'start': start,
@@ -367,8 +371,14 @@ class Concat(Filter):
         streams: List[str] = []
         frames: int = 0
         for meta in metadata:
+            for scene in meta.scenes:
+                scenes.append(Scene(
+                    stream=scene.stream,
+                    duration=scene.duration,
+                    start=scene.start,
+                    position=scene.position + duration,
+                ))
             duration += meta.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
@@ -422,6 +432,12 @@ class Upload(VideoFilter):
     extra_hw_frames: int = param(default=64, init=False)
     device: Device = param(skip=True)
 
+    def __post_init__(self) -> None:
+        if isinstance(self.device, dict):
+            # deserialize back after filter cloning
+            self.device = Device(**self.device)
+        super().__post_init__()
+
     def transform(self, *metadata: Meta) -> VideoMeta:
         """ Marks a stream as uploaded to a device."""
         meta = ensure_video(*metadata)
diff --git a/fffw/graph/meta.py b/fffw/graph/meta.py
index 29beac6..5ed0f15 100644
--- a/fffw/graph/meta.py
+++ b/fffw/graph/meta.py
@@ -143,6 +143,7 @@ class TS(float):
             value = seconds + fractional
         return super().__new__(cls, value)  # type: ignore
 
+    __hash__ = float.__hash__
     __add__ = ts(float.__add__)
     __radd__ = ts(float.__radd__)
     __sub__ = ts(float.__sub__)
@@ -264,7 +265,9 @@ class Scene:
     duration: TS
     """ Stream duration."""
     start: TS
-    """ First frame/sample timestamp for stream."""
+    """ First frame/sample timestamp in source stream."""
+    position: TS
+    """ Position of scene in current stream."""
 
     @property
     def end(self) -> TS:
@@ -386,6 +389,7 @@ def audio_meta_data(**kwargs: Any) -> AudioMeta:
         stream=stream,
         duration=duration,
         start=start,
+        position=start,
     )
 
     return AudioMeta(
@@ -425,8 +429,9 @@ def video_meta_data(**kwargs: Any) -> VideoMeta:
     start = TS(kwargs.get('start', 0))
     scene = Scene(
         stream=stream,
-        start=start,
         duration=duration,
+        start=start,
+        position=start,
     )
     return VideoMeta(
         scenes=[scene],


Test patch:
diff --git a/tests/test_graph.py b/tests/test_graph.py
index c7db0fd..bed22b0 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -1,3 +1,4 @@
+from copy import deepcopy
 from dataclasses import dataclass, replace
 from typing import cast
 from unittest import TestCase
@@ -302,6 +303,33 @@ class FilterGraphTestCase(TestCase):
         self.assertEqual(round(am.duration * audio_meta.sampling_rate),
                          am.samples)
 
+    def test_concat_scenes(self):
+        """
+        Concat shifts scenes start/end timestamps.
+        """
+        video_meta = video_meta_data(duration=1000.0,
+                                     frame_count=10000,
+                                     frame_rate=10.0)
+        vs1 = inputs.Stream(VIDEO, meta=video_meta)
+        vs2 = inputs.Stream(VIDEO, meta=video_meta)
+        vs3 = inputs.Stream(VIDEO, meta=video_meta)
+
+        c = Concat(VIDEO, input_count=3)
+        vs1 | c
+        vs2 | c
+        vs3 | c
+        expected = (
+            deepcopy(vs1.meta.scenes) +
+            deepcopy(vs2.meta.scenes) +
+            deepcopy(vs3.meta.scenes)
+        )
+        assert len(expected) == 3
+        current_duration = TS(0)
+        for scene in expected:
+            scene.position += current_duration
+            current_duration += scene.duration
+        self.assertListEqual(c.meta.scenes, expected)
+
     def test_video_trim_metadata(self):
         """
         Trim filter sets start and changes stream duration.
@@ -389,6 +417,15 @@ class FilterGraphTestCase(TestCase):
         except ValueError:  # pragma: no cover
             self.fail("hardware validation unexpectedly failed")
 
+    def test_upload_filter_clone(self):
+        """ While cloning Upload filter should preserve Device instance."""
+        cuda = meta.Device(hardware='cuda', name='foo')
+        upload = self.source.video | Upload(device=cuda)
+
+        upload = upload.clone(2)[1]
+        vm = cast(VideoMeta, upload.meta)
+        self.assertEqual(vm.device, cuda)
+
     def test_codec_metadata_transform(self):
         """
         Codecs parameters applied to stream metadata when using transform.
diff --git a/tests/test_meta_data.py b/tests/test_meta_data.py
index 801af85..d8fed7f 100644
--- a/tests/test_meta_data.py
+++ b/tests/test_meta_data.py
@@ -217,7 +217,8 @@ class MetaDataTestCase(TestCase):
         self.assertIsInstance(video, meta.VideoMeta)
         scene = meta.Scene(stream=None,
                            start=meta.TS(0),
-                           duration=meta.TS(6.740))
+                           duration=meta.TS(6.740),
+                           position=meta.TS(0))
         expected = meta.VideoMeta(
             scenes=[scene],
             streams=[],
@@ -237,7 +238,8 @@ class MetaDataTestCase(TestCase):
         self.assertIsInstance(audio, meta.AudioMeta)
         scene = meta.Scene(stream=None,
                            start=meta.TS(0),
-                           duration=meta.TS(6.742))
+                           duration=meta.TS(6.742),
+                           position=meta.TS(0))
         expected = meta.AudioMeta(
             scenes=[scene],
             streams=[],
@@ -301,6 +303,11 @@ class TimeStampTestCase(TestCase):
         self.assertIsInstance(ts, meta.TS)
         self.assertAlmostEqual(ts.total_seconds(), expected, places=4)
 
+    def test_ts_hashable(self):
+        marker = object()
+        data = {float(self.ts): marker}
+        self.assertIs(data.get(self.ts), marker)
+
     def test_ts_float(self):
         self.assertEqual(float(self.ts), self.td.total_seconds())