Skip to content

Commit

Permalink
Merge branch 'master' into pr-2117
Browse files Browse the repository at this point in the history
  • Loading branch information
OsaAjani authored Jan 30, 2025
2 parents 5f75103 + 06b5329 commit f2d3174
Show file tree
Hide file tree
Showing 16 changed files with 263 additions and 65 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ 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 Pillow default font on textclip
- Add support for ffmpeg v7

### Changed <!-- for changes in existing functionality -->
- Subclipping outside of clip boundaries now raise an exception
- Freeze effect no longer remove start and end
- Add a parameter to define audio codec of a clip

### Deprecated <!-- for soon-to-be removed features -->

Expand All @@ -32,6 +36,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix TimeMirror and TimeSymmetrize cutting last second of clip
- ImageSequenceClip was wrong when calculating fps with duration and no fps (see issue #2351)
- More consistent frame seek (see issue #2115 and PR #2117)
- Fix audiopreview not working with ffplay >= 7.0.0

## [v2.1.2](https://github.com/zulko/moviepy/tree/master)

[Full Changelog](https://github.com/zulko/moviepy/compare/v2.1.2...HEAD)
Expand Down
18 changes: 18 additions & 0 deletions moviepy/Clip.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,15 @@ def with_start(self, t, change_end=True):
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
----------
Expand Down Expand Up @@ -248,6 +257,15 @@ def with_end(self, t):
(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
----------
Expand Down
4 changes: 2 additions & 2 deletions moviepy/audio/AudioClip.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,9 @@ def write_audiofile(
"""
if not fps:
if hasattr(self, "fps"):
fps = 44100
else:
fps = self.fps
else:
fps = 44100

if codec is None:
name, ext = os.path.splitext(os.path.basename(filename))
Expand Down
25 changes: 17 additions & 8 deletions moviepy/audio/io/ffplay_audiopreviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from moviepy.config import FFPLAY_BINARY
from moviepy.decorators import requires_duration
from moviepy.tools import cross_platform_popen_params
from moviepy.video.io import ffmpeg_tools


class FFPLAY_AudioPreviewer:
Expand All @@ -24,7 +25,6 @@ class FFPLAY_AudioPreviewer:
nchannels:
Number of audio channels in the clip. Default to 2 channels.
"""

def __init__(
Expand All @@ -42,8 +42,22 @@ def __init__(
"s%dle" % (8 * nbytes),
"-ar",
"%d" % fps_input,
"-ac",
"%d" % nchannels,
]

# Adapt number of channels argument to ffplay version
ffplay_version = ffmpeg_tools.ffplay_version()[1]
if int(ffplay_version.split(".")[0]) >= 7:
cmd += [
"-ch_layout",
"stereo" if nchannels == 2 else "mono",
]
else:
cmd += [
"-ac",
"%d" % nchannels,
]

cmd += [
"-i",
"-",
]
Expand All @@ -62,11 +76,6 @@ def write_frames(self, frames_array):
_, ffplay_error = self.proc.communicate()
if ffplay_error is not None:
ffplay_error = ffplay_error.decode()
else:
# The error was redirected to a logfile with `write_logfile=True`,
# so read the error from that file instead
self.logfile.seek(0)
ffplay_error = self.logfile.read()

error = (
f"{err}\n\nMoviePy error: FFPLAY encountered the following error while "
Expand Down
4 changes: 2 additions & 2 deletions moviepy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ def check():
print(f"MoviePy: can't find or access ffmpeg in '{FFMPEG_BINARY}'.")

if try_cmd([FFPLAY_BINARY])[0]:
print(f"MoviePy: ffmpeg successfully found in '{FFPLAY_BINARY}'.")
print(f"MoviePy: ffplay successfully found in '{FFPLAY_BINARY}'.")
else: # pragma: no cover
print(f"MoviePy: can't find or access ffmpeg in '{FFPLAY_BINARY}'.")
print(f"MoviePy: can't find or access ffplay in '{FFPLAY_BINARY}'.")

if DOTENV:
print(f"\n.env file content at {DOTENV}:\n")
Expand Down
2 changes: 1 addition & 1 deletion moviepy/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2.1.1"
__version__ = "2.1.2"
59 changes: 37 additions & 22 deletions moviepy/video/VideoClip.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ def write_videofile(
write_logfile=write_logfile,
logger=logger,
)
# The audio is already encoded,
# so there is no need to encode it during video export
audio_codec = "copy"

ffmpeg_write_video(
self,
Expand All @@ -396,6 +399,7 @@ def write_videofile(
preset=preset,
write_logfile=write_logfile,
audiofile=audiofile,
audio_codec=audio_codec,
threads=threads,
ffmpeg_params=ffmpeg_params,
logger=logger,
Expand Down Expand Up @@ -1450,7 +1454,8 @@ class TextClip(ImageClip):
----------
font
Path to the font to use. Must be an OpenType font.
Path to the font to use. Must be an OpenType font. If set to None
(default) will use Pillow default font
text
A string of the text to write. Can be replaced by argument
Expand Down Expand Up @@ -1549,7 +1554,7 @@ class TextClip(ImageClip):
@convert_path_to_string("filename")
def __init__(
self,
font,
font=None,
text=None,
filename=None,
font_size=None,
Expand All @@ -1567,12 +1572,13 @@ def __init__(
transparent=True,
duration=None,
):
try:
_ = ImageFont.truetype(font)
except Exception as e:
raise ValueError(
"Invalid font {}, pillow failed to use it with error {}".format(font, e)
)
if font is not None:
try:
_ = ImageFont.truetype(font)
except Exception as e:
raise ValueError(
"Invalid font {}, pillow failed to use it with error {}".format(font, e)
)

if filename:
with open(filename, "r") as file:
Expand Down Expand Up @@ -1620,30 +1626,30 @@ def __init__(
allow_break=True,
)

if img_height is None:
img_height = self.__find_text_size(
# Add line breaks whenever needed
text = "\n".join(
self.__break_text(
width=img_width,
text=text,
font=font,
font_size=font_size,
stroke_width=stroke_width,
align=text_align,
spacing=interline,
max_width=img_width,
allow_break=True,
)[1]
)
)

# Add line breaks whenever needed
text = "\n".join(
self.__break_text(
width=img_width,
if img_height is None:
img_height = self.__find_text_size(
text=text,
font=font,
font_size=font_size,
stroke_width=stroke_width,
align=text_align,
spacing=interline,
)
)
max_width=img_width,
allow_break=True,
)[1]

elif method == "label":
if font_size is None and img_width is None:
Expand Down Expand Up @@ -1693,7 +1699,10 @@ def __init__(
bg_color = (0, 0, 0, 0)

img = Image.new(img_mode, (img_width, img_height), color=bg_color)
pil_font = ImageFont.truetype(font, font_size)
if font:
pil_font = ImageFont.truetype(font, font_size)
else:
pil_font = ImageFont.load_default(font_size)
draw = ImageDraw.Draw(img)

# Dont need allow break here, because we already breaked in caption
Expand Down Expand Up @@ -1760,7 +1769,10 @@ def __break_text(
) -> List[str]:
"""Break text to never overflow a width"""
img = Image.new("RGB", (1, 1))
font_pil = ImageFont.truetype(font, font_size)
if font:
font_pil = ImageFont.truetype(font, font_size)
else:
font_pil = ImageFont.load_default(font_size)
draw = ImageDraw.Draw(img)

lines = []
Expand Down Expand Up @@ -1843,7 +1855,10 @@ def __find_text_size(
``real_font_size + (stroke_width * 2) + (lines - 1) * height``
"""
img = Image.new("RGB", (1, 1))
font_pil = ImageFont.truetype(font, font_size)
if font:
font_pil = ImageFont.truetype(font, font_size)
else:
font_pil = ImageFont.load_default(font_size)
ascent, descent = font_pil.getmetrics()
real_font_size = ascent + descent
draw = ImageDraw.Draw(img)
Expand Down
14 changes: 13 additions & 1 deletion moviepy/video/fx/Freeze.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -40,4 +44,12 @@ def apply(self, clip: Clip) -> Clip:
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
29 changes: 20 additions & 9 deletions moviepy/video/io/ffmpeg_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ def parse(self):
self.result["duration"] = self.parse_duration(line)

# parse global bitrate (in kb/s)
bitrate_match = re.search(r"bitrate: (\d+) kb/s", line)
bitrate_match = re.search(r"bitrate: (\d+) k(i?)b/s", line)
self.result["bitrate"] = (
int(bitrate_match.group(1)) if bitrate_match else None
)
Expand Down Expand Up @@ -492,12 +492,12 @@ def parse(self):
# for default streams, set their numbers globally, so it's
# easy to get without iterating all
if self._current_stream["default"]:
self.result[
f"default_{stream_type_lower}_input_number"
] = input_number
self.result[
f"default_{stream_type_lower}_stream_number"
] = stream_number
self.result[f"default_{stream_type_lower}_input_number"] = (
input_number
)
self.result[f"default_{stream_type_lower}_stream_number"] = (
stream_number
)

# exit chapter
if self._current_chapter:
Expand Down Expand Up @@ -544,8 +544,11 @@ def parse(self):

if self._current_stream["stream_type"] == "video":
field, value = self.video_metadata_type_casting(field, value)
# ffmpeg 7 now use displaymatrix instead of rotate
if field == "rotate":
self.result["video_rotation"] = value
elif field == "displaymatrix":
self.result["video_rotation"] = value

# multiline metadata value parsing
if field == "":
Expand Down Expand Up @@ -660,7 +663,7 @@ def parse_audio_stream_data(self, line):
# AttributeError: 'NoneType' object has no attribute 'group'
# ValueError: invalid literal for int() with base 10: '<string>'
stream_data["fps"] = "unknown"
match_audio_bitrate = re.search(r"(\d+) kb/s", line)
match_audio_bitrate = re.search(r"(\d+) k(i?)b/s", line)
stream_data["bitrate"] = (
int(match_audio_bitrate.group(1)) if match_audio_bitrate else None
)
Expand Down Expand Up @@ -688,7 +691,7 @@ def parse_video_stream_data(self, line):
% (self.filename, self.infos)
)

match_bitrate = re.search(r"(\d+) kb/s", line)
match_bitrate = re.search(r"(\d+) k(i?)b/s", line)
stream_data["bitrate"] = int(match_bitrate.group(1)) if match_bitrate else None

# Get the frame rate. Sometimes it's 'tbr', sometimes 'fps', sometimes
Expand Down Expand Up @@ -801,6 +804,14 @@ def video_metadata_type_casting(self, field, value):
"""Cast needed video metadata fields to other types than the default str."""
if field == "rotate":
return (field, float(value))

elif field == "displaymatrix":
match = re.search(r"[-+]?\d+(\.\d+)?", value)
if match:
# We must multiply by -1 because displaymatrix return info
# about how to rotate to show video, not about video rotation
return (field, float(match.group()) * -1)

return (field, value)


Expand Down
Loading

0 comments on commit f2d3174

Please sign in to comment.