In my last post I mentioned converting an animated WebP image format into a WebM movie format. This post expands on how I did it.

First off, you might ask why the need to convert from an animated image to a movie? Its just because Grav uses the PHP function imagecreatefromwebp which does not support reading animated WebP files. Hence I get this error:

PHP E_ERROR: gd-webp cannot allocate temporary buffer

MoviePy

In searching for a solution, I found a workable code snippet here: Convert animated webp to mp4 faster.

It uses MoviePy to read the input animated images (.webp, .gif or .png), extract each frame into a separate file, and then compile all the frames back into a movie (.webm or .mp4).

The MoviePy source code kind of implies it supports frames of varying durations, i.e. ImageSequenceClip(sequence, durations), but it does not work for me. The error is:

No 'fps' (frames per second) attribute specified for function write_videofile and the clip has no 'fps' attribute. Either provide e.g. fps=24 in the arguments of the function, or define the clip's fps with 'clip.fps=24'

At a glace, MobiePy does not seem to be an active project, so I won’t hold my breath for feature enhancements. But since I am not aware of any other similar project, I figured to try workaround my issue to add support for frames of varying duration.

BTW I while my code is original and not a copy of the original, I did follow the same technique but improve it with my workaround. The original supports “additive frames” and a “global palette” for GIFs, but mine does not.

Installation and Code

As mentioned above, MoviePy only works with a fixed frame duration / FPS. So I implemented the workaround for variable frame durations in my webp2webm.py code below. To briefly explain:

  • First, extractFrames(file, folder) will extract every frame from the source file and save each to the given folder into a temporary folder, using the file naming convention xxxxx-yyyyy.png where xxxxx is the frame’s sequence and yyyyy is the playback duraiton.
  • Then, determineDuration(durations) will check if all frames are played with the same duration.
  • If so, i.e. there is a constant frame rate, then compile all the images in the folder into an output movie file using the function compileFolder(output, folder, duration) - this uses the moviepy.video.io.ImageSequenceClip.ImageSequenceClip(folder, fps), converting the duration to FPS (refer to MoviePy documentation for more examples)
  • If not, i.e. each frame has a different duration, then, to workaround the fixed FPS limitation:
    • convert each saved frame into an individual movie clip of the given duration,
    • and store these movie clips in an in-memory list,
    • then combine and re-encode them as a movie.

This workaround is slow and uses a lot of memory because all clips are stored in an in-memory list! Many frames will probably result in out of memory! This is because while concatenate_videoclips() can load clips from files (instead of memory), I get an error `'str' object has no attribute 'duration'. Buggy!

BTW, if you turn off logging by deleting the parameter logger=None, you will see the encoding process of FFmpeg (which is installed by MoviePy).

import os, sys, tempfile, shutil
import PIL.Image
import moviepy.video.io.ImageSequenceClip
from moviepy.editor import concatenate_videoclips

def extractFrames(file, folder):
    i = 0
    files = []
    durations = []
    image = PIL.Image.open(file)
    try:
        while True:
            frame = PIL.Image.new('RGBA', image.size)
            frame.paste(image, (0, 0), image.convert('RGBA'))
            duration = image.info['duration'] if 'duration' in image.info else 0
            name = f'{i:05}-{duration:05}.png'
            print(f' Saving frame #{i} {image.size} as {name}')
            name = os.path.join(folder, name)

            durations.append(duration)
            files.append(name)
            frame.save(name)
            i += 1
            image.seek(image.tell() + 1)
    except EOFError:
        pass
    return files, durations

def determineDuration(durations):
    same = all(d == durations[0] for d in durations)
    if (same):
        return durations[0], None
    return None, durations

def compileFolder(output, folder, duration):
    try:
        movie = moviepy.video.io.ImageSequenceClip.ImageSequenceClip(folder, fps=1000/duration)
        movie.write_videofile(output, logger=None)
    except Exception as x:
        print(f'ERROR moviepy: {x}')

def compileFiles(output, files, durations):
    try:
        if type(files) == list and type(durations) == list:
            print(f' WARNING: using slow, memory-intensive workaround to support varying durations')
            i = 0
            clips = []
            for file in files:
                clip = moviepy.video.io.ImageSequenceClip.ImageSequenceClip([file], fps=1000/durations[i])
                clips.append(clip)
                i += 1
            movie = concatenate_videoclips(clips, method='compose')
            movie.write_videofile(output, logger=None)
    except Exception as x:
        print(f'ERROR moviepy: {x}')

if len(sys.argv) < 3:
    print(f'USAGE: {sys.argv[0]} input.webp output.webm [duration_ms]')
    sys.exit(-1)

input = sys.argv[1]
output = sys.argv[2]
folder = tempfile.mkdtemp()

try:    
    print(f'Extracting frames from {input} to {folder}')
    files, durations = extractFrames(input, folder)
    duration, durations = determineDuration([int(sys.argv[3])] if len(sys.argv) > 3 else durations)

    if durations:
        print('x')
        print(f'Creating {output} of varying frame durations')
        compileFiles(output, files, durations)
    else:
        duration = 1 if duration == 0 else duration
        print(f'Creating {output} using frame duration {duration} ms')
        compileFolder(output, folder, duration)

except Exception as x:
    print(f'ERROR: {x}')

shutil.rmtree(folder)
print('All done!')

On macOS with the bundled python3 installation, I use this method to create a Python virtual environment and install pre-requisites:

python3 -m venv v
source v/bin/activate
pip install moviepy 

Subsequently, to run the code, see the examples below run. It is possible to optionally override the frame duration with a final parameter which is expressed in milliseconds, i.e. for 0.5 seconds per frame, e.g.:

python webp2webm.py inputfile.webp outputfile.webm
python webp2webm.py inputfile.png outputfile.mp4 500

From my (very limited) tests, the script reads my .webp, .gif or .png files and outputs .webm or .mp4. Other formats may work...

As usual: DO NOT RUN MY CODE. I barely test anything and I never handle errors properly, see disclaimers! :)