"""
Transition effects for FMUS-VID.
This module provides various transition effects for videos, including fades,
dissolves, wipes, and other common transitions.
"""
import numpy as np
from typing import List, Dict, Tuple, Optional, Union, Any, Callable
import cv2
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
[docs]
class Transition:
"""Base class for video transitions."""
[docs]
def __init__(self, duration: float = 1.0):
"""
Initialize transition.
Args:
duration: Transition duration in seconds
"""
self.duration = duration
[docs]
def apply(self, frame1: np.ndarray, frame2: np.ndarray,
progress: float) -> np.ndarray:
"""
Apply transition between two frames.
Args:
frame1: First frame (previous frame)
frame2: Second frame (next frame)
progress: Transition progress from 0.0 to 1.0
Returns:
Blended frame
"""
raise NotImplementedError("Subclasses must implement apply()")
[docs]
class FadeTransition(Transition):
"""Fade transition (fade out/in)."""
[docs]
def __init__(self, duration: float = 1.0, fade_to_black: bool = True):
"""
Initialize fade transition.
Args:
duration: Transition duration in seconds
fade_to_black: If True, fade through black; if False, direct crossfade
"""
super().__init__(duration)
self.fade_to_black = fade_to_black
[docs]
def apply(self, frame1: np.ndarray, frame2: np.ndarray,
progress: float) -> np.ndarray:
"""Apply fade transition."""
if self.fade_to_black:
# Fade through black (fade out, then fade in)
if progress < 0.5:
# Fade out first frame to black
p = 1.0 - (progress * 2)
return (frame1 * p).astype(np.uint8)
else:
# Fade in second frame from black
p = (progress - 0.5) * 2
return (frame2 * p).astype(np.uint8)
else:
# Direct crossfade
return cv2.addWeighted(frame1, 1.0 - progress, frame2, progress, 0)
[docs]
class DissolveTransition(Transition):
"""Dissolve transition (crossfade with optional effects)."""
[docs]
def __init__(self, duration: float = 1.0, mode: str = 'simple'):
"""
Initialize dissolve transition.
Args:
duration: Transition duration in seconds
mode: Dissolve mode ('simple', 'zoom', 'blur')
"""
super().__init__(duration)
self.mode = mode
[docs]
def apply(self, frame1: np.ndarray, frame2: np.ndarray,
progress: float) -> np.ndarray:
"""Apply dissolve transition."""
h, w = frame1.shape[:2]
if self.mode == 'simple':
# Simple crossfade
return cv2.addWeighted(frame1, 1.0 - progress, frame2, progress, 0)
elif self.mode == 'zoom':
# Zoom dissolve (first frame zooms out, second zooms in)
scale1 = 1.0 + (progress * 0.2) # Scale up to 120%
scale2 = 0.8 + (progress * 0.2) # Scale from 80% to 100%
# Calculate new dimensions
new_w1, new_h1 = int(w * scale1), int(h * scale1)
new_w2, new_h2 = int(w * scale2), int(h * scale2)
# Resize frames
resized1 = cv2.resize(frame1, (new_w1, new_h1), interpolation=cv2.INTER_LINEAR)
resized2 = cv2.resize(frame2, (new_w2, new_h2), interpolation=cv2.INTER_LINEAR)
# Calculate crop offsets (center crop)
x1, y1 = (new_w1 - w) // 2, (new_h1 - h) // 2
x2, y2 = (new_w2 - w) // 2, (new_h2 - h) // 2
# Crop to original size
cropped1 = resized1[y1:y1+h, x1:x1+w]
cropped2 = resized2[y2:y2+h, x2:x2+w]
# Ensure both crops have the exact same size
if cropped1.shape != cropped2.shape:
# Resize to match dimensions
if cropped1.shape[:2] != (h, w):
cropped1 = cv2.resize(cropped1, (w, h))
if cropped2.shape[:2] != (h, w):
cropped2 = cv2.resize(cropped2, (w, h))
# Crossfade the cropped frames
return cv2.addWeighted(cropped1, 1.0 - progress, cropped2, progress, 0)
elif self.mode == 'blur':
# Blur dissolve (first frame gets increasingly blurry)
blur_amount = int(progress * 30) * 2 + 1 # Odd number needed
blurred1 = cv2.GaussianBlur(frame1, (blur_amount, blur_amount), 0)
# Crossfade between blurred first frame and second frame
return cv2.addWeighted(blurred1, 1.0 - progress, frame2, progress, 0)
else:
logger.warning(f"Unknown dissolve mode: {self.mode}, falling back to simple")
return cv2.addWeighted(frame1, 1.0 - progress, frame2, progress, 0)
[docs]
class WipeTransition(Transition):
"""Wipe transition (one frame wipes over the other)."""
[docs]
def __init__(self, duration: float = 1.0, direction: str = 'left-to-right'):
"""
Initialize wipe transition.
Args:
duration: Transition duration in seconds
direction: Wipe direction ('left-to-right', 'right-to-left',
'top-to-bottom', 'bottom-to-top')
"""
super().__init__(duration)
self.direction = direction
[docs]
def apply(self, frame1: np.ndarray, frame2: np.ndarray,
progress: float) -> np.ndarray:
"""Apply wipe transition."""
h, w = frame1.shape[:2]
result = frame1.copy()
if self.direction == 'left-to-right':
# Calculate wipe position
x = int(w * progress)
result[:, :x] = frame2[:, :x]
elif self.direction == 'right-to-left':
# Calculate wipe position
x = int(w * (1.0 - progress))
result[:, x:] = frame2[:, x:]
elif self.direction == 'top-to-bottom':
# Calculate wipe position
y = int(h * progress)
result[:y, :] = frame2[:y, :]
elif self.direction == 'bottom-to-top':
# Calculate wipe position
y = int(h * (1.0 - progress))
result[y:, :] = frame2[y:, :]
else:
logger.warning(f"Unknown wipe direction: {self.direction}, falling back to left-to-right")
x = int(w * progress)
result[:, :x] = frame2[:, :x]
return result
[docs]
class SlideTransition(Transition):
"""Slide transition (frames slide in/out)."""
[docs]
def __init__(self, duration: float = 1.0, direction: str = 'left-to-right'):
"""
Initialize slide transition.
Args:
duration: Transition duration in seconds
direction: Slide direction ('left-to-right', 'right-to-left',
'top-to-bottom', 'bottom-to-top')
"""
super().__init__(duration)
self.direction = direction
[docs]
def apply(self, frame1: np.ndarray, frame2: np.ndarray,
progress: float) -> np.ndarray:
"""Apply slide transition."""
h, w = frame1.shape[:2]
result = np.zeros_like(frame1)
if self.direction == 'left-to-right':
# First frame slides out to the right
x1 = int(w * progress)
# Second frame slides in from the left
x2 = int(-w * (1.0 - progress))
# Copy portions of frames that are visible
if x1 < w:
result[:, x1:] = frame1[:, :(w-x1)]
if x2 < 0:
result[:, :w+x2] = frame2[:, -x2:]
else:
result = frame2
elif self.direction == 'right-to-left':
# First frame slides out to the left
x1 = int(-w * progress)
# Second frame slides in from the right
x2 = int(w * (1.0 - progress))
# Copy portions of frames that are visible
if x1 > -w:
result[:, :w+x1] = frame1[:, -x1:]
if x2 < w:
result[:, x2:] = frame2[:, :(w-x2)]
else:
result = frame2
elif self.direction == 'top-to-bottom':
# First frame slides out to the bottom
y1 = int(h * progress)
# Second frame slides in from the top
y2 = int(-h * (1.0 - progress))
# Copy portions of frames that are visible
if y1 < h:
result[y1:, :] = frame1[:(h-y1), :]
if y2 < 0:
result[:h+y2, :] = frame2[-y2:, :]
else:
result = frame2
elif self.direction == 'bottom-to-top':
# First frame slides out to the top
y1 = int(-h * progress)
# Second frame slides in from the bottom
y2 = int(h * (1.0 - progress))
# Copy portions of frames that are visible
if y1 > -h:
result[:h+y1, :] = frame1[-y1:, :]
if y2 < h:
result[y2:, :] = frame2[:(h-y2), :]
else:
result = frame2
else:
logger.warning(f"Unknown slide direction: {self.direction}, falling back to left-to-right")
return self.apply(frame1, frame2, progress)
return result
[docs]
class PixelateTransition(Transition):
"""Pixelate transition (image pixelates and forms new image)."""
[docs]
def __init__(self, duration: float = 1.0, max_pixel_size: int = 50):
"""
Initialize pixelate transition.
Args:
duration: Transition duration in seconds
max_pixel_size: Maximum pixel size during pixelation
"""
super().__init__(duration)
self.max_pixel_size = max_pixel_size
[docs]
def apply(self, frame1: np.ndarray, frame2: np.ndarray,
progress: float) -> np.ndarray:
"""Apply pixelate transition."""
h, w = frame1.shape[:2]
if progress < 0.5:
# First half: pixelate first frame
p = progress * 2 # Scale to 0-1
pixel_size = int(p * self.max_pixel_size) + 1
# Skip if pixel_size is 1 (no pixelation)
if pixel_size > 1:
# Downscale and upscale to create pixelation
small_h, small_w = h // pixel_size, w // pixel_size
pixelated = cv2.resize(frame1, (small_w, small_h), interpolation=cv2.INTER_LINEAR)
pixelated = cv2.resize(pixelated, (w, h), interpolation=cv2.INTER_NEAREST)
return pixelated
return frame1
else:
# Second half: unpixelate second frame
p = (1.0 - progress) * 2 # Scale to 1-0
pixel_size = int(p * self.max_pixel_size) + 1
# Skip if pixel_size is 1 (no pixelation)
if pixel_size > 1:
# Downscale and upscale to create pixelation
small_h, small_w = h // pixel_size, w // pixel_size
pixelated = cv2.resize(frame2, (small_w, small_h), interpolation=cv2.INTER_LINEAR)
pixelated = cv2.resize(pixelated, (w, h), interpolation=cv2.INTER_NEAREST)
return pixelated
return frame2
[docs]
class ZoomTransition(Transition):
"""Zoom transition (zoom in on first frame, zoom out on second)."""
[docs]
def __init__(self, duration: float = 1.0, zoom_factor: float = 2.0):
"""
Initialize zoom transition.
Args:
duration: Transition duration in seconds
zoom_factor: Maximum zoom factor
"""
super().__init__(duration)
self.zoom_factor = zoom_factor
[docs]
def apply(self, frame1: np.ndarray, frame2: np.ndarray,
progress: float) -> np.ndarray:
"""Apply zoom transition."""
h, w = frame1.shape[:2]
if progress < 0.5:
# First half: zoom in on first frame
p = progress * 2 # Scale to 0-1
scale = 1.0 + (p * (self.zoom_factor - 1.0))
# Calculate new dimensions
new_w, new_h = int(w * scale), int(h * scale)
# Resize frame
resized = cv2.resize(frame1, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
# Calculate crop offsets (center crop)
x, y = (new_w - w) // 2, (new_h - h) // 2
# Crop to original size
result = resized[y:y+h, x:x+w]
return result
else:
# Second half: zoom out on second frame
p = (progress - 0.5) * 2 # Scale to 0-1
scale = self.zoom_factor - (p * (self.zoom_factor - 1.0))
# Calculate new dimensions
new_w, new_h = int(w * scale), int(h * scale)
# Create canvas
result = np.zeros_like(frame2)
# Resize frame
resized = cv2.resize(frame2, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
# Calculate paste offsets (center)
x, y = (w - new_w) // 2, (h - new_h) // 2
# Paste resized frame onto canvas
# Ensure we don't go out of bounds
y_end = min(y + new_h, h)
x_end = min(x + new_w, w)
y_start = max(0, y)
x_start = max(0, x)
# Adjust resized frame to fit the slice
resized_h = y_end - y_start
resized_w = x_end - x_start
# Calculate source offsets to crop the resized frame
src_y = max(0, -y)
src_x = max(0, -x)
result[y_start:y_end, x_start:x_end] = resized[src_y:src_y+resized_h, src_x:src_x+resized_w]
return result
[docs]
class RotateTransition(Transition):
"""Rotate transition (3D flip effect)."""
[docs]
def __init__(self, duration: float = 1.0, direction: str = 'horizontal'):
"""
Initialize rotate transition.
Args:
duration: Transition duration in seconds
direction: Rotation direction ('horizontal' or 'vertical')
"""
super().__init__(duration)
self.direction = direction
[docs]
def apply(self, frame1: np.ndarray, frame2: np.ndarray,
progress: float) -> np.ndarray:
"""Apply rotate/flip transition."""
h, w = frame1.shape[:2]
if self.direction == 'horizontal':
# Horizontal flip (like a page turning)
if progress < 0.5:
# First half: shrink first frame horizontally
p = progress * 2 # Scale to 0-1
new_w = int(w * (1.0 - p))
# Skip if new width is too small
if new_w < 1:
return frame2
# Resize frame
resized = cv2.resize(frame1, (new_w, h), interpolation=cv2.INTER_LINEAR)
# Create canvas
result = np.zeros_like(frame1)
# Calculate paste offset (center)
x = (w - new_w) // 2
# Paste resized frame
result[:, x:x+new_w] = resized
return result
else:
# Second half: grow second frame horizontally
p = (progress - 0.5) * 2 # Scale to 0-1
new_w = int(w * p)
# Skip if new width is too small
if new_w < 1:
return frame1
# Resize frame
resized = cv2.resize(frame2, (new_w, h), interpolation=cv2.INTER_LINEAR)
# Create canvas
result = np.zeros_like(frame2)
# Calculate paste offset (center)
x = (w - new_w) // 2
# Paste resized frame
result[:, x:x+new_w] = resized
return result
elif self.direction == 'vertical':
# Vertical flip
if progress < 0.5:
# First half: shrink first frame vertically
p = progress * 2 # Scale to 0-1
new_h = int(h * (1.0 - p))
# Skip if new height is too small
if new_h < 1:
return frame2
# Resize frame
resized = cv2.resize(frame1, (w, new_h), interpolation=cv2.INTER_LINEAR)
# Create canvas
result = np.zeros_like(frame1)
# Calculate paste offset (center)
y = (h - new_h) // 2
# Paste resized frame
result[y:y+new_h, :] = resized
return result
else:
# Second half: grow second frame vertically
p = (progress - 0.5) * 2 # Scale to 0-1
new_h = int(h * p)
# Skip if new height is too small
if new_h < 1:
return frame1
# Resize frame
resized = cv2.resize(frame2, (w, new_h), interpolation=cv2.INTER_LINEAR)
# Create canvas
result = np.zeros_like(frame2)
# Calculate paste offset (center)
y = (h - new_h) // 2
# Paste resized frame
result[y:y+new_h, :] = resized
return result
else:
logger.warning(f"Unknown rotation direction: {self.direction}, falling back to horizontal")
return self.apply(frame1, frame2, progress)
[docs]
def get_transition(name: str, **kwargs) -> Transition:
"""
Factory function to create a transition by name.
Args:
name: Transition name
**kwargs: Parameters for the transition
Returns:
Transition object
"""
transitions = {
'fade': FadeTransition,
'dissolve': DissolveTransition,
'wipe': WipeTransition,
'slide': SlideTransition,
'pixelate': PixelateTransition,
'zoom': ZoomTransition,
'rotate': RotateTransition
}
if name not in transitions:
logger.warning(f"Unknown transition: {name}, falling back to dissolve")
name = 'dissolve'
return transitions[name](**kwargs)