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

Fix issue 2269 - And a lot of other things #2307

Merged
merged 15 commits into from
Jan 5, 2025
2 changes: 1 addition & 1 deletion .github/workflows/formatting_linting.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,4 @@ jobs:
python -m pip install --upgrade wheel pip
pip install .[lint]
- name: Run isort
run: isort --check-only --diff moviepy setup.py scripts docs/conf.py examples tests
run: isort --check-only --diff moviepy scripts docs/conf.py examples tests
Binary file added media/transparent.webm
Binary file not shown.
1 change: 0 additions & 1 deletion moviepy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Imports everything that you need from the MoviePy submodules so that every thing
can be directly imported with ``from moviepy import *``.
"""

from moviepy.audio import fx as afx
from moviepy.audio.AudioClip import (
AudioArrayClip,
Expand Down
1 change: 0 additions & 1 deletion moviepy/audio/io/readers.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,6 @@ def seek(self, pos):
t = 1.0 * pos / self.fps
self.initialize(t)
elif pos > self.pos:
# print pos
self.skip_chunk(pos - self.pos)
# last case standing: pos = current pos
self.pos = pos
Expand Down
71 changes: 69 additions & 2 deletions moviepy/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ def ffmpeg_escape_filename(filename):

That will ensure the filename doesn't start with a '-' (which would raise an error)
"""
if filename.startswith('-') :
filename = './' + filename
if filename.startswith("-"):
filename = "./" + filename

return filename

Expand Down Expand Up @@ -244,3 +244,70 @@ def no_display_available() -> bool:
return True

return False


def compute_position(
clip1_size: tuple, clip2_size: tuple, pos: any, relative: bool = False
) -> tuple[int, int]:
"""Return the position to put clip 1 on clip 2 based on both clip size
and the position of clip 1, as return by clip1.pos() method

Parameters
----------
clip1_size : tuple
The width and height of clip1 (e.g., (width, height)).
clip2_size : tuple
The width and height of clip2 (e.g., (width, height)).
pos : Any
The position of clip1 as returned by the `clip1.pos()` method.
relative: bool
Is the position relative (% of clip size), default False.

Returns
-------
tuple[int, int]
A tuple (x, y) representing the top-left corner of clip1 relative to clip2.

Notes
-----
For more information on `pos`, see the documentation for `VideoClip.with_position`.
"""
if pos is None:
pos = (0, 0)

# preprocess short writings of the position
if isinstance(pos, str):
pos = {
"center": ["center", "center"],
"left": ["left", "center"],
"right": ["right", "center"],
"top": ["center", "top"],
"bottom": ["center", "bottom"],
}[pos]
else:
pos = list(pos)

# is the position relative (given in % of the clip's size) ?
if relative:
for i, dim in enumerate(clip2_size):
if not isinstance(pos[i], str):
pos[i] = dim * pos[i]

if isinstance(pos[0], str):
D = {
"left": 0,
"center": (clip2_size[0] - clip1_size[0]) / 2,
"right": clip2_size[0] - clip1_size[0],
}
pos[0] = D[pos[0]]

if isinstance(pos[1], str):
D = {
"top": 0,
"center": (clip2_size[1] - clip1_size[1]) / 2,
"bottom": clip2_size[1] - clip1_size[1],
}
pos[1] = D[pos[1]]

# Return as int, rounding if necessary
return (int(pos[0]), int(pos[1]))
185 changes: 134 additions & 51 deletions moviepy/video/VideoClip.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,12 @@
requires_fps,
use_clip_fps_by_default,
)
from moviepy.tools import extensions_dict, find_extension
from moviepy.tools import compute_position, extensions_dict, find_extension
from moviepy.video.fx.Crop import Crop
from moviepy.video.fx.Resize import Resize
from moviepy.video.fx.Rotate import Rotate
from moviepy.video.io.ffmpeg_writer import ffmpeg_write_video
from moviepy.video.io.gif_writers import write_gif_with_imageio
from moviepy.video.tools.drawing import blit


class VideoClip(Clip):
Expand Down Expand Up @@ -714,72 +713,146 @@ def fill_array(self, pre_array, shape=(0, 0)):
post_array = np.hstack((post_array, x_1))
return post_array

def blit_on(self, picture, t):
"""Returns the result of the blit of the clip's frame at time `t`
def compose_on(self, background: Image.Image, t) -> Image.Image:
"""Returns the result of the clip's frame at time `t` on top
on the given `picture`, the position of the clip being given
by the clip's ``pos`` attribute. Meant for compositing.
"""
wf, hf = picture.size

If the clip/backgrounds have transparency the transparency will
be accounted for.

The return is a Pillow Image

Parameters
----------
backrgound (Image)
The background image to apply current clip on top of
if the background image is transparent it must be given as a RGBA image

t
The time of clip to apply on top of clip

Return
"""
ct = t - self.start # clip time

# GET IMAGE AND MASK IF ANY
img = self.get_frame(ct).astype("uint8")
im_img = Image.fromarray(img)
clip_frame = self.get_frame(ct).astype("uint8")
clip_img = Image.fromarray(clip_frame)

if self.mask is not None:
mask = (self.mask.get_frame(ct) * 255).astype("uint8")
im_mask = Image.fromarray(mask).convert("L")
clip_mask = (self.mask.get_frame(ct) * 255).astype("uint8")
clip_mask_img = Image.fromarray(clip_mask).convert("L")

if im_img.size != im_mask.size:
bg_size = (
max(im_img.size[0], im_mask.size[0]),
max(im_img.size[1], im_mask.size[1]),
)

im_img_bg = Image.new("RGB", bg_size, "black")
im_img_bg.paste(im_img, (0, 0))
# Resize clip_mask_img to match clip_img, always use top left corner
if clip_mask_img.size != clip_img.size:
mask_width, mask_height = clip_mask_img.size
img_width, img_height = clip_img.size

im_mask_bg = Image.new("L", bg_size, 0)
im_mask_bg.paste(im_mask, (0, 0))
if mask_width > img_width or mask_height > img_height:
# Crop mask if it is larger
clip_mask_img = clip_mask_img.crop((0, 0, img_width, img_height))
else:
# Fill mask with 0 if it is smaller
new_mask = Image.new("L", (img_width, img_height), 0)
new_mask.paste(clip_mask_img, (0, 0))
clip_mask_img = new_mask

im_img, im_mask = im_img_bg, im_mask_bg
clip_img = clip_img.convert("RGBA")
clip_img.putalpha(clip_mask_img)

else:
im_mask = None

wi, hi = im_img.size
# SET POSITION
pos = self.pos(ct)
pos = compute_position(clip_img.size, background.size, pos, self.relative_pos)

# If neither background nor clip have alpha layer (check if mode end
# with A), we can juste use pillow paste
if clip_img.mode[-1] != "A" and background.mode[-1] != "A":
background.paste(clip_img, pos)
return background

# For images with transparency we must use pillow alpha composite
# instead of a simple paste, because pillow paste dont work nicely
# with alpha compositing
if background.mode[-1] != "A":
background = background.convert("RGBA")

if clip_img.mode[-1] != "A":
clip_img = clip_img.convert("RGBA")

# We need both image to do the same size for alpha compositing in pillow
# so we must start by making a fully transparent canvas of background's
# size and paste our clip img into it in position pos, only then can we
# composite this canvas on top of background
canvas = Image.new("RGBA", (background.width, background.height), (0, 0, 0, 0))
canvas.paste(clip_img, pos)
result = Image.alpha_composite(background, canvas)
return result

# preprocess short writings of the position
if isinstance(pos, str):
pos = {
"center": ["center", "center"],
"left": ["left", "center"],
"right": ["right", "center"],
"top": ["center", "top"],
"bottom": ["center", "bottom"],
}[pos]
else:
pos = list(pos)
def compose_mask(self, background_mask: np.ndarray, t: float) -> np.ndarray:
"""Returns the result of the clip's mask at time `t` composited
on the given `background_mask`, the position of the clip being given
by the clip's ``pos`` attribute. Meant for compositing.

(warning: only use this function to blit two masks together, never images)

# is the position relative (given in % of the clip's size) ?
if self.relative_pos:
for i, dim in enumerate([wf, hf]):
if not isinstance(pos[i], str):
pos[i] = dim * pos[i]
Parameters
----------
background_mask:
The underlying mask onto which the clip mask will be composed.

t:
The time position in the clip at which to extract the mask.
"""
ct = t - self.start # clip time
clip_mask = self.get_frame(ct).astype("float")

if isinstance(pos[0], str):
D = {"left": 0, "center": (wf - wi) / 2, "right": wf - wi}
pos[0] = D[pos[0]]
# numpy shape is H*W not W*H
bg_h, bg_w = background_mask.shape
clip_h, clip_w = clip_mask.shape

if isinstance(pos[1], str):
D = {"top": 0, "center": (hf - hi) / 2, "bottom": hf - hi}
pos[1] = D[pos[1]]
# SET POSITION
pos = self.pos(ct)
pos = compute_position((clip_w, clip_h), (bg_w, bg_h), pos, self.relative_pos)

# ALPHA COMPOSITING
# Determine the base_mask region to merge size
x_start = int(max(pos[0], 0)) # Dont go under 0 left
x_end = int(min(pos[0] + clip_w, bg_w)) # Dont go over base_mask width
y_start = int(max(pos[1], 0)) # Dont go under 0 top
y_end = int(min(pos[1] + clip_h, bg_h)) # Dont go over base_mask height

# Determine the clip_mask region to overlapp
# Dont go under 0 for horizontal, if we have negative margin of X px start at X
# And dont go over clip width
clip_x_start = int(max(0, -pos[0]))
clip_x_end = int(clip_x_start + min((x_end - x_start), (clip_w - clip_x_start)))
# same for vertical
clip_y_start = int(max(0, -pos[1]))
clip_y_end = int(clip_y_start + min((y_end - y_start), (clip_h - clip_y_start)))

# Blend the overlapping regions
# The calculus is base_opacity + clip_opacity * (1 - base_opacity)
# this ensure that masks are drawn in the right order and
# the contribution of each mask is proportional to their transparency
#
# Note :
# Thinking in transparency is hard, as we tend to think
# that 50% opaque + 40% opaque = 90% opacity, when it really its 70%
# It's a lot easier to think in terms of "passing light"
# Consider I emit 100 photons, and my first layer is 50% opaque, meaning it
# will "stop" 50% of the photons, I'll have 50 photons left
# now my second layer is blocking 40% of thoses 50 photons left
# blocking 50 * 0.4 = 20 photons, and leaving me with only 30 photons
# So, by adding two layer of 50% and 40% opacity my finaly opacity is only
# of (100-30)*100 = 70% opacity !
background_mask[y_start:y_end, x_start:x_end] = background_mask[
y_start:y_end, x_start:x_end
] + clip_mask[clip_y_start:clip_y_end, clip_x_start:clip_x_end] * (
1 - background_mask[y_start:y_end, x_start:x_end]
)

pos = map(int, pos)
return blit(im_img, picture, pos, mask=im_mask)
return background_mask

def with_background_color(self, size=None, color=(0, 0, 0), pos=None, opacity=None):
"""Place the clip on a colored background.
Expand Down Expand Up @@ -857,10 +930,20 @@ def with_audio(self, audioclip):

@outplace
def with_mask(self, mask: Union["VideoClip", str] = "auto"):
"""Set the clip's mask.
"""
Set the clip's mask.

Returns a copy of the VideoClip with the mask attribute set to
``mask``, which must be a greyscale (values in 0-1) VideoClip.

Parameters
----------
mask : Union["VideoClip", str], optional
The mask to apply to the clip.
If set to "auto", a default mask will be generated:
- If the clip has a constant size, a solid mask with a value of 1.0
will be created.
- Otherwise, a dynamic solid mask will be created based on the frame size.
"""
if mask == "auto":
if self.has_constant_size:
Expand Down Expand Up @@ -1321,7 +1404,7 @@ class ColorClip(ImageClip):
----------

size
Size (width, height) in pixels of the clip.
Size tuple (width, height) in pixels of the clip.

color
If argument ``is_mask`` is False, ``color`` indicates
Expand Down
Loading
Loading