Skip to content

Commit

Permalink
Merge pull request #2331 from OsaAjani/fix-issue-2289
Browse files Browse the repository at this point in the history
Add support for new argument ch_layout on ffmpeg >= 7 and make compatible with ffmpeg 7
  • Loading branch information
OsaAjani authored Jan 22, 2025
2 parents cb02c8f + 25b61eb commit 4ad4008
Show file tree
Hide file tree
Showing 8 changed files with 144 additions and 32 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,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

0 comments on commit 4ad4008

Please sign in to comment.