Repository: Zulko/moviepy
Pull request #2339 (2025-01-22 22:24:07)

Fix Freeze effect removing original start and end parameters
By default Freeze effect would remove the original clip start and end properties, this PR add a parameter to preserve thoses as much as we can.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8eb9b4b..25cc039 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,10 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Add codecs to .mov files
 - Add background radius to text clips
 - Support pillow 11
-- Add support for ffmpeg v7
 
 ### Changed <!-- for changes in existing functionality -->
-- Subclipping outside of clip boundaries now raise an exception
 
 ### Deprecated <!-- for soon-to-be removed features -->
 
diff --git a/moviepy/Clip.py b/moviepy/Clip.py
index 2d4c0b2..92fbfdd 100644
--- a/moviepy/Clip.py
+++ b/moviepy/Clip.py
@@ -218,6 +218,15 @@ class Clip:
         These changes are also applied to the ``audio`` and ``mask``
         clips of the current clip, if they exist.
 
+        note::
+          The start and end attribute of a clip define when a clip will start
+          playing when used in a composite video clip, not the start time of
+          the clip itself.
+
+          i.e: with_start(10) mean the clip will still start at his first frame,
+          but if used in a composite video clip it will only start to show at
+          10 seconds.
+
         Parameters
         ----------
 
@@ -248,6 +257,15 @@ class Clip:
         (hour, min, sec), or as a string: '01:03:05.35'. Also sets the duration
         of the mask and audio, if any, of the returned clip.
 
+        note::
+          The start and end attribute of a clip define when a clip will start
+          playing when used in a composite video clip, not the start time of
+          the clip itself.
+
+          i.e: with_start(10) mean the clip will still start at his first frame,
+          but if used in a composite video clip it will only start to show at
+          10 seconds.
+
         Parameters
         ----------
 
@@ -422,14 +440,6 @@ class Clip:
                 end_time = self.duration + end_time
 
         if end_time is not None:
-            # Allow a slight tolerance to account for rounding errors
-            if (self.duration is not None) and (end_time - self.duration > 0.00000001):
-                raise ValueError(
-                    "end_time (%.02f) " % end_time
-                    + "should be smaller or equal to the clip's "
-                    + "duration (%.02f)." % self.duration
-                )
-
             new_clip.duration = end_time - start_time
             new_clip.end = new_clip.start + new_clip.duration
 
diff --git a/moviepy/audio/AudioClip.py b/moviepy/audio/AudioClip.py
index 3a04b40..5aa10e9 100644
--- a/moviepy/audio/AudioClip.py
+++ b/moviepy/audio/AudioClip.py
@@ -240,9 +240,9 @@ class AudioClip(Clip):
         """
         if not fps:
             if hasattr(self, "fps"):
-                fps = self.fps
-            else:
                 fps = 44100
+            else:
+                fps = self.fps
 
         if codec is None:
             name, ext = os.path.splitext(os.path.basename(filename))
diff --git a/moviepy/video/fx/Freeze.py b/moviepy/video/fx/Freeze.py
index 40dc3c9..532f255 100644
--- a/moviepy/video/fx/Freeze.py
+++ b/moviepy/video/fx/Freeze.py
@@ -15,12 +15,16 @@ class Freeze(Effect):
     With ``total_duration`` you can specify the total duration of
     the clip and the freeze (i.e. the duration of the freeze is
     automatically computed). One of them must be provided.
+
+    With ``update_start_end`` you can define if the effect must preserve
+    and/or update start and end properties of the original clip
     """
 
     t: float = 0
     freeze_duration: float = None
     total_duration: float = None
     padding_end: float = 0
+    update_start_end: bool = True
 
     def apply(self, clip: Clip) -> Clip:
         """Apply the effect to the clip."""
@@ -40,4 +44,12 @@ class Freeze(Effect):
         before = [clip[: self.t]] if (self.t != 0) else []
         freeze = [clip.to_ImageClip(self.t).with_duration(self.freeze_duration)]
         after = [clip[self.t :]] if (self.t != clip.duration) else []
-        return concatenate_videoclips(before + freeze + after)
+
+        new_clip = concatenate_videoclips(before + freeze + after)
+        if self.update_start_end:
+            if clip.start is not None:
+                new_clip = new_clip.with_start(clip.start)
+            if clip.end is not None:
+                new_clip = new_clip.with_end(clip.end + self.freeze_duration)
+
+        return new_clip
diff --git a/moviepy/video/io/ffmpeg_tools.py b/moviepy/video/io/ffmpeg_tools.py
index c154d9b..8cb5351 100644
--- a/moviepy/video/io/ffmpeg_tools.py
+++ b/moviepy/video/io/ffmpeg_tools.py
@@ -240,7 +240,8 @@ def ffmpeg_version():
     cmd = [
         FFMPEG_BINARY,
         "-version",
-        "-v", "quiet",
+        "-v",
+        "quiet",
     ]
 
     result = subprocess.run(cmd, capture_output=True, text=True, check=True)


Test patch:
diff --git a/tests/test_Clip.py b/tests/test_Clip.py
index 0dcabf8..397f228 100644
--- a/tests/test_Clip.py
+++ b/tests/test_Clip.py
@@ -184,7 +184,6 @@ def test_clip_copy(copy_func):
         (3, 3, None, ValueError),  # start_time == duration
         (3, 1, -1, 1),  # negative end_time
         (None, 1, -1, ValueError),  # negative end_time for clip without duration
-        (1, 0, 2, ValueError),  # end_time after video end should raise exception
     ),
 )
 def test_clip_subclip(duration, start_time, end_time, expected_duration):
diff --git a/tests/test_VideoClip.py b/tests/test_VideoClip.py
index 6cd61bb..53c0481 100644
--- a/tests/test_VideoClip.py
+++ b/tests/test_VideoClip.py
@@ -389,16 +389,16 @@ def test_afterimage(util):
 
 def test_add():
     clip = VideoFileClip("media/fire2.mp4")
-    new_clip = clip[0:1] + clip[1.5:2]
-    assert new_clip.duration == 1.5
-    assert np.array_equal(new_clip[1.1], clip[1.6])
+    new_clip = clip[0:1] + clip[2:3.2]
+    assert new_clip.duration == 2.2
+    assert np.array_equal(new_clip[1.1], clip[2.1])
 
 
 def test_slice_tuples():
     clip = VideoFileClip("media/fire2.mp4")
-    new_clip = clip[0:1, 1.5:2]
-    assert new_clip.duration == 1.5
-    assert np.array_equal(new_clip[1.1], clip[1.6])
+    new_clip = clip[0:1, 2:3.2]
+    assert new_clip.duration == 2.2
+    assert np.array_equal(new_clip[1.1], clip[2.1])
 
 
 def test_slice_mirror():
diff --git a/tests/test_issues.py b/tests/test_issues.py
index 79a086d..ae2d6ac 100644
--- a/tests/test_issues.py
+++ b/tests/test_issues.py
@@ -303,8 +303,9 @@ def test_issue_470(util):
     audio_clip = AudioFileClip("media/crunching.mp3")
 
     # end_time is out of bounds
-    with pytest.raises(ValueError):
-        subclip = audio_clip.subclipped(start_time=6, end_time=9)
+    subclip = audio_clip.subclipped(start_time=6, end_time=9)
+
+    with pytest.raises(IOError):
         subclip.write_audiofile(wav_filename, write_logfile=True)
 
     # but this one should work..
@@ -333,7 +334,7 @@ def test_issue_636():
 
 def test_issue_655():
     video_file = "media/fire2.mp4"
-    for subclip in [(0, 2), (1, 2), (2, 2.10)]:
+    for subclip in [(0, 2), (1, 2), (2, 3)]:
         with VideoFileClip(video_file) as v:
             with v.subclipped(1, 2) as _:
                 pass