"""Powerpoint-like transitions implemented in VapourSynth."""
__all__ = [
"cover",
"cube_rotate",
"curtain_cover",
"curtain_reveal",
"fade",
"fade_from_black",
"fade_to_black",
"linear_boundary",
"poly_fade",
"push",
"reveal",
"slide_expand",
"squeeze_expand",
"squeeze_slide",
"wipe",
]
import enum
import math
from fractions import Fraction
from typing import Callable, Optional, Tuple, TYPE_CHECKING
from warnings import simplefilter, warn
import vapoursynth as vs
try:
from ._metadata import __author__, __date__, __version__ # type: ignore
except (ImportError, ModuleNotFoundError):
__author__ = __date__ = __version__ = "unknown (portable version)" # type: ignore
simplefilter("always")
core = vs.core
[docs]class Direction(str, enum.Enum):
"""Direction enumeration.
Members can be simply referenced by their names,
i.e. ``vs_transitions.LEFT`` instead of ``vs_transitions.Direction.LEFT``.
"""
LEFT = "left"
RIGHT = "right"
UP = "up"
DOWN = "down"
HORIZONTAL = "horizontal"
VERTICAL = "vertical"
LEFT = Direction.LEFT
RIGHT = Direction.RIGHT
UP = Direction.UP
DOWN = Direction.DOWN
HORIZONTAL = Direction.HORIZONTAL
VERTICAL = Direction.VERTICAL
__all__ += list(Direction.__members__)
[docs]class MiscConstants(str, enum.Enum):
"""Miscellanious enumeration for some functions.
Members can be simply referenced by their names,
i.e. ``vs_transitions.SLIDE`` instead of ``vs_transitions.MiscConstants.SLIDE``.
"""
SLIDE = "slide"
SQUEEZE = "squeeze"
EXPAND = "expand"
SLIDE = MiscConstants.SLIDE
SQUEEZE = MiscConstants.SQUEEZE
EXPAND = MiscConstants.EXPAND
__all__ += list(MiscConstants.__members__)
def _check_clips(frames: int, caller: Callable, *clips: vs.VideoNode, **kwargs) -> None:
"""General checker for clip formats, resolutions, length, and other keywords.
Possible kwargs:
'subsampling': checks that all clips have 444 subsampling for resize purposes
"""
if frames <= 0:
raise ValueError(f"{caller.__name__}: `frames` cannot be less than 1")
same_check = set()
for clip in clips:
if clip.format is None:
raise ValueError(f"{caller.__name__}: all clips must be constant-format")
if 0 in (clip.width, clip.height):
raise ValueError(f"{caller.__name__}: all clips must be constant-resolution")
if clip.num_frames < frames:
raise ValueError(f"{caller.__name__}: all clips must have at least {frames} frames")
same_check.add((clip.format.id, clip.width, clip.height))
if kwargs:
if ("subsampling" in kwargs) and kwargs["subsampling"]:
if clip.format.subsampling_w != 0 or clip.format.subsampling_h != 0:
raise ValueError(
f"{caller.__name__}: all clips must have 444 chroma subsampling for a non-mod2 resize"
)
if len(same_check) > 1:
raise ValueError(f"{caller.__name__}: all clips must be same format and resolution")
def _return_combo(
clip1: Optional[vs.VideoNode],
clip_middle: vs.VideoNode,
clip2: Optional[vs.VideoNode],
) -> vs.VideoNode:
"""Prevents splicing empty clips.
:param clip1: optional start clip
:param clip_middle: mandatory middle clip
:param clip2: optional ending clip
:return: splice of existing clips in order
"""
if clip1 is not None and clip2 is not None:
return clip1 + clip_middle + clip2
elif clip1 is not None and clip2 is None:
return clip1 + clip_middle
elif clip1 is None and clip2 is not None:
return clip_middle + clip2
elif clip1 is None and clip2 is None:
return clip_middle
def _transition_clips(
clip1: vs.VideoNode, clip2: vs.VideoNode, frames: int
) -> Tuple[Optional[vs.VideoNode], Optional[vs.VideoNode], vs.VideoNode, vs.VideoNode]:
"""Returns clean (non-transition) and transition sections of the given clips based on frames."""
if clip1.num_frames == frames:
clip1_t_zone = clip1
clip1_clean = None
else:
clip1_t_zone = clip1[-frames:]
clip1_clean = clip1[:-frames]
if clip2.num_frames == frames:
clip2_t_zone = clip2
clip2_clean = None
else:
clip2_t_zone = clip2[:frames]
clip2_clean = clip2[frames:]
return clip1_clean, clip2_clean, clip1_t_zone, clip2_t_zone
[docs]def fade(clipa: vs.VideoNode, clipb: vs.VideoNode, frames: Optional[int] = None) -> vs.VideoNode:
"""Cross-fade clips."""
frames_ = frames or min(clipa.num_frames, clipb.num_frames)
if TYPE_CHECKING:
assert isinstance(frames_, int)
_check_clips(frames_, fade, clipa, clipb)
clipa_clean, clipb_clean, clipa_fade_zone, clipb_fade_zone = _transition_clips(clipa, clipb, frames_)
def _fade(n: int) -> vs.VideoNode:
progress = Fraction(n, frames_ - 1)
if progress == 0:
return clipa_fade_zone
elif progress == 1:
return clipb_fade_zone
else:
return core.std.Merge(
clipa_fade_zone,
clipb_fade_zone,
weight=[float(progress)],
)
faded = core.std.FrameEval(clipa_fade_zone, _fade)
return _return_combo(clipa_clean, faded, clipb_clean)
[docs]def poly_fade(
clipa: vs.VideoNode,
clipb: vs.VideoNode,
frames: Optional[int] = None,
exponent: int = 1,
) -> vs.VideoNode:
"""Cross-fade clips according to a curve.
:param exponent: An integer in the range from 1-5 (inclusive)
where 1 represents a parabolic curve, 2 represents a quartic curve,
and higher powers more resembling an tight ease-in-out function with constant speed for most of the transition.
An `exponent` of ``1`` is probably most useful,
as higher exponents tend towards a constant speed and therefore are
almost indistinguishable from a normal :func:`fade`.
"""
if not (1 <= exponent <= 5):
raise ValueError("poly_fade: exponent must be an int between 1 and 5 (inclusive)")
frames_ = frames or min(clipa.num_frames, clipb.num_frames)
if TYPE_CHECKING:
assert isinstance(frames_, int)
_check_clips(frames_, fade, clipa, clipb)
clipa_clean, clipb_clean, clipa_fade_zone, clipb_fade_zone = _transition_clips(clipa, clipb, frames_)
def get_pos(x: Fraction) -> Fraction:
"""Returns position as a fractions.Fraction, based on a input percentage fractions.Fraction"""
def _curve(y: Fraction) -> Fraction:
return -(((2 * y - 1) ** (2 * exponent + 1)) / (4 * exponent + 2)) + y - Fraction(1, 2)
return ((_curve(Fraction(1, 1)) - _curve(Fraction())) ** -1) * (_curve(x) - _curve(Fraction()))
def _fade(n: int) -> vs.VideoNode:
progress = Fraction(n, frames_ - 1)
if progress == 0:
return clipa_fade_zone
elif progress == 1:
return clipb_fade_zone
else:
return core.std.Merge(clipa_fade_zone, clipb_fade_zone, weight=[float(get_pos(progress))])
faded = core.std.FrameEval(core.std.BlankClip(clipa, length=frames_), _fade)
return _return_combo(clipa_clean, faded, clipb_clean)
[docs]def fade_to_black(src_clip: vs.VideoNode, frames: Optional[int] = None) -> vs.VideoNode:
"""Simple convenience function to :func:`fade` a clip to black.
`frames` will be the number of frames consumed from the end of the `src_clip` during the transition.
The first frame of the transition will be the first frame of the `src_clip`,
while the last frame of the transition will be a pure black frame.
If `frames` is not given, will fade to black over the entire duration of the `src_clip`.
"""
if src_clip.format is None:
raise ValueError("fade_to_black: `src_clip` must be a constant format VideoNode")
black_clip = core.std.BlankClip(format=vs.GRAY8, length=frames, color=[0])
if TYPE_CHECKING:
assert black_clip.format is not None
assert src_clip.format is not None
black_clip_resized = black_clip.resize.Point(
width=src_clip.width,
height=src_clip.height,
format=black_clip.format.replace(
color_family=src_clip.format.color_family,
sample_type=src_clip.format.sample_type,
bits_per_sample=src_clip.format.bits_per_sample,
subsampling_w=src_clip.format.subsampling_w,
subsampling_h=src_clip.format.subsampling_h,
).id,
)
return fade(src_clip, black_clip_resized, frames)
[docs]def fade_from_black(src_clip: vs.VideoNode, frames: Optional[int] = None) -> vs.VideoNode:
"""Simple convenience function to :func:`fade` a clip into view from black.
`frames` will be the number of frames consumed from the start of the `src_clip` during the transition.
The first frame of the transition will be a pure black frame,
while the last frame of the transition will be the last frame of the `src_clip`.
If `frames` is not given, will fade in over the entire duration of the `src_clip`.
"""
if src_clip.format is None:
raise ValueError("fade_to_black: `src_clip` must be a constant format VideoNode")
black_clip = core.std.BlankClip(format=vs.GRAY8, length=frames, color=[0])
if TYPE_CHECKING:
assert black_clip.format is not None
assert src_clip.format is not None
black_clip_resized = black_clip.resize.Point(
width=src_clip.width,
height=src_clip.height,
format=black_clip.format.replace(
color_family=src_clip.format.color_family,
sample_type=src_clip.format.sample_type,
bits_per_sample=src_clip.format.bits_per_sample,
subsampling_w=src_clip.format.subsampling_w,
subsampling_h=src_clip.format.subsampling_h,
).id,
)
return fade(black_clip_resized, src_clip, frames)
[docs]def wipe(
clipa: vs.VideoNode,
clipb: vs.VideoNode,
frames: Optional[int] = None,
direction: Direction = Direction.LEFT,
) -> vs.VideoNode:
"""A moving directional fade.
Similar to a :func:`fade`, but with a moving mask.
The `direction` will be the direction the fade progresses towards.
(i.e. the second clip begins fading in from the **opposite** given direction,
and the first clip begins fading out starting from the **opposite** given direction,
progressing towards `direction`)
Uses a pure white to black gradient for the fade.
If possible, uses `numpy <https://pypi.org/project/numpy/>`_ to generate the mask.
If the numpy module is not found, falls back to a slower
and possibly less accurate approach using lists
and the ``ctypes`` module for writing to a VapourSynth frame.
"""
if direction not in [Direction.LEFT, Direction.RIGHT, Direction.UP, Direction.DOWN]:
raise ValueError("wipe: give a proper direction")
frames_ = frames or min(clipa.num_frames, clipb.num_frames)
if TYPE_CHECKING:
assert isinstance(frames_, int)
_check_clips(frames_, wipe, clipa, clipb)
clipa_clean, clipb_clean, clipa_wipe_zone, clipb_wipe_zone = _transition_clips(clipa, clipb, frames_)
blank_clip = core.std.BlankClip(width=1 << 16, height=1, format=vs.GRAYS, color=[0.0], length=1)
write_frame = blank_clip.get_frame(0).copy()
try:
import numpy as np
def frame_writer(n, f):
if n is not None:
pass
fout = f.copy()
ptr = np.asarray(fout.get_write_array(0))
ptr[0] = np.linspace(0, 1, 1 << 16)
return fout
mask = blank_clip.std.ModifyFrame([blank_clip], frame_writer)
except (ImportError, ModuleNotFoundError):
warn("wipe: numpy module not found, falling back to slower less accurate method", Warning)
import ctypes
ptr_type = ctypes.c_float * (1 << 16)
vs_ptr = ctypes.cast(write_frame.get_write_ptr(0), ctypes.POINTER(ptr_type))
float_lin_array = [i / ((1 << 16) - 1) for i in list(range(1 << 16))]
float_Arr_ptr = ctypes.pointer(ptr_type(*float_lin_array))
vs_ptr[0] = float_Arr_ptr[0]
mask = blank_clip.std.ModifyFrame([blank_clip], lambda n, f: write_frame)
mask = mask.grain.Add()
if TYPE_CHECKING:
assert mask.format is not None
assert clipa.format is not None
mask_horiz = mask.resize.Spline64(
clipa.width,
clipa.height,
dither_type="error_diffusion",
format=mask.format.replace(
bits_per_sample=clipa.format.bits_per_sample, color_family=vs.GRAY, sample_type=clipa.format.sample_type
).id,
matrix_in_s="rgb",
)
mask_vert = core.std.Transpose(mask).resize.Spline64(
clipa.width,
clipa.height,
dither_type="error_diffusion",
format=mask.format.replace(
bits_per_sample=clipa.format.bits_per_sample, color_family=vs.GRAY, sample_type=clipa.format.sample_type
).id,
matrix_in_s="rgb",
)
black_clip = core.std.BlankClip(mask_horiz, length=1, color=[0])
white_clip = core.std.BlankClip(mask_horiz, length=1, color=[(1 << clipa.format.bits_per_sample) - 1])
if direction in [Direction.LEFT, Direction.RIGHT]:
stack = core.std.StackHorizontal([black_clip, mask_horiz, white_clip])
w = mask_horiz.width
if direction == Direction.LEFT:
def _wipe(n: int) -> vs.VideoNode:
stack_ = stack.resize.Spline36(
width=w,
src_left=2 * w * n / (frames_ - 1),
src_width=w,
)
return core.std.MaskedMerge(clipa_wipe_zone, clipb_wipe_zone, stack_)
elif direction == Direction.RIGHT:
stack = core.std.FlipHorizontal(stack)
def _wipe(n: int) -> vs.VideoNode:
stack_ = stack.resize.Spline36(
width=w,
src_left=(2 * w) * (1 - n / (frames_ - 1)),
src_width=w,
)
return core.std.MaskedMerge(clipa_wipe_zone, clipb_wipe_zone, stack_)
elif direction in [Direction.UP, Direction.DOWN]:
stack = core.std.StackVertical([black_clip, mask_vert, white_clip])
h = mask_vert.height
if direction == Direction.UP:
def _wipe(n: int) -> vs.VideoNode:
stack_ = stack.resize.Spline36(
height=h,
src_top=2 * h * n / (frames_ - 1),
src_height=h,
)
return core.std.MaskedMerge(clipa_wipe_zone, clipb_wipe_zone, stack_)
elif direction == Direction.DOWN:
stack = core.std.FlipVertical(stack)
def _wipe(n: int) -> vs.VideoNode:
stack_ = stack.resize.Spline36(
height=h,
src_top=(2 * h) * (1 - n / (frames_ - 1)),
src_height=h,
)
return core.std.MaskedMerge(clipa_wipe_zone, clipb_wipe_zone, stack_)
wiped = core.std.FrameEval(core.std.BlankClip(clipa, length=frames_), _wipe)
return _return_combo(clipa_clean, wiped, clipb_clean)
[docs]def cube_rotate(
clipa: vs.VideoNode,
clipb: vs.VideoNode,
frames: Optional[int] = None,
direction: Direction = Direction.LEFT,
exaggeration: int = 0,
) -> vs.VideoNode:
"""Mimics a cube face rotation by adjusting the speed at which the :func:`squeeze_expand` boundary moves.
Cube face containing `clipa` rotates away from the viewer in projected 3-D space towards `direction`.
:param exaggeration: An integer between 0 and 100 (inclusive)
representing how much the effect of the cosine wave should be exaggerated.
`0` corresponds to a mathematically correct projection of a 90 degree rotation offset by 45 degrees.
`100` corresponds to a fitted cosine wave.
"""
if direction not in [Direction.LEFT, Direction.RIGHT, Direction.UP, Direction.DOWN]:
raise ValueError("cube_rotate: give a proper direction")
if not (0 <= exaggeration <= 100):
raise ValueError(f"cube_rotate: exaggeration {exaggeration} not between 0 and 100")
frames_ = frames or min(clipa.num_frames, clipb.num_frames)
if TYPE_CHECKING:
assert isinstance(frames_, int)
_check_clips(frames_, cube_rotate, clipa, clipb)
clipa_clean, clipb_clean, clipa_squeeze_zone, clipb_squeeze_zone = _transition_clips(clipa, clipb, frames_)
def rotation(percentage: float) -> float:
"""Return a radian rotation based on `percentage` ranging from -pi/4 at 0% to -3pi/4 at 100%"""
return (-math.pi / 2) * percentage - math.pi / 4
def position(percentage: float, bias: int) -> float:
"""
Return position of a rotated edge as a percentage
0% at 0%, 23% at 25%, 50% at 50%, 77% at 75%, 100% at 100%
"""
def _projection(x: float):
"""mathmatically correct projection"""
return (-math.cos(rotation(x)) + (math.sqrt(2) / 2)) / math.sqrt(2)
def _fitted(x: float):
"""fitted cosine wave to exaggerate the effects"""
return -0.5 * math.cos(2 * rotation(x) + math.pi / 2) + 0.5
if bias == 0:
return round(_projection(percentage), 9)
elif bias == 100:
return round(_fitted(percentage), 9)
else:
fitted = (bias / 100) * _fitted(percentage)
projection = ((100 - bias) / 100) * _projection(percentage)
return round(fitted + projection, 9)
if direction in [Direction.LEFT, Direction.RIGHT]:
def _rotate(n: int):
w_inc = math.floor(clipa.width * position(n / (frames_ - 1), exaggeration))
w_dec = clipa.width - w_inc
if w_dec == clipa.width:
return clipa_squeeze_zone
elif w_inc == clipa.width:
return clipb_squeeze_zone
else:
clipa_squeezed = clipa_squeeze_zone.resize.Spline36(width=w_dec)
clipb_squeezed = clipb_squeeze_zone.resize.Spline36(width=w_inc)
if direction == Direction.LEFT:
return core.std.StackHorizontal([clipa_squeezed, clipb_squeezed])
elif direction == Direction.RIGHT:
return core.std.StackHorizontal([clipb_squeezed, clipa_squeezed])
elif direction in [Direction.UP, Direction.DOWN]:
def _rotate(n: int):
h_inc = math.floor(clipa.height * position(n / (frames_ - 1), exaggeration))
h_dec = clipa.height - h_inc
if h_dec == clipa.height:
return clipa_squeeze_zone
elif h_inc == clipa.height:
return clipb_squeeze_zone
else:
clipa_squeezed = clipa_squeeze_zone.resize.Spline36(height=h_dec)
clipb_squeezed = clipb_squeeze_zone.resize.Spline36(height=h_inc)
if direction == Direction.UP:
return core.std.StackVertical([clipa_squeezed, clipb_squeezed])
elif direction == Direction.DOWN:
return core.std.StackVertical([clipb_squeezed, clipa_squeezed])
rotated = core.std.FrameEval(core.std.BlankClip(clipa, length=frames_), _rotate)
return _return_combo(clipa_clean, rotated, clipb_clean)
[docs]def linear_boundary(
clipa: vs.VideoNode,
clipb: vs.VideoNode,
clipa_movement: MiscConstants,
clipb_movement: MiscConstants,
frames: Optional[int] = None,
direction: Direction = Direction.LEFT,
) -> vs.VideoNode:
"""Generalized boundary moving function for a linear transition between two stacked clips.
`clipa` can either slide out of view (having its size unchanged) or be squeezed to nothing from its original size.
`clipb` can either slide into view (having its size unchanged) or be expanded from nothing to its full size.
The boundary between the two clips moves towards `direction`.
The parameter `clipa_movement` can be :attr:`MiscConstants.SLIDE` or :attr:`MiscConstants.SQUEEZE`.
The parameter `clipb_movement` can be :attr:`MiscConstants.SLIDE` or :attr:`MiscConstants.EXPAND`.
See :func:`push`, :func:`slide_expand`, :func:`squeeze_slide`, or :func:`squeeze_expand`
for simpler aliases in the same form as most other linear, directional transitions.
"""
if clipa_movement not in [MiscConstants.SLIDE, MiscConstants.SQUEEZE]:
raise ValueError("linear_boundary: clipa_movement must be either a slide or a squeeze")
if clipb_movement not in [MiscConstants.SLIDE, MiscConstants.EXPAND]:
raise ValueError("linear_boundary: clipb_movement must be either a slide or an expand")
if direction not in [Direction.LEFT, Direction.RIGHT, Direction.UP, Direction.DOWN]:
raise ValueError("linear_boundary: give a proper direction")
frames_ = frames or min(clipa.num_frames, clipb.num_frames)
if TYPE_CHECKING:
assert isinstance(frames_, int)
check_for_subsampling = not (
clipa_movement == clipb_movement == MiscConstants.SLIDE
) # only need subsampling check if resizing
_check_clips(frames_, linear_boundary, clipa, clipb, subsampling=check_for_subsampling)
clipa_clean, clipb_clean, clipa_t_zone, clipb_t_zone = _transition_clips(clipa, clipb, frames_)
if clipa_movement == clipb_movement == MiscConstants.SLIDE:
w, h = clipa.width, clipa.height
def _stack(clipa_: vs.VideoNode, clipb_: vs.VideoNode) -> vs.VideoNode:
if direction == Direction.LEFT:
return core.std.StackHorizontal([clipa_, clipb_])
elif direction == Direction.RIGHT:
return core.std.StackHorizontal([clipb_, clipa_])
elif direction == Direction.UP:
return core.std.StackVertical([clipa_, clipb_])
elif direction == Direction.DOWN:
return core.std.StackVertical([clipb_, clipa_])
stack = _stack(clipa_t_zone, clipb_t_zone)
def _push(n: int) -> vs.VideoNode:
if direction == Direction.LEFT:
return stack.resize.Spline36(width=w, src_left=w * n / (frames_ - 1), src_width=w)
elif direction == Direction.RIGHT:
return stack.resize.Spline36(width=w, src_left=w * (1 - n / (frames_ - 1)), src_width=w)
elif direction == Direction.UP:
return stack.resize.Spline36(height=h, src_top=h * n / (frames_ - 1), src_height=h)
elif direction == Direction.DOWN:
return stack.resize.Spline36(height=h, src_top=h * (1 - n / (frames_ - 1)), src_height=h)
pushed = core.std.FrameEval(core.std.BlankClip(clipa, length=frames_), _push)
return _return_combo(clipa_clean, pushed, clipb_clean)
elif clipa_movement == MiscConstants.SLIDE and clipb_movement == MiscConstants.EXPAND:
def _slide_expand(n: int):
scale = Fraction(n, frames_ - 1)
if scale == 0:
return clipa_t_zone
elif scale == 1:
return clipb_t_zone
if direction in [Direction.LEFT, Direction.RIGHT]:
w = math.floor(scale * clipa.width)
if w == 0:
return clipa_t_zone
if direction == Direction.LEFT:
stack = core.std.StackHorizontal([clipa_t_zone, clipb_t_zone.resize.Spline36(width=w)])
return stack.std.Crop(left=w)
elif direction == Direction.RIGHT:
stack = core.std.StackHorizontal([clipb_t_zone.resize.Spline36(width=w), clipa_t_zone])
return stack.std.Crop(right=w)
elif direction in [Direction.UP, Direction.DOWN]:
h = math.floor(scale * clipa.height)
if h == 0:
return clipa_t_zone
if direction == Direction.UP:
stack = core.std.StackVertical([clipa_t_zone, clipb_t_zone.resize.Spline36(height=h)])
return stack.std.Crop(top=h)
elif direction == Direction.DOWN:
stack = core.std.StackVertical([clipb_t_zone.resize.Spline36(height=h), clipa_t_zone])
return stack.std.Crop(bottom=h)
slide_expanded = core.std.FrameEval(core.std.BlankClip(clipa, length=frames_), _slide_expand)
return _return_combo(clipa_clean, slide_expanded, clipb_clean)
elif clipa_movement == MiscConstants.SQUEEZE and clipb_movement == MiscConstants.SLIDE:
def _squeeze_slide(n: int):
scale = 1 - Fraction(n, frames_ - 1)
if scale == 1:
return clipa_t_zone
elif scale == 0:
return clipb_t_zone
if direction in [Direction.LEFT, Direction.RIGHT]:
w = math.floor(scale * clipa.width)
if w == 0:
return clipb_t_zone
if direction == Direction.LEFT:
stack = core.std.StackHorizontal([clipa_t_zone.resize.Spline36(width=w), clipb_t_zone])
return stack.std.Crop(right=w)
elif direction == Direction.RIGHT:
stack = core.std.StackHorizontal([clipb_t_zone, clipa_t_zone.resize.Spline36(width=w)])
return stack.std.Crop(left=w)
elif direction in [Direction.UP, Direction.DOWN]:
h = math.floor(scale * clipa.height)
if h == 0:
return clipb_t_zone
if direction == Direction.UP:
stack = core.std.StackVertical([clipa_t_zone.resize.Spline36(height=h), clipb_t_zone])
return stack.std.Crop(bottom=h)
elif direction == Direction.DOWN:
stack = core.std.StackVertical([clipb_t_zone, clipa_t_zone.resize.Spline36(height=h)])
return stack.std.Crop(top=h)
squeeze_slided = core.std.FrameEval(core.std.BlankClip(clipa, length=frames_), _squeeze_slide)
return _return_combo(clipa_clean, squeeze_slided, clipb_clean)
elif clipa_movement == MiscConstants.SQUEEZE and clipb_movement == MiscConstants.EXPAND:
def _squeeze_expand(n: int) -> vs.VideoNode:
scale = Fraction(n, frames_ - 1)
if scale == 0:
return clipa_t_zone
elif scale == 1:
return clipb_t_zone
if direction in [Direction.LEFT, Direction.RIGHT]:
w_inc = math.floor(scale * clipa.width)
w_dec = clipa.width - w_inc
if w_inc == 0:
return clipa_t_zone
if direction == Direction.LEFT:
return core.std.StackHorizontal(
[clipa_t_zone.resize.Spline36(width=w_dec), clipb_t_zone.resize.Spline36(width=w_inc)]
)
elif direction == Direction.RIGHT:
return core.std.StackHorizontal(
[clipb_t_zone.resize.Spline36(width=w_inc), clipa_t_zone.resize.Spline36(width=w_dec)]
)
elif direction in [Direction.UP, Direction.DOWN]:
h_inc = math.floor(scale * clipa.width)
h_dec = clipa.width - h_inc
if h_inc == 0:
return clipa_t_zone
if direction == Direction.UP:
return core.std.StackVertical(
[clipa_t_zone.resize.Spline36(width=h_dec), clipb_t_zone.resize.Spline36(width=h_inc)]
)
elif direction == Direction.RIGHT:
return core.std.StackVertical(
[clipb_t_zone.resize.Spline36(width=h_inc), clipa_t_zone.resize.Spline36(width=h_dec)]
)
squeeze_expanded = core.std.FrameEval(core.std.BlankClip(clipa, length=frames_), _squeeze_expand)
return _return_combo(clipa_clean, squeeze_expanded, clipb_clean)
[docs]def push(clipa: vs.VideoNode, clipb: vs.VideoNode, frames: Optional[int] = None, direction: Direction = Direction.LEFT):
"""Second clip pushes first clip off of the screen.
The first clip moves off of the screen moving towards the given `direction`.
Alias for :func:`linear_boundary` with ``clipa_movement=SLIDE`` and ``clipb_movement=SLIDE``.
"""
return linear_boundary(clipa, clipb, MiscConstants.SLIDE, MiscConstants.SLIDE, frames=frames, direction=direction)
[docs]def slide_expand(
clipa: vs.VideoNode, clipb: vs.VideoNode, frames: Optional[int] = None, direction: Direction = Direction.LEFT
):
"""First clip slides out of view, while second clip expands into view from nothing.
`clipa` slides off of the screen towards `direction`.
`clipb` expands into view from the opposite side of the given direction.
Alias for :func:`linear_boundary` with ``clipa_movement=SLIDE`` and ``clipb_movement=EXPAND``.
"""
return linear_boundary(clipa, clipb, MiscConstants.SLIDE, MiscConstants.EXPAND, frames=frames, direction=direction)
[docs]def squeeze_slide(
clipa: vs.VideoNode, clipb: vs.VideoNode, frames: Optional[int] = None, direction: Direction = Direction.LEFT
):
"""First clip squeezes into nothing, while second clip slides into view.
`clipa` gets compressed off of the screen towards `direction`.
`clipb` slides into view from the opposite side of the given direction.
Alias for :func:`linear_boundary` with ``clipa_movement=SQUEEZE`` and ``clipb_movement=SLIDE``.
"""
return linear_boundary(clipa, clipb, MiscConstants.SQUEEZE, MiscConstants.SLIDE, frames=frames, direction=direction)
[docs]def squeeze_expand(
clipa: vs.VideoNode, clipb: vs.VideoNode, frames: Optional[int] = None, direction: Direction = Direction.LEFT
):
"""First clip squeezes into nothing, while second clip expands into view from nothing.
`clipa` gets compressed off of the screen towards `direction`.
`clipb` expands into view from the opposite side of the given direction.
Alias for :func:`linear_boundary` with ``clipa_movement=SQUEEZE`` and ``clipb_movement=EXPAND``.
"""
return linear_boundary(
clipa, clipb, MiscConstants.SQUEEZE, MiscConstants.EXPAND, frames=frames, direction=direction
)
[docs]def cover(
clipa: vs.VideoNode, clipb: vs.VideoNode, frames: Optional[int] = None, direction: Direction = Direction.LEFT
) -> vs.VideoNode:
"""Second clip slides in and covers the first clip which stays in place.
`clipb` slides into frame towards `direction` covering `clipa`.
"""
if direction not in [Direction.LEFT, Direction.RIGHT, Direction.UP, Direction.DOWN]:
raise ValueError("cover: give a proper direction")
frames_ = frames or min(clipa.num_frames, clipb.num_frames)
if TYPE_CHECKING:
assert isinstance(frames_, int)
_check_clips(frames_, cover, clipa, clipb)
clipa_clean, clipb_clean, clipa_t_zone, clipb_t_zone = _transition_clips(clipa, clipb, frames_)
def _cover(n: int) -> vs.VideoNode:
progress = Fraction(n, frames_ - 1)
w = math.floor(progress * clipa.width)
h = math.floor(progress * clipa.height)
if progress == 0:
return clipa_t_zone
elif progress == 1:
return clipb_t_zone
if direction in [Direction.LEFT, Direction.RIGHT]:
if w == 0:
return clipa_t_zone
elif direction in [Direction.UP, Direction.DOWN]:
if h == 0:
return clipa_t_zone
if direction == Direction.LEFT:
cropped_a = clipa_t_zone.std.Crop(right=w)
stack = core.std.StackHorizontal([cropped_a, clipb_t_zone])
return stack.resize.Spline36(width=clipa.width, src_width=clipa.width)
elif direction == Direction.RIGHT:
cropped_a = clipa_t_zone.std.Crop(left=w)
stack = core.std.StackHorizontal([clipb_t_zone, cropped_a])
return stack.resize.Spline36(width=clipa.width, src_left=clipa.width - w, src_width=clipa.width)
elif direction == Direction.UP:
cropped_a = clipa_t_zone.std.Crop(bottom=h)
stack = core.std.StackVertical([cropped_a, clipb_t_zone])
return stack.resize.Spline36(height=clipa.height, src_height=clipa.height)
elif direction == Direction.DOWN:
cropped_a = clipa_t_zone.std.Crop(top=h)
stack = core.std.StackVertical([clipb_t_zone, cropped_a])
return stack.resize.Spline36(height=clipa.height, src_top=clipa.height - h, src_height=clipa.height)
covered = core.std.FrameEval(core.std.BlankClip(clipa, length=frames_), _cover)
return _return_combo(clipa_clean, covered, clipb_clean)
[docs]def reveal(
clipa: vs.VideoNode, clipb: vs.VideoNode, frames: Optional[int] = None, direction: Direction = Direction.LEFT
) -> vs.VideoNode:
"""First clip slides out of view exposing second clip that stays in place.
`clipa` slides out of frame towards `direction` revealing `clipb`.
"""
if direction not in [Direction.LEFT, Direction.RIGHT, Direction.UP, Direction.DOWN]:
raise ValueError("reveal: give a proper direction")
frames_ = frames or min(clipa.num_frames, clipb.num_frames)
if TYPE_CHECKING:
assert isinstance(frames_, int)
_check_clips(frames_, reveal, clipa, clipb)
clipa_clean, clipb_clean, clipa_t_zone, clipb_t_zone = _transition_clips(clipa, clipb, frames_)
def _reveal(n: int) -> vs.VideoNode:
progress = 1 - Fraction(n, frames_ - 1)
w = math.floor(progress * clipa.width)
h = math.floor(progress * clipa.height)
if progress == 1:
return clipa_t_zone
elif progress == 0:
return clipb_t_zone
if direction in [Direction.LEFT, Direction.RIGHT]:
if w == 0:
return clipb_t_zone
elif direction in [Direction.UP, Direction.DOWN]:
if h == 0:
return clipb_t_zone
if direction == Direction.LEFT:
cropped_b = clipb_t_zone.std.Crop(left=w)
stack = core.std.StackHorizontal([clipa_t_zone, cropped_b])
return stack.resize.Spline36(width=clipa.width, src_width=clipa.width, src_left=clipa.width - w)
elif direction == Direction.RIGHT:
cropped_b = clipb_t_zone.std.Crop(right=w)
stack = core.std.StackHorizontal([cropped_b, clipa_t_zone])
return stack.resize.Spline36(width=clipa.width, src_width=clipa.width)
elif direction == Direction.UP:
cropped_b = clipb_t_zone.std.Crop(top=h)
stack = core.std.StackVertical([clipa_t_zone, cropped_b])
return stack.resize.Spline36(height=clipa.height, src_height=clipa.height, src_top=clipa.height - h)
elif direction == Direction.DOWN:
cropped_b = clipb_t_zone.std.Crop(bottom=h)
stack = core.std.StackVertical([cropped_b, clipa_t_zone])
return stack.resize.Spline36(height=clipa.height, src_height=clipa.height)
covered = core.std.FrameEval(core.std.BlankClip(clipa, length=frames_), _reveal)
return _return_combo(clipa_clean, covered, clipb_clean)
[docs]def curtain_cover(
clipa: vs.VideoNode, clipb: vs.VideoNode, frames: Optional[int] = None, axis: Direction = Direction.HORIZONTAL
) -> vs.VideoNode:
"""Second clip comes into view from both directions split along the given axis covering the first clip in place.
`clipb` splits and moves inwards along the given `axis`.
If `axis` is given as :attr:`Direction.HORIZONTAL`, the clips must have an even integer width.
If `axis` is given as :attr:`Direction.VERTICAL`, the clips must have an even integer height.
"""
if axis not in [Direction.HORIZONTAL, Direction.VERTICAL]:
raise ValueError("curtain_cover: give a proper axis")
if axis == Direction.HORIZONTAL and clipa.width % 2:
raise ValueError("curtain_cover: for horizontal reveal, input clips must have an even width")
elif axis == Direction.VERTICAL and clipa.height % 2:
raise ValueError("curtain_cover: for vertical reveal, input clips must have an even height")
frames_ = frames or min(clipa.num_frames, clipb.num_frames)
if TYPE_CHECKING:
assert isinstance(frames_, int)
_check_clips(frames_, curtain_cover, clipa, clipb)
clipa_clean, clipb_clean, clipa_t_zone, clipb_t_zone = _transition_clips(clipa, clipb, frames_)
def _curtain_cover(n: int) -> vs.VideoNode:
progress = Fraction(n, frames_ - 1)
if progress == 0:
return clipa_t_zone
elif progress == 1:
return clipb_t_zone
if axis == Direction.HORIZONTAL:
w = round(float(clipa.width * progress / 2)) * 2
if w == 0:
return clipa_t_zone
elif w == clipa.width:
return clipb_t_zone
clipb_left = clipb_t_zone.std.Crop(right=clipa.width // 2)
clipb_right = clipb_t_zone.std.Crop(left=clipa.width // 2)
clipb_left = clipb_left.std.Crop(left=clipb_left.width - w // 2)
clipb_right = clipb_right.std.Crop(right=clipb_right.width - w // 2)
clipa_cropped = clipa_t_zone.std.Crop(left=clipb_left.width, right=clipb_right.width)
return core.std.StackHorizontal([clipb_left, clipa_cropped, clipb_right])
elif axis == Direction.VERTICAL:
h = round(float(clipa.height * progress / 2)) * 2
if h == 0:
return clipa_t_zone
elif h == clipa.height:
return clipb_t_zone
clipb_top = clipb_t_zone.std.Crop(bottom=clipa.height // 2)
clipb_bottom = clipb_t_zone.std.Crop(top=clipa.height // 2)
clipb_top = clipb_top.std.Crop(top=clipb_top.height - h // 2)
clipb_bottom = clipb_bottom.std.Crop(bottom=clipb_bottom.height - h // 2)
clipa_cropped = clipa_t_zone.std.Crop(top=clipb_top.height, bottom=clipb_bottom.height)
return core.std.StackVertical([clipb_top, clipa_cropped, clipb_bottom])
curtain_covered = core.std.FrameEval(core.std.BlankClip(clipa, length=frames_), _curtain_cover)
return _return_combo(clipa_clean, curtain_covered, clipb_clean)
[docs]def curtain_reveal(
clipa: vs.VideoNode, clipb: vs.VideoNode, frames: Optional[int] = None, axis: Direction = Direction.HORIZONTAL
) -> vs.VideoNode:
"""First clip splits apart to reveal the second clip in place.
`clipa` splits and moves apart along the given `axis`.
If `axis` is given as :attr:`Direction.HORIZONTAL`, the clips must have an even integer width.
If `axis` is given as :attr:`Direction.VERTICAL`, the clips must have an even integer height.
"""
if axis not in [Direction.HORIZONTAL, Direction.VERTICAL]:
raise ValueError("curtain_reveal: give a proper axis")
if axis == Direction.HORIZONTAL and clipa.width % 2:
raise ValueError("curtain_reveal: for horizontal reveal, input clips must have an even width")
elif axis == Direction.VERTICAL and clipa.height % 2:
raise ValueError("curtain_reveal: for vertical reveal, input clips must have an even height")
frames_ = frames or min(clipa.num_frames, clipb.num_frames)
if TYPE_CHECKING:
assert isinstance(frames_, int)
_check_clips(frames_, curtain_reveal, clipa, clipb)
clipa_clean, clipb_clean, clipa_t_zone, clipb_t_zone = _transition_clips(clipa, clipb, frames_)
def _curtain_reveal(n: int) -> vs.VideoNode:
progress = Fraction(n, frames_ - 1)
if progress == 0:
return clipa_t_zone
elif progress == 1:
return clipb_t_zone
if axis == Direction.HORIZONTAL:
w = round(float(clipa.width * progress / 2)) * 2
if w == 0:
return clipa_t_zone
elif w == clipa.width:
return clipb_t_zone
clipa_left = clipa_t_zone.std.Crop(right=clipa.width // 2)
clipa_right = clipa_t_zone.std.Crop(left=clipa.width // 2)
clipa_left = clipa_left.std.Crop(left=w // 2)
clipa_right = clipa_right.std.Crop(right=w // 2)
clipb_cropped = clipb_t_zone.std.Crop(left=clipa_left.width, right=clipa_right.width)
return core.std.StackHorizontal([clipa_left, clipb_cropped, clipa_right])
elif axis == Direction.VERTICAL:
h = round(float(clipa.height * progress / 2)) * 2
if h == 0:
return clipa_t_zone
elif h == clipa.height:
return clipb_t_zone
clipa_top = clipa_t_zone.std.Crop(bottom=clipa.height // 2)
clipa_bottom = clipa_t_zone.std.Crop(top=clipa.height // 2)
clipa_top = clipa_top.std.Crop(top=h // 2)
clipa_bottom = clipa_bottom.std.Crop(bottom=h // 2)
clipb_cropped = clipb_t_zone.std.Crop(top=clipa_top.height, bottom=clipa_bottom.height)
return core.std.StackVertical([clipa_top, clipb_cropped, clipa_bottom])
curtain_revealed = core.std.FrameEval(core.std.BlankClip(clipa, length=frames_), _curtain_reveal)
return _return_combo(clipa_clean, curtain_revealed, clipb_clean)
[docs]def pixellate(
clipa: vs.VideoNode,
clipb: vs.VideoNode,
frames: Optional[int] = None,
lowest_target_w: Optional[int] = 2,
lowest_target_h: Optional[int] = 2,
) -> vs.VideoNode:
"""Pixellate using rescales and aggressively fade at the center.
For large clips (width `x` height), the effect might not be too noticeable
until the transition is near the middle point.
This is due to bicubic downscales and point re-upscales
at very high percentages of the original dimensions
not being noticeably different.
Due to the way pixellation progress is calculated,
the transition `must` be at least 4 frames long.
Longer transitions paired with larger target dimensions
will cause the pixellation effect to appear to pause towards the center of the transition.
:param lowest_target_w: An integer that determines the minimum width target to downscale to.
By specifying ``None``, or by specifying the width of the source clips, the clips will not be scaled in the
`x` or width direction, making this only pixellate vertically.
:param lowest_target_h: An integer that determines the minimum height target to downscale to.
By specifying ``None``, or by specifying the height of the source clips, the clips will not be scaled in the
`y` or height direction, making this only pixellate horizontally.
"""
frames_ = frames or min(clipa.num_frames, clipb.num_frames)
if frames_ < 4:
raise ValueError("pixellate: transition must be at least 4 frames long")
if lowest_target_w is None:
lowest_target_w = clipa.width
if lowest_target_h is None:
lowest_target_h = clipa.height
if lowest_target_w < 1 or lowest_target_w > clipa.width:
raise ValueError("pixellate: `lowest_target_w` must be at least one and at most the width of the source clips")
if lowest_target_h < 1 or lowest_target_h > clipa.height:
raise ValueError("pixellate: `lowest_target_h` must be at least one and at most the height of the source clips")
if lowest_target_w == clipa.width and lowest_target_h == clipa.height:
raise ValueError("pixellate: at least one target dimension must be lower than the source dimensions")
lowest_target_w_ = lowest_target_w
lowest_target_h_ = lowest_target_h
if TYPE_CHECKING:
assert isinstance(frames_, int)
assert isinstance(lowest_target_w_, int)
assert isinstance(lowest_target_h_, int)
_check_clips(frames_, pixellate, clipa, clipb)
clipa_clean, clipb_clean, clipa_t_zone, clipb_t_zone = _transition_clips(clipa, clipb, frames_)
def _pixellate(n: int):
if iseven(frames_):
center = (frames_ - 1) / 2
if n < center:
progress_a = Fraction(math.floor(center) - n, math.floor(center)) ** 2
target_w_a = max([lowest_target_w_, math.floor(progress_a * clipa.width)])
target_h_a = max([lowest_target_h_, math.floor(progress_a * clipa.height)])
target_w_b = lowest_target_w_
target_h_b = lowest_target_h_
clipa_small = clipa_t_zone.resize.Bicubic(target_w_a, target_h_a)
clipb_small = clipb_t_zone.resize.Bicubic(target_w_b, target_h_b)
clipa_pixellated = clipa_small.resize.Point(clipa.width, clipa.height)
clipb_pixellated = clipb_small.resize.Point(clipa.width, clipa.height)
if n == math.floor(center):
return core.std.Merge(clipa_pixellated, clipb_pixellated, [1 / 3])
else:
return clipa_pixellated
else:
progress_b = Fraction(n - math.ceil(center), math.floor(center)) ** 2
target_w_b = max([lowest_target_w_, math.floor(progress_b * clipa.width)])
target_h_b = max([lowest_target_h_, math.floor(progress_b * clipa.height)])
target_w_a = lowest_target_w_
target_h_a = lowest_target_h_
clipa_small = clipa_t_zone.resize.Bicubic(target_w_a, target_h_a)
clipb_small = clipb_t_zone.resize.Bicubic(target_w_b, target_h_b)
clipa_pixellated = clipa_small.resize.Point(clipa.width, clipa.height)
clipb_pixellated = clipb_small.resize.Point(clipa.width, clipa.height)
if n == math.ceil(center):
return core.std.Merge(clipa_pixellated, clipb_pixellated, [2 / 3])
else:
return clipb_pixellated
else:
center = (frames_ - 1) // 2
if n < center:
progress_a = Fraction(center - n, center) ** 2
target_w_a = max([lowest_target_w_, math.floor(progress_a * clipa.width)])
target_h_a = max([lowest_target_h_, math.floor(progress_a * clipa.height)])
target_w_b = lowest_target_w_
target_h_b = lowest_target_h_
clipa_small = clipa_t_zone.resize.Bicubic(target_w_a, target_h_a)
clipb_small = clipb_t_zone.resize.Bicubic(target_w_b, target_h_b)
clipa_pixellated = clipa_small.resize.Point(clipa.width, clipa.height)
clipb_pixellated = clipb_small.resize.Point(clipa.width, clipa.height)
if n == center - 1:
return core.std.Merge(clipa_pixellated, clipb_pixellated, [1 / 4])
else:
return clipa_pixellated
elif n == center:
target_w_a = target_w_b = lowest_target_w_
target_h_a = target_h_b = lowest_target_h_
clipa_small = clipa_t_zone.resize.Bicubic(target_w_a, target_h_a)
clipb_small = clipb_t_zone.resize.Bicubic(target_w_b, target_h_b)
clipa_pixellated = clipa_small.resize.Point(clipa.width, clipa.height)
clipb_pixellated = clipb_small.resize.Point(clipa.width, clipa.height)
return core.std.Merge(clipa_pixellated, clipb_pixellated, [1 / 2])
else:
progress_b = Fraction(n - center, center) ** 2
target_w_b = max([lowest_target_w_, math.floor(progress_b * clipa.width)])
target_h_b = max([lowest_target_h_, math.floor(progress_b * clipa.height)])
target_w_a = lowest_target_w_
target_h_a = lowest_target_h_
clipa_small = clipa_t_zone.resize.Bicubic(target_w_a, target_h_a)
clipb_small = clipb_t_zone.resize.Bicubic(target_w_b, target_h_b)
clipa_pixellated = clipa_small.resize.Point(clipa.width, clipa.height)
clipb_pixellated = clipb_small.resize.Point(clipa.width, clipa.height)
if n == center + 1:
return core.std.Merge(clipa_pixellated, clipb_pixellated, [3 / 4])
else:
return clipb_pixellated
pixellated = core.std.FrameEval(core.std.BlankClip(clipa, length=frames_), _pixellate)
return _return_combo(clipa_clean, pixellated, clipb_clean)
[docs]def peel(
clipa: vs.VideoNode, clipb: vs.VideoNode, frames: Optional[int] = None, direction: Direction = Direction.LEFT
) -> vs.VideoNode:
"""First clip peels away revealing the second clip beneath.
Both clips remain in place during the transition. The boundary between clips moves towards `direction`.
"""
if direction not in [Direction.LEFT, Direction.RIGHT, Direction.UP, Direction.DOWN]:
raise ValueError("peel: give a proper direction")
frames_ = frames or min(clipa.num_frames, clipb.num_frames)
if TYPE_CHECKING:
assert isinstance(frames_, int)
_check_clips(frames_, peel, clipa, clipb, subsampling=True)
clipa_clean, clipb_clean, clipa_t_zone, clipb_t_zone = _transition_clips(clipa, clipb, frames_)
def _peel(n: int):
progress = Fraction(n, frames_ - 1)
if progress == 0:
return clipa_t_zone
elif progress == 1:
return clipb_t_zone
else:
if direction in [Direction.LEFT, Direction.RIGHT]:
clipb_w = math.floor(clipa.width * progress)
clipa_w = clipa.width - clipb_w
if clipb_w == 0:
return clipa_t_zone
elif clipb_w == clipa.width:
return clipb_t_zone
else:
if direction == Direction.LEFT:
clipa_cropped = clipa_t_zone.std.Crop(right=clipb_w)
clipb_cropped = clipb_t_zone.std.Crop(left=clipa_w)
return core.std.StackHorizontal([clipa_cropped, clipb_cropped])
elif direction == Direction.RIGHT:
clipa_cropped = clipa_t_zone.std.Crop(left=clipb_w)
clipb_cropped = clipb_t_zone.std.Crop(right=clipa_w)
return core.std.StackHorizontal([clipb_cropped, clipa_cropped])
elif direction in [Direction.UP, Direction.DOWN]:
clipb_h = math.floor(clipa.height * progress)
clipa_h = clipa.height - clipb_h
if clipb_h == 0:
return clipa_t_zone
elif clipb_h == clipa.width:
return clipb_t_zone
else:
if direction == Direction.UP:
clipa_cropped = clipa_t_zone.std.Crop(bottom=clipb_h)
clipb_cropped = clipb_t_zone.std.Crop(top=clipa_h)
return core.std.StackVertical([clipa_cropped, clipb_cropped])
elif direction == Direction.DOWN:
clipa_cropped = clipa_t_zone.std.Crop(top=clipb_h)
clipb_cropped = clipb_t_zone.std.Crop(bottom=clipa_h)
return core.std.StackVertical([clipb_cropped, clipa_cropped])
peeled = core.std.FrameEval(core.std.BlankClip(clipa, length=frames_), _peel)
return _return_combo(clipa_clean, peeled, clipb_clean)
def round_to(f: Fraction, n: int) -> int:
"""Rounds a fractional value to the nearest `n`, rounding half up and never returning less than `n`"""
if n < 1:
raise ValueError("round_to: `n` must be an integer greater than 0")
if n == 1:
return max(1, round(float(f)))
else:
return max(n, round(float(f / n)) * n)
def isodd(value: int) -> bool:
return bool(value % 2)
def iseven(value: int) -> bool:
return not bool(value % 2)