Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for new argument ch_layout on ffmpeg >= 7 and make compatible with ffmpeg 7 #2331

Merged
merged 3 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve perfs of decorator by pre-computing arguments
- Fix textclip being cut or of impredictable height (see issues #2325, #2260 and #2268)
- Fix TimeMirror and TimeSymmetrize cutting last second of clip
- Fix audiopreview not working with ffplay >= 7.0.0

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

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
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"
29 changes: 20 additions & 9 deletions moviepy/video/io/ffmpeg_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,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 @@ -476,12 +476,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 @@ -528,8 +528,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 @@ -644,7 +647,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 @@ -672,7 +675,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 @@ -785,6 +788,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
82 changes: 81 additions & 1 deletion moviepy/video/io/ffmpeg_tools.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Miscellaneous bindings to ffmpeg."""

import os
import re
import subprocess

from moviepy.config import FFMPEG_BINARY
from moviepy.config import FFMPEG_BINARY, FFPLAY_BINARY
from moviepy.decorators import convert_parameter_to_seconds, convert_path_to_string
from moviepy.tools import ffmpeg_escape_filename, subprocess_call

Expand Down Expand Up @@ -207,3 +209,81 @@ def ffmpeg_stabilize_video(
cmd.append("-y")

subprocess_call(cmd, logger=logger)


def ffmpeg_version():
"""
Retrieve the FFmpeg version.

This function retrieves both the full and numeric version of FFmpeg
by executing the `ffmpeg -version` command. The full version includes
additional details like build information, while the numeric version
contains only the version numbers (e.g., '7.0.2').

Return
------
tuple
A tuple containing:
- `full_version` (str): The complete version string (e.g., '7.0.2-static').
- `numeric_version` (str): The numeric version string (e.g., '7.0.2').

Example
-------
>>> ffmpeg_version()
('7.0.2-static', '7.0.2')

Raises
------
subprocess.CalledProcessError
If the FFmpeg command fails to execute properly.
"""
cmd = [
FFMPEG_BINARY,
"-version",
"-v", "quiet",
]

result = subprocess.run(cmd, capture_output=True, text=True, check=True)

# Extract the version number from the first line of output
full_version = result.stdout.splitlines()[0].split()[2]
numeric_version = re.match(r"^[0-9.]+", full_version).group(0)
return (full_version, numeric_version)


def ffplay_version():
"""
Retrieve the FFplay version.

This function retrieves both the full and numeric version of FFplay
by executing the `ffplay -version` command. The full version includes
additional details like build information, while the numeric version
contains only the version numbers (e.g., '6.0.1').

Return
------
tuple
A tuple containing:
- `full_version` (str): The complete version string (e.g., '6.0.1-static').
- `numeric_version` (str): The numeric version string (e.g., '6.0.1').

Example
-------
>>> ffplay_version()
('6.0.1-static', '6.0.1')

Raises
------
subprocess.CalledProcessError
If the FFplay command fails to execute properly.
"""
cmd = [
FFPLAY_BINARY,
"-version",
]

result = subprocess.run(cmd, capture_output=True, text=True, check=True)
# Extract the version number from the first line of output
full_version = result.stdout.splitlines()[0].split()[2]
numeric_version = re.match(r"^[0-9.]+", full_version).group(0)
return (full_version, numeric_version)
2 changes: 2 additions & 0 deletions tests/test_PR.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ def test_PR_528(util):


def test_PR_529():
# print(ffmpeg_tools.ffplay_version())
print(ffmpeg_tools.ffmpeg_version())
with VideoFileClip("media/fire2.mp4") as video_clip:
assert video_clip.rotation == 180

Expand Down
8 changes: 7 additions & 1 deletion tests/test_ffmpeg_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
FFmpegInfosParser,
ffmpeg_parse_infos,
)
from moviepy.video.io.ffmpeg_tools import ffmpeg_version
from moviepy.video.io.VideoFileClip import VideoFileClip
from moviepy.video.VideoClip import BitmapClip, ColorClip

Expand Down Expand Up @@ -59,7 +60,7 @@ def test_ffmpeg_parse_infos_video_nframes():
("decode_file", "expected_duration"),
(
(False, 30),
(True, 30.02),
(True, 30),
),
ids=(
"decode_file=False",
Expand All @@ -69,6 +70,11 @@ def test_ffmpeg_parse_infos_video_nframes():
def test_ffmpeg_parse_infos_decode_file(decode_file, expected_duration):
"""Test `decode_file` argument of `ffmpeg_parse_infos` function."""
d = ffmpeg_parse_infos("media/big_buck_bunny_0_30.webm", decode_file=decode_file)

# On old version of ffmpeg, duration and video duration was different
if int(ffmpeg_version()[1].split(".")[0]) < 7:
expected_duration += 0.02

assert d["duration"] == expected_duration

# check metadata is fine
Expand Down
27 changes: 15 additions & 12 deletions tests/test_ffmpeg_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
ffmpeg_extract_subclip,
ffmpeg_resize,
ffmpeg_stabilize_video,
ffmpeg_version,
)
from moviepy.video.io.VideoFileClip import VideoFileClip

Expand Down Expand Up @@ -57,9 +58,10 @@ def test_ffmpeg_resize(util):
ffmpeg_resize("media/bitmap.mp4", outputfile, expected_size, logger=None)
assert os.path.isfile(outputfile)

# overwrite file
with pytest.raises(OSError):
ffmpeg_resize("media/bitmap.mp4", outputfile, expected_size, logger=None)
# overwrite file on old version of ffmpeg
if int(ffmpeg_version()[1].split(".")[0]) < 7:
with pytest.raises(OSError):
ffmpeg_resize("media/bitmap.mp4", outputfile, expected_size, logger=None)

clip = VideoFileClip(outputfile)
assert clip.size[0] == expected_size[0]
Expand Down Expand Up @@ -98,15 +100,16 @@ def test_ffmpeg_stabilize_video(util):
expected_filepath = os.path.join(stabilize_video_tempdir, "foo.mp4")
assert os.path.isfile(expected_filepath)

# don't overwrite file
with pytest.raises(OSError):
ffmpeg_stabilize_video(
"media/bitmap.mp4",
output_dir=stabilize_video_tempdir,
outputfile="foo.mp4",
overwrite_file=False,
logger=None,
)
# don't overwrite file on old version of ffmpeg
if int(ffmpeg_version()[1].split(".")[0]) < 7:
with pytest.raises(OSError):
ffmpeg_stabilize_video(
"media/bitmap.mp4",
output_dir=stabilize_video_tempdir,
outputfile="foo.mp4",
overwrite_file=False,
logger=None,
)

if os.path.isdir(stabilize_video_tempdir):
try:
Expand Down
Loading