A 128KB Export Pipeline

Published by Tim on Monday September 8, 2025

Last modified on September 9th, 2025 at 17:59

With some help by CoPilot I have coded a Bash script that you can use to convert your animations for the 128KB challenge into a wide range of video formats. It’s not completely finished yet, but it does a good job for 72×128 pixel gifs.

What is 128KB?

My goal is to cover all edge cases for converting submissions to the 128KB challenge with a single script and to make the tiny animations ready for sharing on the internet in high resolution.

This post will be updated with new versions of the script. And of course, constructive feedback is welcome!

Dependencies

  • ImageMagick (convertidentify)
  • gifsicle
  • ffmpeg
  • gum (for the interactive UI)

How this works

  1. Checks dependencies: Verifies that convertgifsicle, and ffmpeg are installed.
  2. Accepts input: Takes either a .gif file or a folder containing a png/ subdirectory with PNG frames.
  3. If input is a GIF:
    • Enlarges the GIF by 400%.
    • Creates a looped version (3x longer, infinite loop).
    • Converts the looped GIF to MP4.
    • Creates a padded (passepartout) MP4 with a black background.
  4. If input is a folder with PNGs:
    • Combines PNG frames into a GIF.
    • Runs the same post-processing steps as above.
  5. Outputs:
    • Enlarged GIF
    • Looped GIF
    • MP4
    • Padded MP4
      (All named after the input file or folder.)
#!/bin/bash
###############################################################################
#                                                                             #
#                        128KB MEDIA ENHANCEMENT SCRIPT                       #
#                        128kb.timrodenbroeker.de                             #
#                                                                             #
#                        Version: 2025-09-09.2                                 #
###############################################################################

clear

###############################################################################
#                        CLEANUP OLD OUTPUT FILES                             #
###############################################################################
find . -maxdepth 1 -type f -name "*processed*" -exec rm -f {} \;

# Exit immediately if a command exits with a non-zero status
set -e

###############################################################################
#                        COLOR CODES FOR TERMINAL                             #
###############################################################################

GREEN="\033[42;30m"
AQUA="\033[38;2;0;255;255m" # #00ffff
CYAN="\033[36m"
YELLOW="\033[33m"
RESET="\033[0m"

###############################################################################
#                                FANCY BANNER                                 #
###############################################################################
gum style --foreground "#00ffff" --border double --align center --margin "1 2" --padding "1 4" "
 ██╗██████╗  █████╗ ██╗  ██╗██████╗ 
███║╚════██╗██╔══██╗██║ ██╔╝██╔══██╗
╚██║ █████╔╝╚█████╔╝█████╔╝ ██████╔╝
 ██║██╔═══╝ ██╔══██╗██╔═██╗ ██╔══██╗
 ██║███████╗╚█████╔╝██║  ██╗██████╔╝
 ╚═╝╚══════╝ ╚════╝ ╚═╝  ╚═╝╚═════╝ "
gum style --foreground "#00ffff" --align center "a 128KB media enhancement script"
gum style --foreground "#00ffff" --align center "128kb.timrodenbroeker.de"
echo

###############################################################################
#                        PASSEPARTOUT COLOR SELECTION UI                      #
###############################################################################

ALL_COLORS=("#000000" "#aaaaaa" "#f1f1f1" "#ffffff" "#ff0000" "#00ff00" "#0000ff" "#ffff00")
color_choice=$(gum choose --header "Choose a background color for the passepartout:" \
  "1) #000000 (black)" \
  "2) #aaaaaa (gray)" \
  "3) #f1f1f1 (light gray)" \
  "4) #ffffff (white)" \
  "5) #ff0000 (red)" \
  "6) #00ff00 (green)" \
  "7) #0000ff (blue)" \
  "8) #ffff00 (yellow)")
color_choice=${color_choice:0:1}

if [[ "$color_choice" =~ ^[1-8]$ ]]; then
  case "$color_choice" in
    1) PASSEPARTOUT_COLOR="#000000" ;;
    2) PASSEPARTOUT_COLOR="#aaaaaa" ;;
    3) PASSEPARTOUT_COLOR="#f1f1f1" ;;
    4) PASSEPARTOUT_COLOR="#ffffff" ;;
    5) PASSEPARTOUT_COLOR="#ff0000" ;;
    6) PASSEPARTOUT_COLOR="#00ff00" ;;
    7) PASSEPARTOUT_COLOR="#0000ff" ;;
    8) PASSEPARTOUT_COLOR="#ffff00" ;;
  esac
  echo -e "${CYAN}Passepartout color set to: $PASSEPARTOUT_COLOR${RESET}"
  echo
else
  echo -e "${CYAN}Rendering with all colors...${RESET}"
  for PASSEPARTOUT_COLOR in "${ALL_COLORS[@]}"; do
    echo -e "${CYAN}Passepartout color set to: $PASSEPARTOUT_COLOR${RESET}"
    ARGS=("$@")
    PASSEPARTOUT_COLOR="$PASSEPARTOUT_COLOR" bash "$0" --color-loop "${ARGS[@]}"
  done
  exit 0
fi

###############################################################################
#                        CHECK FOR REQUIRED TOOLS                             #
###############################################################################
for tool in convert gifsicle ffmpeg; do
  if ! command -v $tool &> /dev/null; then
    echo -e "${YELLOW}Error: $tool is not installed. Please install it first.${RESET}"
    exit 1
  fi
done

###############################################################################
#                                CONFIGURATION                                #
###############################################################################
OUTPUT_DIR="output"

###############################################################################
# Function: generate1080x1920
# Enlarges, loops, and generates 1080x1920 or 1080x1080 MP4
###############################################################################
generate1080x1920() {
  INPUT_GIF="$1"
  BASENAME="$2"
  local SCALE_FACTOR=400           # For scaling up GIFs
  local LOOP_COUNT=5               # How many times to loop the GIF
  local MP4_WIDTH=1080             # Standard MP4 width
  local MP4_HEIGHT=1920            # Standard MP4 height
  local MP4_SQUARE=1080            # Square MP4 size for 128x128 GIFs
  mkdir -p "$OUTPUT_DIR"
  OUTPUT_GIF="${OUTPUT_DIR}/${BASENAME}_processed.gif"
  LOOPED_GIF="${OUTPUT_DIR}/${BASENAME}_processed_looped.gif"
  OUTPUT_MP4_1080x1920="${OUTPUT_DIR}/${BASENAME}_processed_1080x1920.mp4"
  OUTPUT_MP4_1080x1080="${OUTPUT_DIR}/${BASENAME}_processed_1080x1080.mp4"
  ORIG_SIZE=$(identify "$INPUT_GIF" | head -n1 | awk '{print $3}')
  ORIG_WIDTH=$(echo $ORIG_SIZE | cut -dx -f1)
  ORIG_HEIGHT=$(echo $ORIG_SIZE | cut -dx -f2)
  echo -e "${CYAN}[*] [1080x1920] Original GIF size: ${ORIG_WIDTH} x ${ORIG_HEIGHT} ${RESET}"
  echo -e "${CYAN}[*] [1080x1920] Processing GIF (scaling up)...${RESET}"
  convert "$INPUT_GIF" -filter point -resize ${SCALE_FACTOR}% "$OUTPUT_GIF"
  echo "    Created processed GIF: $OUTPUT_GIF"
  echo -e "${CYAN}[*] [1080x1920] Looping GIF...${RESET}"
  gifsicle --loopcount=0 $(for i in $(seq 1 $LOOP_COUNT); do echo "$OUTPUT_GIF"; done) > "$LOOPED_GIF"
  echo "    Created looped GIF: $LOOPED_GIF"
  if [ "$ORIG_WIDTH" -eq 128 ] && [ "$ORIG_HEIGHT" -eq 128 ]; then
    echo -e "${CYAN}[*] [1080x1920] Converting to MP4 (1080x1080, no passepartout)...${RESET}"
    ffmpeg -y -i "$LOOPED_GIF" -vf "scale=${MP4_SQUARE}:${MP4_SQUARE}:flags=neighbor" -movflags faststart -pix_fmt yuv420p "$OUTPUT_MP4_1080x1080" &> /dev/null
    echo "    Created 1080x1080 MP4: $OUTPUT_MP4_1080x1080"
  else
    echo -e "${CYAN}[*] [1080x1920] Converting to MP4 (1080x1920, no passepartout)...${RESET}"
    ffmpeg -y -i "$LOOPED_GIF" -vf "scale=${MP4_WIDTH}:${MP4_HEIGHT}:flags=neighbor" -movflags faststart -pix_fmt yuv420p "$OUTPUT_MP4_1080x1920" &> /dev/null
    echo "    Created 1080x1920 MP4: $OUTPUT_MP4_1080x1920"
  fi
  rm "$OUTPUT_GIF" "$LOOPED_GIF"
}

###############################################################################
# Function: generatePadded
# Enlarges, loops, and generates padded MP4 (passepartout)
###############################################################################
generatePadded() {
  INPUT_GIF="$1"
  BASENAME="$2"
  local SCALE_FACTOR=400           # For scaling up GIFs
  local LOOP_COUNT=5               # How many times to loop the GIF
  local PAD_WIDTH=800              # Passepartout pad width
  local PAD_HEIGHT=1000            # Passepartout pad height
  mkdir -p "$OUTPUT_DIR"
  COLOR_SUFFIX="$(echo "$PASSEPARTOUT_COLOR" | tr -d '#')"
  OUTPUT_GIF="${OUTPUT_DIR}/${BASENAME}_processed.gif"
  LOOPED_GIF="${OUTPUT_DIR}/${BASENAME}_processed_looped.gif"
  OUTPUT_PADDED_MP4="${OUTPUT_DIR}/${BASENAME}_processed_padded_${COLOR_SUFFIX}.mp4"
  ORIG_SIZE=$(identify "$INPUT_GIF" | head -n1 | awk '{print $3}')
  ORIG_WIDTH=$(echo $ORIG_SIZE | cut -dx -f1)
  ORIG_HEIGHT=$(echo $ORIG_SIZE | cut -dx -f2)
  echo -e "${CYAN}[*] [Padded] Original GIF size: ${ORIG_WIDTH} x ${ORIG_HEIGHT} ${RESET}"
  echo -e "${CYAN}[*] [Padded] Processing GIF (scaling up)...${RESET}"
  convert "$INPUT_GIF" -filter point -resize ${SCALE_FACTOR}% "$OUTPUT_GIF"
  echo "    Created processed GIF: $OUTPUT_GIF"
  echo -e "${CYAN}[*] [Padded] Looping GIF...${RESET}"
  gifsicle --loopcount=0 $(for i in $(seq 1 $LOOP_COUNT); do echo "$OUTPUT_GIF"; done) > "$LOOPED_GIF"
  echo "    Created looped GIF: $LOOPED_GIF"
  if [ "$ORIG_WIDTH" -eq 128 ] && [ "$ORIG_HEIGHT" -eq 128 ]; then
    echo -e "${CYAN}[*] [Padded] Adding passepartout (padded MP4, 1080x1920 with 1080x1080 content)...${RESET}"
    ffmpeg -y -i "$LOOPED_GIF" \
      -vf "scale=1080:1080:flags=neighbor,pad=1080:1920:0:420:${PASSEPARTOUT_COLOR}" \
      -movflags faststart -pix_fmt yuv420p "$OUTPUT_PADDED_MP4" &> /dev/null
    echo "    Created padded MP4: $OUTPUT_PADDED_MP4"
  else
    echo -e "${CYAN}[*] [Padded] Adding passepartout (padded MP4)...${RESET}"
    ffmpeg -y -i "$LOOPED_GIF" \
      -vf "format=rgba,pad=${PAD_WIDTH}:${PAD_HEIGHT}:((${PAD_WIDTH}-iw)/2):((${PAD_HEIGHT}-ih)/2):${PASSEPARTOUT_COLOR}" \
      -movflags faststart -pix_fmt yuv420p "$OUTPUT_PADDED_MP4" &> /dev/null
    echo "    Created padded MP4: $OUTPUT_PADDED_MP4"
  fi
  rm "$OUTPUT_GIF" "$LOOPED_GIF"
}


###############################################################################
# Function: process_single_gif
# Processes an existing GIF file
###############################################################################
process_single_gif() {
  INPUT="$1"
  BASENAME="${INPUT%.*}"
  echo -e "${YELLOW}Processing existing GIF: $INPUT${RESET}"
  generate1080x1920 "$INPUT" "$BASENAME"
  generatePadded "$INPUT" "$BASENAME"
}


###############################################################################
# Function: process_all_gifs_in_folder
# Processes all GIF files in the current directory
###############################################################################
process_all_gifs_in_folder() {
  shopt -s nullglob
  files=(*.gif)
  if [ ${#files[@]} -eq 0 ]; then
    echo -e "${YELLOW}No GIF files found in current directory.${RESET}"
    exit 1
  fi
  for gif in "${files[@]}"; do
    process_single_gif "$gif"
  done
}

###############################################################################
#                                   MAIN                                      #
###############################################################################
if [ $# -eq 0 ]; then
  echo -e "${YELLOW}Usage:${RESET}"
  echo "  $0 input.gif"
  echo "  $0 input_folder/"
  echo "  $0 *.gif   # process all GIFs in current folder"
  exit 1
fi

if [ $# -gt 1 ]; then
  # Multiple arguments, likely *.gif expansion
  for gif in "$@"; do
    if [[ "$gif" == *.gif && -f "$gif" ]]; then
      process_single_gif "$gif"
    else
      echo -e "${YELLOW}Skipping non-GIF: $gif${RESET}"
    fi
  done
elif [ "$1" == "*.gif" ]; then
  process_all_gifs_in_folder
elif [ -f "$1" ] && [[ "$1" == *.gif ]]; then
  process_single_gif "$1"
elif [ -d "$1" ]; then
  echo -e "${YELLOW}Error: PNG-to-GIF conversion has been moved to 128kbGifMaker. Please use that script first.${RESET}"
  exit 1
else
  echo -e "${YELLOW}Error: Input must be a .gif file, '*.gif', or a directory containing a png/ subfolder.${RESET}"
  exit 1
fi

echo -e "${GREEN}All done!${RESET}"

Enjoying the content?

Since 2018, I have published 248 interviews, case studies, and tutorials, along with over 359 lessons in 22 online courses – and there's more to come! If you want to get full access or simply support my work and help keep this platform thriving, please consider supporting me on Patreon. Thank you very much!

Speaking Image

Monthly Newsletter

Fresh perspectives circling around Creative Coding, Design and Technology, every first Friday of the month, directly to your inbox.

Related

Lo-Fi Collage Machine

Click here to login or connect!To view this content, you must be a member of Tim's Patreon at €10 or […]

Diogenes meets Demo Festival

Below is the written version of my talk at DEMO Festival in Amsterdam, January 2025. I’ve also recorded an audio […]

A bash script to convert 128KB gifs to mp4

Do you want to share the creations and sketches you’ve developed for the 128KB challenge on Instagram or other social […]

DEMO 2025 – My Submissions

Limitations have always been playing a major role in my creative work; I was only able to develop my best […]

Throwback: My Talk at Demo Festival 2022

The next edition of the DEMO Festival is already approaching and I am currently developing a brand new talk for […]

Powers of Two – 128kb by Lena Weber

20 = 1 21 = 222 = 323 = 824 = 1625 = 3226 = 6427 = 128 … »In […]

A p5.js starter template for the 128kb Challenge

Your 128kb journey starts here! This is a template you can use to start developing your idea within the 128kb […]

The 128kb Framework and its Aesthetic Characteristics

One day in early 2024 I started to experiment with a new idea. I wrote down a set of rules […]