437 lines
15 KiB
Python
437 lines
15 KiB
Python
"""
|
|
Video Creation Script - Vinyl Record Animation
|
|
|
|
Creates an animated video with a rotating vinyl record effect by layering:
|
|
- Base background image (1080x1080)
|
|
- Pet/cover art image (centered in vinyl, rotates with record)
|
|
- Vinyl record template (transparent PNG, rotates)
|
|
- Needle overlay (stationary)
|
|
|
|
The script generates ONE complete rotation cycle and streams frames directly to
|
|
FFmpeg, which seamlessly loops the rotation to match the audio duration. This
|
|
approach is significantly faster and more efficient than generating all frames.
|
|
|
|
The vinyl rotates at a configurable RPM (default: 20 RPM for smooth animation).
|
|
Audio duration must be at least as long as one full rotation.
|
|
|
|
Dependency:
|
|
- ffmpeg should be installed on the OS and be present on the system $PATH
|
|
|
|
Usage:
|
|
main(pet_img_path="path/to/image.jpg", audio_track_path="path/to/audio.mp3")
|
|
|
|
Run script with uv:
|
|
$ uv run create-video.py
|
|
|
|
Configuration:
|
|
Only settings in the below categories should be modified:
|
|
- Asset paths
|
|
- Video settings
|
|
- Rotation settings
|
|
- Output settings
|
|
|
|
Do not modify calculated values (image positioning offsets, output settings).
|
|
"""
|
|
|
|
# /// script
|
|
# requires-python = ">=3.13"
|
|
# dependencies = [
|
|
# "mutagen",
|
|
# "pillow",
|
|
# ]
|
|
# ///
|
|
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from datetime import datetime as dt
|
|
from math import ceil
|
|
from pathlib import Path
|
|
|
|
from mutagen.mp3 import MP3
|
|
from PIL import Image
|
|
|
|
# START Configuration
|
|
|
|
# Asset paths
|
|
ASSET_DIR = "./assets"
|
|
BASE_IMG = f"{ASSET_DIR}/1080x1080-bg.png" # 1080 sq. px. image
|
|
VINYL_TEMPLATE = f"{ASSET_DIR}/736-x-736-record.png" # 736sq.px. image
|
|
NEEDLE_IMG = f"{ASSET_DIR}/needle.png" # 1080 sq. px. image
|
|
|
|
|
|
# Video settings
|
|
VIDEO_FORMAT = "mp4"
|
|
FINAL_VIDEO_WIDTH_PX = 720 # lower this for faster generation
|
|
FINAL_VIDEO_HEIGHT_PX = 720 # lower this for faster generation
|
|
FRAME_RATE = 15
|
|
|
|
# Rotation settings
|
|
# Rotations per minute (33.33 = standard LP speed, 45 = single speed)
|
|
# Lower value for slower rotation/spin
|
|
VINYL_RPM = 20
|
|
|
|
# Output settings
|
|
OUTPUT_DIR = "./output"
|
|
OUTPUT_VIDEO = (
|
|
f"{OUTPUT_DIR}/final_video-{dt.now().strftime('%Y%b%d_%H%M%S')}.{VIDEO_FORMAT}"
|
|
)
|
|
|
|
# END Configuration
|
|
|
|
# Image positioning offsets (centered on base image)
|
|
# These can be adjusted for quick iteration
|
|
# Original asset dimensions (at 1080p)
|
|
ORIGINAL_RESOLUTION = 1080
|
|
|
|
# Scale factor based on target resolution
|
|
SCALE_FACTOR = FINAL_VIDEO_WIDTH_PX / ORIGINAL_RESOLUTION
|
|
|
|
BASE_WIDTH = FINAL_VIDEO_WIDTH_PX
|
|
BASE_HEIGHT = FINAL_VIDEO_HEIGHT_PX
|
|
|
|
# Pet image settings
|
|
PET_SIZE_PX = int(360 * SCALE_FACTOR) # Size to fit in vinyl center circle
|
|
|
|
# Vinyl template settings
|
|
VINYL_SIZE_PX = int(736 * SCALE_FACTOR)
|
|
|
|
# Calculate centered positions (offset from top-left)
|
|
# For centering: offset = (base_dimension - layer_dimension) / 2
|
|
VINYL_OFFSET_X = (BASE_WIDTH - VINYL_SIZE_PX) // 2
|
|
VINYL_OFFSET_Y = (BASE_HEIGHT - VINYL_SIZE_PX) // 2 + int(
|
|
100 * SCALE_FACTOR
|
|
) # Lowered by 100px - proportionately (scaled)
|
|
|
|
PET_OFFSET_X = VINYL_OFFSET_X + (VINYL_SIZE_PX - PET_SIZE_PX) // 2
|
|
PET_OFFSET_Y = VINYL_OFFSET_Y + (VINYL_SIZE_PX - PET_SIZE_PX) // 2
|
|
|
|
# Needle is same size as base (1080x1080), so it's placed at (0, 0)
|
|
NEEDLE_OFFSET_X = 0
|
|
NEEDLE_OFFSET_Y = 0
|
|
|
|
|
|
def ensure_directories():
|
|
"""Create output directories if they don't exist."""
|
|
Path(OUTPUT_DIR).mkdir(exist_ok=True)
|
|
print(f"✓ Output directories created/verified")
|
|
|
|
|
|
def get_audio_duration(audio_track_path):
|
|
"""Read the duration of the audio track."""
|
|
print("Reading audio track duration...")
|
|
audio = MP3(audio_track_path)
|
|
duration = ceil(audio.info.length) # round up any fractional seconds
|
|
print(f"✓ Audio duration: {duration:.2f} seconds ({duration / 60:.2f} minutes)")
|
|
return duration
|
|
|
|
|
|
def load_and_resize_images(pet_img_path):
|
|
"""Load all images and resize them to required dimensions."""
|
|
print("Loading and resizing images...")
|
|
|
|
# Use LANCZOS for initial resize (better quality, only done once)
|
|
resize_mode = Image.Resampling.LANCZOS
|
|
|
|
# Load base image (should already be 1080x1080)
|
|
base_img = Image.open(BASE_IMG).convert("RGBA")
|
|
if base_img.size != (BASE_WIDTH, BASE_HEIGHT):
|
|
base_img = base_img.resize((BASE_WIDTH, BASE_HEIGHT), resize_mode)
|
|
|
|
# Load and resize pet image to 360x360
|
|
pet_img = Image.open(pet_img_path).convert("RGBA")
|
|
pet_img = pet_img.resize((PET_SIZE_PX, PET_SIZE_PX), resize_mode)
|
|
|
|
# Load vinyl template (should be 736x736 transparent PNG)
|
|
vinyl_img = Image.open(VINYL_TEMPLATE).convert("RGBA")
|
|
if vinyl_img.size != (VINYL_SIZE_PX, VINYL_SIZE_PX):
|
|
vinyl_img = vinyl_img.resize((VINYL_SIZE_PX, VINYL_SIZE_PX), resize_mode)
|
|
|
|
# Load needle image (should be 1080x1080 transparent PNG)
|
|
needle_img = Image.open(NEEDLE_IMG).convert("RGBA")
|
|
if needle_img.size != (BASE_WIDTH, BASE_HEIGHT):
|
|
needle_img = needle_img.resize((BASE_WIDTH, BASE_HEIGHT), resize_mode)
|
|
|
|
print(f"✓ Images loaded and resized")
|
|
print(f" - Base: {base_img.size}")
|
|
print(f" - Pet: {pet_img.size}")
|
|
print(f" - Vinyl: {vinyl_img.size}")
|
|
print(f" - Needle: {needle_img.size}")
|
|
|
|
return base_img, pet_img, vinyl_img, needle_img
|
|
|
|
|
|
def create_composite_frame(base_img, pet_img, vinyl_img, needle_img, rotation_angle=0):
|
|
"""Composite all layers into a single frame.
|
|
|
|
Args:
|
|
rotation_angle: Angle in degrees to rotate vinyl and pet image (clockwise)
|
|
"""
|
|
# Start with a copy of the base image
|
|
frame = base_img.copy()
|
|
|
|
# This is called hundreds of times (once per frame)
|
|
# BILINEAR is 2-3x faster with minimal quality loss in video
|
|
resample_mode=Image.Resampling.BILINEAR
|
|
|
|
# Layer 2: Rotate and paste pet image
|
|
if rotation_angle != 0:
|
|
# Rotate pet image around its center (negative for clockwise)
|
|
rotated_pet = pet_img.rotate(
|
|
-rotation_angle, resample=resample_mode, expand=False
|
|
)
|
|
frame.paste(rotated_pet, (PET_OFFSET_X, PET_OFFSET_Y), rotated_pet)
|
|
else:
|
|
frame.paste(pet_img, (PET_OFFSET_X, PET_OFFSET_Y), pet_img)
|
|
|
|
# Layer 3: Rotate and paste vinyl template
|
|
if rotation_angle != 0:
|
|
# Rotate vinyl around its center (negative for clockwise)
|
|
rotated_vinyl = vinyl_img.rotate(
|
|
-rotation_angle, resample=resample_mode, expand=False
|
|
)
|
|
frame.paste(rotated_vinyl, (VINYL_OFFSET_X, VINYL_OFFSET_Y), rotated_vinyl)
|
|
else:
|
|
frame.paste(vinyl_img, (VINYL_OFFSET_X, VINYL_OFFSET_Y), vinyl_img)
|
|
|
|
# Layer 4: Paste needle on top
|
|
frame.paste(needle_img, (NEEDLE_OFFSET_X, NEEDLE_OFFSET_Y), needle_img)
|
|
|
|
# Convert to RGB for FFmpeg (removes alpha channel, faster encoding)
|
|
return frame.convert("RGB")
|
|
|
|
|
|
def calculate_rotation_duration():
|
|
"""Calculate duration in seconds for one full 360° rotation based on RPM."""
|
|
rotation_duration = 60.0 / VINYL_RPM # seconds per rotation
|
|
return rotation_duration
|
|
|
|
|
|
def calculate_rotation_angle(frame_num, total_frames, include_last_frame=False):
|
|
"""Calculate rotation angle for a given frame.
|
|
|
|
Args:
|
|
frame_num: Current frame number (0-indexed)
|
|
total_frames: Total number of frames in one rotation
|
|
include_last_frame: If True, goes to 360°. If False, stops just before 360°
|
|
to avoid duplicate frames when looping
|
|
"""
|
|
progress = frame_num / total_frames # 0.0 to 1.0
|
|
|
|
if include_last_frame:
|
|
return progress * 360 # 0° to 360°
|
|
else:
|
|
# For seamless looping, we don't include the 360° frame (same as 0°)
|
|
return (progress * 360) % 360 # 0° to just under 360°
|
|
|
|
|
|
def generate_and_stream_frames(
|
|
base_img, pet_img, vinyl_img, needle_img, audio_duration, audio_track_path
|
|
):
|
|
"""Generate frames for one full rotation and stream to FFmpeg via stdin.
|
|
|
|
Args:
|
|
audio_duration: Duration of audio track in seconds
|
|
audio_track_path: Path to audio file
|
|
|
|
Returns:
|
|
subprocess.Popen: FFmpeg process handle
|
|
"""
|
|
step_start = time.time()
|
|
|
|
# Calculate frames needed for one full rotation
|
|
rotation_duration = calculate_rotation_duration()
|
|
frames_per_rotation = int(rotation_duration * FRAME_RATE)
|
|
|
|
# Calculate how many times to loop the rotation
|
|
total_rotations = audio_duration / rotation_duration
|
|
|
|
print(
|
|
f"Generating 1 rotation cycle ({frames_per_rotation} frames at {FRAME_RATE} fps)..."
|
|
)
|
|
print(f" Rotation duration: {rotation_duration:.2f} seconds at {VINYL_RPM} RPM")
|
|
print(f" Video will loop {total_rotations:.1f} times to match audio duration")
|
|
print(f" Streaming frames directly to FFmpeg (no temp files)...")
|
|
|
|
# Start FFmpeg process with stdin as input
|
|
# We'll use the loop filter to repeat the video seamlessly
|
|
# Calculate exact number of loops needed
|
|
num_loops = int(audio_duration / rotation_duration)
|
|
|
|
# fmt: off
|
|
ffmpeg_cmd = [
|
|
"ffmpeg",
|
|
"-y", # Overwrite output file
|
|
"-f", "image2pipe", # Read images from pipe
|
|
"-framerate", str(FRAME_RATE),
|
|
"-vcodec", "png", # Explicitly tell FFmpeg the input codec
|
|
"-i", "pipe:0", # Read from stdin
|
|
"-i", audio_track_path, # Audio input
|
|
"-filter_complex", f"[0:v]loop=loop={num_loops}:size={frames_per_rotation}:start=0[outv]", # Loop video
|
|
"-map", "[outv]", # Use looped video
|
|
"-map", "1:a", # Use audio from second input
|
|
"-preset", "faster", # Trade encoding time for file size (ultrafast/superfast/veryfast/faster/fast/medium)
|
|
"-c:v", "libx264",
|
|
"-crf", "28", # Constant Rate Factor: 18=high quality, 28=good quality/smaller, 32+=lower quality
|
|
"-pix_fmt", "yuv420p", # Convert pixel format (standard for MP4)
|
|
"-c:a", "aac", # Encode audio using AAC codec
|
|
"-b:a", "192k",
|
|
"-r", str(FRAME_RATE),
|
|
"-movflags", "+faststart", # Optimize for web streaming (metadata at beginning)
|
|
"-shortest", # Stop when audio ends
|
|
OUTPUT_VIDEO,
|
|
]
|
|
# fmt: on
|
|
|
|
try:
|
|
# Start FFmpeg process
|
|
ffmpeg_process = subprocess.Popen(
|
|
ffmpeg_cmd,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
bufsize=10**8, # Large buffer for performance
|
|
)
|
|
|
|
# Generate and stream frames for one full rotation
|
|
for frame_num in range(frames_per_rotation):
|
|
# Calculate rotation angle (excluding 360° to avoid duplicate with 0°)
|
|
rotation_angle = calculate_rotation_angle(
|
|
frame_num, frames_per_rotation, include_last_frame=False
|
|
)
|
|
|
|
# Create composite frame
|
|
composite_frame = create_composite_frame(
|
|
base_img, pet_img, vinyl_img, needle_img, rotation_angle
|
|
)
|
|
|
|
# Convert PIL Image to PNG bytes and write to FFmpeg stdin
|
|
composite_frame.save(ffmpeg_process.stdin, format="PNG")
|
|
|
|
# Progress indicator
|
|
if (frame_num + 1) % 50 == 0 or frame_num == frames_per_rotation - 1:
|
|
print(f" Progress: {frame_num + 1}/{frames_per_rotation} frames")
|
|
|
|
# Close stdin to signal end of input
|
|
ffmpeg_process.stdin.close()
|
|
|
|
# Wait for FFmpeg to finish
|
|
# Prevent buffer fill-up and cause the process to hang indefinitely
|
|
stdout, stderr = ffmpeg_process.communicate()
|
|
|
|
if ffmpeg_process.returncode != 0:
|
|
print(f"✗ Error creating video with ffmpeg:")
|
|
print(stderr.decode())
|
|
raise subprocess.CalledProcessError(ffmpeg_process.returncode, ffmpeg_cmd)
|
|
|
|
step_time = time.time() - step_start
|
|
print(f"✓ Video created successfully: {OUTPUT_VIDEO}")
|
|
print(f" Time taken: {step_time:.2f} seconds ({step_time / 60:.2f} minutes)")
|
|
|
|
except BrokenPipeError:
|
|
print("✗ FFmpeg process terminated unexpectedly")
|
|
raise
|
|
|
|
return step_time
|
|
|
|
|
|
def check_dependencies():
|
|
"""Verify that required system tools are installed."""
|
|
if shutil.which("ffmpeg") is None:
|
|
print("=" * 60)
|
|
print("✗ ERROR: 'ffmpeg' was not found on your system PATH.")
|
|
print("This tool is required to convert image frames into a video.")
|
|
print("\nTo install it:")
|
|
print(" - macOS: brew install ffmpeg")
|
|
print(" - Ubuntu/Debian: sudo apt-get install ffmpeg")
|
|
print(" - Windows: https://ffmpeg.org/download.html")
|
|
print("=" * 60)
|
|
raise SystemExit(1)
|
|
print("✓ Dependency check passed (ffmpeg found)")
|
|
|
|
|
|
def validate_audio_duration(audio_duration):
|
|
"""Validate that audio duration is sufficient for at least one rotation.
|
|
|
|
Args:
|
|
audio_duration: Duration of audio in seconds
|
|
|
|
Raises:
|
|
SystemExit: If audio is too short
|
|
"""
|
|
rotation_duration = calculate_rotation_duration()
|
|
if audio_duration < rotation_duration:
|
|
print("=" * 60)
|
|
print(
|
|
f"✗ ERROR: Audio duration ({audio_duration:.2f}s) is shorter than one full rotation ({rotation_duration:.2f}s at {VINYL_RPM} RPM)"
|
|
)
|
|
print(f"Please use a longer audio track or increase the VINYL_RPM setting.")
|
|
print("=" * 60)
|
|
raise SystemExit(1)
|
|
|
|
|
|
def main(pet_img_path, audio_track_path, output_path=None):
|
|
"""Main execution function.
|
|
|
|
Args:
|
|
pet_img_path: Path to pet image file
|
|
audio_track_path: Path to audio track file
|
|
output_path: Optional custom output path for the video file.
|
|
If not provided, uses auto-generated path in OUTPUT_DIR.
|
|
"""
|
|
global OUTPUT_VIDEO
|
|
if output_path:
|
|
OUTPUT_VIDEO = output_path
|
|
# Ensure parent directory exists
|
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
start_time = time.time()
|
|
start_datetime = dt.now()
|
|
|
|
print("=" * 60)
|
|
print(f"Start time: {start_datetime.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
print("=" * 60)
|
|
print(f"Pet image: {pet_img_path}")
|
|
print(f"Audio track: {audio_track_path}")
|
|
print("=" * 60)
|
|
|
|
check_dependencies()
|
|
|
|
# Step 0: Get audio duration and validate
|
|
audio_duration = get_audio_duration(audio_track_path)
|
|
|
|
# Validate audio duration
|
|
validate_audio_duration(audio_duration)
|
|
|
|
# Step 1: Setup
|
|
ensure_directories()
|
|
|
|
# Step 2: Load and prepare images
|
|
base_img, pet_img, vinyl_img, needle_img = load_and_resize_images(pet_img_path)
|
|
print("=" * 60)
|
|
|
|
# Step 3: Generate frames for one rotation and stream to FFmpeg with looping
|
|
generate_and_stream_frames(
|
|
base_img, pet_img, vinyl_img, needle_img, audio_duration, audio_track_path
|
|
)
|
|
|
|
end_time = time.time()
|
|
end_datetime = dt.now()
|
|
total_time = end_time - start_time
|
|
|
|
print("=" * 60)
|
|
print(f"✓ COMPLETE! Video saved to: {OUTPUT_VIDEO}")
|
|
print(f"End time: {end_datetime.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
print(f"Total time taken: {total_time:.2f} seconds ({total_time / 60:.2f} minutes)")
|
|
print(
|
|
f"Video file size: {Path(OUTPUT_VIDEO).stat().st_size / (1024 * 1024):.1f} MB"
|
|
)
|
|
print("=" * 60)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Default parameters for testing
|
|
PET_IMG = f"{ASSET_DIR}/dog_upload.jpg"
|
|
AUDIO_TRACK = f"{ASSET_DIR}/my-track.mp3"
|
|
|
|
main(pet_img_path=PET_IMG, audio_track_path=AUDIO_TRACK)
|