animate_pngs.py

#

Create a video sequence from PNG files in a directory, with a date frame at the start.

Usage: ./animate_pngs.py -h ./animate_pngs.py -i /path/to/png/directory ./animate_pngs.py -i /path/to/png/directory -o custom_output.mp4 ./animate_pngs.py -i /path/to/png/directory -f 30 -v ./animate_pngs.py -i /path/to/png/directory –open-dir

import glob
import logging
import os
import re
import subprocess
from argparse import ArgumentParser, RawDescriptionHelpFormatter
from datetime import datetime

import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
#
def setup_logging(verbosity):
    logging_level = logging.WARNING
    if verbosity == 1:
        logging_level = logging.INFO
    elif verbosity >= 2:
        logging_level = logging.DEBUG

    logging.basicConfig(
        handlers=[
            logging.StreamHandler(),
        ],
        format="%(asctime)s - %(filename)s:%(lineno)d - %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
        level=logging_level,
    )
    logging.captureWarnings(capture=True)
#
def check_file_name_pattern(filename):
    pattern = r"^animated_\d{8}_\d{6}\.mp4$"
    return re.match(pattern, os.path.basename(filename)) is not None
#

Get a sorted list of PNG files from the specified directory.

def get_sorted_png_files(directory):
#
    png_files = glob.glob(os.path.join(directory, "*.png"))
    sorted_files = sorted(png_files)
    logging.info(f"Found {len(sorted_files)} PNG files")
    logging.debug("Sorted PNG files:")
    for file in sorted_files:
        logging.debug(f"  {os.path.basename(file)}")
    return sorted_files
#

Generate a default output filename in the input directory.

def generate_default_output_filename(input_dir):
#
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    return os.path.join(input_dir, f"animated_{timestamp}.mp4")
#

Create a frame with the given date in a very large font.

def create_date_frame(date, size=(640, 480), font_file=None):
#
    img = Image.new("RGB", size, color="black")
    draw = ImageDraw.Draw(img)
#

Use a larger font size (adjust as needed)

    font_size = min(size) // 10
    logging.debug(f"Attempting to use font size: {font_size}")

    if font_file and os.path.exists(font_file):
        try:
            font = ImageFont.truetype(font_file, font_size)
            logging.info(f"Using custom font: {font_file}")
        except OSError:
            logging.warning(f"Failed to load custom font: {font_file}")
            font = None
    else:
        font = None

    if font is None:
#

List of fonts to try (add or remove based on your system)

        font_names = ["Arial.ttf", "Helvetica.ttf", "DejaVuSans.ttf", "FreeSans.ttf"]

        for font_name in font_names:
            try:
                font = ImageFont.truetype(font_name, font_size)
                logging.info(f"Using font: {font_name}")
                break
            except OSError:
                continue

        if font is None:
            logging.warning("No suitable TrueType font found, using default font")
            font = ImageFont.load_default()

    date_str = date.strftime("%Y-%m-%d")
    logging.debug(f"Date string: {date_str}")
#

Get the size of the text

    bbox = draw.textbbox((0, 0), date_str, font=font)
    text_width = bbox[2] - bbox[0]
    text_height = bbox[3] - bbox[1]
    logging.debug(f"Text size: {text_width}x{text_height}")
#

Calculate position to center the text

    position = ((size[0] - text_width) / 2, (size[1] - text_height) / 2)
    logging.debug(f"Text position: {position}")
#

Draw the text

    draw.text(position, date_str, fill="white", font=font)
    return img
#

Determine the most common image size in the list.

def get_common_size(images):
#
    sizes = [img.size for img in images if isinstance(img, Image.Image)]
    if not sizes:
        return (640, 480)  # Default size if no valid sizes found
    return max(set(sizes), key=sizes.count)
#

Resize the image to the given size, maintaining aspect ratio and filling with black.

def resize_image(img, size):
#
    if not isinstance(img, Image.Image):
        return img  # Return as-is if not a PIL Image (e.g., the date frame)
    img_ratio = img.width / img.height
    target_ratio = size[0] / size[1]
    if img_ratio > target_ratio:
#

Image is wider, resize based on width

        new_size = (size[0], int(size[0] / img_ratio))
    else:
#

Image is taller, resize based on height

        new_size = (int(size[1] * img_ratio), size[1])
    resized_img = img.resize(new_size, Image.LANCZOS)
    new_img = Image.new("RGB", size, (0, 0, 0))
    paste_pos = ((size[0] - new_size[0]) // 2, (size[1] - new_size[1]) // 2)
    new_img.paste(resized_img, paste_pos)
    return new_img
#

Create a video from the list of PNG files, including a date frame at the start.

def create_video(png_files, output_file, fps, start_date, codec="mp4v", font_file=None):
#
    images = []
#

Add date frame

    date_frame = create_date_frame(start_date, font_file=font_file)
    images.append(date_frame)
#

Process PNG files

    for png_file in png_files:
        logging.info(f"Processing file: {png_file}")
        try:
            with Image.open(png_file) as img:
#

Verify the image by loading it completely

                img.load()
                images.append(img.copy())
        except OSError as e:
            logging.error(f"Error processing {png_file}: {str(e)}. Skipping this file.")
        except Exception as e:
            logging.error(f"Unexpected error processing {png_file}: {str(e)}. Skipping this file.")

    if len(images) > 1:
#

Determine common size

        common_size = get_common_size(images)
        logging.info(f"Resizing images to {common_size}")
#

Resize images

        resized_images = [resize_image(img, common_size) for img in images]
#

Convert to numpy arrays

        np_images = [cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) for img in resized_images]
#

Create video writer

        fourcc = cv2.VideoWriter_fourcc(*codec)
        out = cv2.VideoWriter(output_file, fourcc, fps, common_size)

        logging.info(f"Creating video: {output_file}")
        for img in np_images:
            out.write(img)

        out.release()
        actual_duration = len(np_images) / fps
        logging.info(f"Video created successfully. Duration: {actual_duration:.2f} seconds")
    else:
        logging.error("Not enough valid images to create a video")
#

Open the specified directory using the default file manager.

def open_directory(directory):
#
    try:
        if os.name == "nt":  # For Windows
            os.startfile(directory)
        elif os.name == "posix":  # For macOS and Linux
            opener = "open" if os.uname().sysname == "Darwin" else "xdg-open"
            subprocess.call([opener, directory])
        logging.info(f"Opened directory: {directory}")
    except Exception as e:
        logging.error(f"Failed to open directory: {str(e)}")
#
def parse_args():
    parser = ArgumentParser(description=__doc__, formatter_class=RawDescriptionHelpFormatter)
    parser.add_argument("-i", "--input-dir", required=True, help="Directory containing PNG files")
    parser.add_argument("-o", "--output-file", help="Output video file name")
    parser.add_argument("--fps", type=int, default=30, help="Frames per second (default: 30)")
    parser.add_argument("--codec", default="mp4v", help="Codec to use (default: mp4v)")
    parser.add_argument("--open-dir", action="store_true", help="Open the input directory after processing")
    parser.add_argument(
        "-v",
        "--verbose",
        action="count",
        default=0,
        dest="verbose",
        help="Increase verbosity of logging output",
    )
    parser.add_argument("--font", help="Path to a TrueType font file to use for the date frame", type=str, default=None)
    return parser.parse_args()
#
def main(args):
    setup_logging(args.verbose)

    logging.info(f"Processing PNG files in directory: {args.input_dir}")
    png_files = get_sorted_png_files(args.input_dir)

    if not png_files:
        logging.error(f"No PNG files found in directory: {args.input_dir}")
        return

    logging.info("Files to be processed (in order):")
    for file in png_files:
        logging.info(f"  {os.path.basename(file)}")

    if args.output_file:
        output_file = args.output_file
    else:
        output_file = generate_default_output_filename(args.input_dir)

    logging.info(f"Output file: {output_file}")
#

Check if the output file already exists and matches the pattern

    if os.path.exists(output_file) or check_file_name_pattern(output_file):
        logging.info("Animated file already exists and matches the expected pattern. Skipping video creation.")
        return

    try:
#

Extract date from the first PNG file

        first_file = os.path.basename(png_files[0])
        start_date = datetime.strptime(first_file[:8], "%Y%m%d")

        create_video(png_files, output_file, args.fps, start_date, args.codec, font_file=args.font)

        if args.open_dir:
            open_directory(args.input_dir)
    except ValueError as e:
        logging.error(f"Error parsing date from filename: {str(e)}")
    except Exception as e:
        logging.error(f"An unexpected error occurred: {str(e)}")
    finally:
        logging.info("Processing completed. Check the logs for any skipped files or errors.")


if __name__ == "__main__":
    args = parse_args()
    main(args)