Repository: Zulko/moviepy

Issue:
Freezing movie frame on last frame or end

This code freezes the _first frame at the end_ but it should **freeze the last frame** at the end?
```
from moviepy.editor import *

mainclip = (VideoFileClip("C:/Users/Admin/Videos/ukulele.mp4")
            .fx( vfx.freeze, t='end', freeze_duration=2))

final = CompositeVideoClip([mainclip])
final.write_videofile("ukulele.mp4",fps=24,codec='libx264')
```



#### Expected Behavior
freeze the last frame at the end

#### Actual Behavior
freezes the _first frame_ at the end

#### Steps to Reproduce the Problem
<!-- Please include code that demonstrates this problem so that we can reproduce it. For advice on how to do this, see https://stackoverflow.com/help/mcve -->


#### Specifications

  - Python Version: 3.8.5
  - Moviepy Version: 1.0.3
  - Windows10



Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/36377678/badge)](https://coveralls.io/builds/36377678)

Coverage increased (+0.5%) to 66.485% when pulling **ce5e7e519fb13d0f89857a27cf0d05bf1ba02b0b on mondeja:fix-1307** into **646674c48017bca8bf1eaa576531a090d874a4ed on Zulko:master**.

mondeja: I'm going to use this pull to correct the `freeze` tests.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a3fa0f5..cddcdcc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Fixed positioning error generating frames in `CompositeVideoClip` [\#1420](https://github.com/Zulko/moviepy/pull/1420)
 - Changed deprecated `tostring` method by `tobytes` in `video.io.gif_writers::write_gif` [\#1429](https://github.com/Zulko/moviepy/pull/1429)
 - Fixed calling `audio_normalize` on a clip with no sound causing `ZeroDivisionError` [\#1401](https://github.com/Zulko/moviepy/pull/1401)
+- Fixed `freeze` FX was freezing at time minus 1 second as the end [\#1461](https://github.com/Zulko/moviepy/pull/1461)
 
 
 ## [v2.0.0.dev2](https://github.com/zulko/moviepy/tree/v2.0.0.dev2) (2020-10-05)
diff --git a/media/subtitles.srt b/media/subtitles.srt
index fe57e43..6fd947c 100644
--- a/media/subtitles.srt
+++ b/media/subtitles.srt
@@ -1,24 +1,20 @@
 0
-00:00:00,000 --> 00:00:04,000
+00:00:00,000 --> 00:00:01,000
 Red!
 
 1
-00:00:05,000 --> 00:00:09,000
+00:00:02,000 --> 00:00:03,500
 More Red!
 
 2
-00:00:10,000 --> 00:00:14,000
+00:00:5,000 --> 00:00:6,000
 Green!
 
 3
-00:00:15,000 --> 00:00:19,000
-More Green!
-
-4
-00:00:20,000 --> 00:00:24,000
+00:00:7,000 --> 00:00:8,000
 Blue
 
-5
-00:00:25,000 --> 00:00:29,000
+4
+00:00:9,000 --> 00:00:10,000
 More Blue!
 
diff --git a/moviepy/audio/AudioClip.py b/moviepy/audio/AudioClip.py
index 9600ce7..db837cc 100644
--- a/moviepy/audio/AudioClip.py
+++ b/moviepy/audio/AudioClip.py
@@ -115,13 +115,13 @@ class AudioClip(Clip):
           2 for 16bit, 4 for 32bit sound.
 
         """
-        if fps is None:
-            fps = self.fps
-
-        stacker = np.vstack if self.nchannels == 2 else np.hstack
-        max_duration = 1.0 * buffersize / fps
         if tt is None:
+            if fps is None:
+                fps = self.fps
+
+            max_duration = 1 * buffersize / fps
             if self.duration > max_duration:
+                stacker = np.vstack if self.nchannels == 2 else np.hstack
                 return stacker(
                     tuple(
                         self.iter_chunks(
diff --git a/moviepy/video/fx/freeze.py b/moviepy/video/fx/freeze.py
index f272c8b..47e8b19 100644
--- a/moviepy/video/fx/freeze.py
+++ b/moviepy/video/fx/freeze.py
@@ -7,17 +7,21 @@ def freeze(clip, t=0, freeze_duration=None, total_duration=None, padding_end=0):
     """Momentarily freeze the clip at time t.
 
     Set `t='end'` to freeze the clip at the end (actually it will freeze on the
-    frame at time clip.duration - padding_end seconds).
-    With ``duration``you can specify the duration of the freeze.
+    frame at time clip.duration - padding_end seconds - 1 / clip_fps).
+    With ``duration`` you can specify the duration of the freeze.
     With ``total_duration`` you can specify the total duration of
     the clip and the freeze (i.e. the duration of the freeze is
-    automatically calculated). One of them must be provided.
+    automatically computed). One of them must be provided.
     """
 
     if t == "end":
-        t = clip.duration - padding_end - 1
+        t = clip.duration - padding_end - 1 / clip.fps
 
     if freeze_duration is None:
+        if total_duration is None:
+            raise ValueError(
+                "You must provide either 'freeze_duration' or 'total_duration'"
+            )
         freeze_duration = total_duration - clip.duration
 
     before = [clip.subclip(0, t)] if (t != 0) else []


Test patch:
diff --git a/tests/test_PR.py b/tests/test_PR.py
index 37dc46c..7e45763 100644
--- a/tests/test_PR.py
+++ b/tests/test_PR.py
@@ -79,7 +79,7 @@ def test_PR_515():
 def test_PR_528():
     with ImageClip("media/vacation_2017.jpg") as clip:
         new_clip = scroll(clip, w=1000, x_speed=50)
-        new_clip = new_clip.with_duration(1)
+        new_clip = new_clip.with_duration(0.2)
         new_clip.fps = 24
         new_clip.write_videofile(os.path.join(TMP_DIR, "pano.mp4"))
 
@@ -105,7 +105,9 @@ def test_PR_1137_video():
     """
     Test support for path-like objects as arguments for VideoFileClip.
     """
-    with VideoFileClip(Path("media/big_buck_bunny_432_433.webm")) as video:
+    with VideoFileClip(Path("media/big_buck_bunny_432_433.webm")).subclip(
+        0.2, 0.4
+    ) as video:
         video.write_videofile(Path(TMP_DIR) / "pathlike.mp4")
         assert isinstance(video.filename, str)
 
diff --git a/tests/test_Videos.py b/tests/test_Videos.py
index c6625c3..90b61e1 100644
--- a/tests/test_Videos.py
+++ b/tests/test_Videos.py
@@ -17,8 +17,7 @@ def test_afterimage():
     some_background_clip = ColorClip((800, 600), color=(255, 255, 255))
     final_clip = CompositeVideoClip(
         [some_background_clip, masked_clip], use_bgclip=True
-    )
-    final_clip.duration = 5
+    ).with_duration(0.2)
     final_clip.write_videofile(os.path.join(TMP_DIR, "afterimage.mp4"), fps=30)
 
 
diff --git a/tests/test_examples.py b/tests/test_examples.py
index dfe4da0..0a837b6 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -21,7 +21,7 @@ def test_matplotlib_simple_example():
     plt.switch_backend("agg")
 
     x = np.linspace(-2, 2, 200)
-    duration = 2
+    duration = 0.5
 
     fig, ax = plt.subplots()
 
diff --git a/tests/test_fx.py b/tests/test_fx.py
index a8dfda3..33b39f8 100644
--- a/tests/test_fx.py
+++ b/tests/test_fx.py
@@ -202,24 +202,127 @@ def test_fadeout():
     close_all_clips(locals())
 
 
-def test_freeze():
-    clip = BitmapClip([["R"], ["G"], ["B"]], fps=1)  # 3 separate frames
+@pytest.mark.parametrize(
+    (
+        "t",
+        "freeze_duration",
+        "total_duration",
+        "padding_end",
+        "output_frames",
+    ),
+    (
+        # at start, 1 second (default t == 0)
+        (
+            None,
+            1,
+            None,
+            None,
+            ["R", "R", "G", "B"],
+        ),
+        # at start, 1 second (explicit t)
+        (
+            0,
+            1,
+            None,
+            None,
+            ["R", "R", "G", "B"],
+        ),
+        # at end, 1 second
+        (
+            "end",
+            1,
+            None,
+            None,
+            ["R", "G", "B", "B"],
+        ),
+        # at end 1 second, padding end 1 second
+        (
+            "end",
+            1,
+            None,
+            1,
+            ["R", "G", "G", "B"],
+        ),
+        # at 2nd frame, 1 second
+        (
+            1,  # second 0 is frame 1, second 1 is frame 2...
+            1,
+            None,
+            None,
+            ["R", "G", "G", "B"],
+        ),
+        # at 2nd frame, 2 seconds
+        (
+            1,
+            2,
+            None,
+            None,
+            ["R", "G", "G", "G", "B"],
+        ),
+        # `freeze_duration`, `total_duration` are None
+        (1, None, None, None, ValueError),
+        # `total_duration` 5 at start (2 seconds)
+        (None, None, 5, None, ["R", "R", "R", "G", "B"]),
+        # total duration 5 at end
+        ("end", None, 5, None, ["R", "G", "B", "B", "B"]),
+        # total duration 5 padding end
+        ("end", None, 5, 1, ["R", "G", "G", "G", "B"]),
+    ),
+    ids=[
+        "at start, 1 second (default t == 0)",
+        "at start, 1 second (explicit t)",
+        "at end, 1 second",
+        "at end 1 second, padding end 1 second",
+        "at 2nd frame, 1 second",
+        "at 2nd frame, 2 seconds",
+        "`freeze_duration`, `total_duration` are None",
+        "`total_duration` 5 at start (2 seconds)",
+        "`total_duration` 5 at end",
+        "`total_duration` 5 padding end",
+    ],
+)
+def test_freeze(t, freeze_duration, total_duration, padding_end, output_frames):
+    input_frames = ["R", "G", "B"]
+    clip_duration = len(input_frames)
 
-    clip1 = freeze(clip, t=1, freeze_duration=1)
-    target1 = BitmapClip([["R"], ["G"], ["G"], ["B"]], fps=1)
-    assert clip1 == target1
+    # create BitmapClip with predefined set of colors, during 1 second each one
+    clip = BitmapClip([list(color) for color in input_frames], fps=1).with_duration(
+        clip_duration
+    )
 
-    clip2 = freeze(clip, t="end", freeze_duration=1)
-    target2 = BitmapClip([["R"], ["G"], ["B"], ["B"]], fps=1)
-    assert clip2 == target2
+    # build kwargs passed to `freeze`
+    possible_kwargs = {
+        "t": t,
+        "freeze_duration": freeze_duration,
+        "total_duration": total_duration,
+        "padding_end": padding_end,
+    }
+    kwargs = {
+        kw_name: kw_value
+        for kw_name, kw_value in possible_kwargs.items()
+        if kw_value is not None
+    }
 
-    clip3 = freeze(clip, t=1, total_duration=4)
-    target3 = BitmapClip([["R"], ["G"], ["G"], ["B"]], fps=1)
-    assert clip3 == target3
+    # freeze clip
+    if hasattr(output_frames, "__traceback__"):
+        with pytest.raises(output_frames):
+            freeze(clip, **kwargs)
+        return
+    else:
+        freezed_clip = freeze(clip, **kwargs)
+
+    # assert new duration
+    expected_freeze_duration = (
+        freeze_duration
+        if freeze_duration is not None
+        else total_duration - clip_duration
+    )
+    assert freezed_clip.duration == clip_duration + expected_freeze_duration
 
-    clip4 = freeze(clip, t="end", total_duration=4, padding_end=1)
-    target4 = BitmapClip([["R"], ["G"], ["G"], ["B"]], fps=1)
-    assert clip4 == target4
+    # assert colors are the expected
+    for i, color in enumerate(freezed_clip.iter_frames()):
+        expected_color = list(BitmapClip.DEFAULT_COLOR_DICT[output_frames[i]])
+        assert list(color[0][0]) == expected_color
 
 
 def test_freeze_region():
@@ -235,8 +338,6 @@ def test_freeze_region():
     target2 = BitmapClip([["BBB", "DDD"], ["BBR", "DDD"], ["BBC", "DDD"]], fps=1)
     assert clip2 == target2
 
-    pass
-
 
 def test_gamma_corr():
     pass
@@ -279,16 +380,17 @@ def test_loop():
     target3 = BitmapClip([["R"], ["G"], ["B"], ["R"], ["G"]], fps=1)
     assert clip3 == target3
 
-    clip = get_test_video()
-    clip1 = loop(clip).with_duration(3)  # infinite looping
+    clip = get_test_video().subclip(0.2, 0.3)  # 0.1 seconds long
+    clip1 = loop(clip).with_duration(0.5)  # infinite looping
     clip1.write_videofile(os.path.join(TMP_DIR, "loop1.webm"))
 
-    clip2 = loop(clip, duration=10)  # loop for 10 seconds
+    clip2 = loop(clip, duration=0.5)  # loop for 1 second
     clip2.write_videofile(os.path.join(TMP_DIR, "loop2.webm"))
 
     clip3 = loop(clip, n=3)  # loop 3 times
     clip3.write_videofile(os.path.join(TMP_DIR, "loop3.webm"))
 
+    # Test audio looping
     clip = AudioClip(
         lambda t: np.sin(440 * 2 * np.pi * t) * (t % 1) + 0.5, duration=2.5, fps=44100
     )
@@ -373,7 +475,7 @@ def test_painting():
 
 def test_resize():
     # TODO update to use BitmapClip
-    clip = get_test_video()
+    clip = get_test_video().subclip(0.2, 0.3)
 
     clip1 = resize(clip, (460, 720))  # New resolution: (460,720)
     assert clip1.size == (460, 720)
diff --git a/tests/test_issues.py b/tests/test_issues.py
index 5216822..90fa313 100644
--- a/tests/test_issues.py
+++ b/tests/test_issues.py
@@ -256,7 +256,7 @@ def test_issue_354():
 
 
 def test_issue_359():
-    with ColorClip((800, 600), color=(255, 0, 0)).with_duration(5) as video:
+    with ColorClip((800, 600), color=(255, 0, 0)).with_duration(0.2) as video:
         video.fps = 30
         video.write_gif(filename=os.path.join(TMP_DIR, "issue_359.gif"), tempfiles=True)
 
@@ -301,7 +301,7 @@ def test_issue_368():
 
         return mplfig_to_npimage(fig)
 
-    animation = VideoClip(make_frame, duration=2)
+    animation = VideoClip(make_frame, duration=0.2)
     animation.write_gif(os.path.join(TMP_DIR, "svm.gif"), fps=20)
 
 
diff --git a/tests/test_misc.py b/tests/test_misc.py
index 958e5eb..c45a1ff 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -15,12 +15,11 @@ from tests.test_helper import FONT, TMP_DIR
 
 
 MEDIA_SUBTITLES_DATA = [
-    ([0.0, 4.0], "Red!"),
-    ([5.0, 9.0], "More Red!"),
-    ([10.0, 14.0], "Green!"),
-    ([15.0, 19.0], "More Green!"),
-    ([20.0, 24.0], "Blue"),
-    ([25.0, 29.0], "More Blue!"),
+    ([0.0, 1.0], "Red!"),
+    ([2.0, 3.5], "More Red!"),
+    ([5.0, 6.0], "Green!"),
+    ([7.0, 8.0], "Blue"),
+    ([9.0, 10.0], "More Blue!"),
 ]
 
 MEDIA_SUBTITLES_UNICODE_DATA = [