Source code for fmusvid.operations.transitions

"""
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)