BadUStudio is designed to play the iconic Bad Apple!! animation on the Ulanzi D200 U-Studio stream controller.
Note: The player is video-only. The D200 unfortunately has no speaker.
The Ulanzi D200 is a budget stream controller featuring 13 customizable keys, plus a stats window that normally serves either as a clock or for CPU/RAM/GPU usage monitoring.
D200 units have (accidentally?) been shipping with an open adb root shell and numerous other characteristics that make the device extremely tinker-friendly.
Lucas Teske (@racerxdl) mentioned this on October 11, 2025, which led to this project.
- 1-bit image format for 24x memory reduction vs RGB888
- PNG support with fast 1-bit conversion
- 2x upscaling with optimized nearest-neighbor interpolation
- Smart compression with RLE and special handling for solid colors
- Optimized framebuffer rendering using lookup tables
- Rolling cache for smooth playback with minimal memory
- Direct framebuffer rendering with no Qt, X11, or graphics library dependencies
- CMake 3.16 or later
- C++17 compatible compiler
- Linux with framebuffer support
- Standard C++ library with filesystem support
- Python 3.7 or higher
- PIL/Pillow library
- OpenCV (cv2) for mp4_to_raw.py
Install Python dependencies:
pip3 install Pillow opencv-pythonThe D200 uses ARMv7 architecture, so you need to cross-compile from your development machine.
Install ARM cross-compiler:
# Ubuntu/Debian
sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf
# Arch Linux
sudo pacman -S arm-linux-gnueabihf-gccBuild for D200: (A toolchain-armv7.cmake file is already provided in the
repository)
mkdir build
cd build
cmake -DCMAKE_TOOLCHAIN_FILE=../toolchain-armv7.cmake ..
makeThe compiled binary will be at build/BadUStudio.
For testing on your local machine:
mkdir build
cd build
cmake ..
makeOption 1: Direct framebuffer (best for actual visual testing)
Switch to a TTY (text console) where the framebuffer is directly visible:
# Switch to TTY3 (Ctrl+Alt+F3)
# Login, then:
sudo ./build/BadUStudio --path frames --fps 30
# Switch back to X11 with Ctrl+Alt+F7 or Ctrl+Alt+F1This shows the actual output on your screen. The video will render in the top-right corner.
Option 2: Capture framebuffer screenshots
Test without leaving X11 by capturing screenshots:
# Install fbcat or fbgrab
sudo apt-get install fbcat # Ubuntu/Debian
# or
sudo apt-get install fbgrab
# Run player in background
sudo ./build/BadUStudio --path frames --fps 30 &
# Capture framebuffer to image
sudo fbcat > screenshot.ppm
# or
sudo fbgrab screenshot.png
# View the captured image
display screenshot.ppm # ImageMagick# Play PNG files directly
./build/BadUStudio --path frames_png --fps 30
# Play compressed .raw files
./build/BadUStudio --path frames --fps 30
# Play half-resolution with 2x upscaling
./build/BadUStudio --path frames_half --fps 30 --2x
# Loop continuously
./build/BadUStudio --path frames --fps 30 --loop-f, --fb <device> Framebuffer device (default: /dev/fb0)
-p, --path <path> Path to frames directory (default: ./frames)
-r, --fps <fps> Frame rate (default: 30)
-c, --cache <size> Cache size in frames (default: 50)
--2x Enable 2x upscaling for raw frames
--loop Loop video playback (default: exit after one play)
-h, --help Show help message
Extracts frames from MP4 video and converts directly to compressed 1-bit raw format.
Usage:
python3 utils/mp4_to_raw.py video.mp4 -o frames_raw -f 30Options:
-f, --fps: Target frame rate (default: source FPS)-t, --threshold: Binarization threshold 0-255 (default: 128)--start-frame: Skip to specific frame number--max-frames: Limit number of frames extracted--scale: Scale factor for output resolution (e.g., 0.5 for half resolution)--png-output: Output PNG files instead of .raw
Converts PNG images to compressed 1-bit raw format.
Single file:
python3 utils/convert_to_1bit_raw.py single input.png output.rawBatch conversion:
python3 utils/convert_to_1bit_raw.py batch input_dir output_dirAdjust binarization threshold (default: 128):
python3 utils/convert_to_1bit_raw.py batch input_dir output_dir --threshold 200Higher threshold = more white pixels (0-255).
Standard 1-bit (or grayscale) PNG files. Loaded using stb_image, converted to 1-bit on-the-fly with threshold of 128.
Note: using PNG images may be more effective storage-wise but leads to poor performance due to the decoding process. Expect no more than 14 FPS on the Ulanzi D200 while using PNG.
Custom format optimized for speed and compression.
Header (9 bytes):
- Width: 4 bytes (uint32, little-endian)
- Height: 4 bytes (uint32, little-endian)
- Compression type: 1 byte
0= Full black (no data follows)1= Full white (no data follows)2= RLE compressed (count+value pairs)3= Uncompressed packed bits
Data (variable length):
- Type 0/1: No data (image is solid color)
- Type 2: Pairs of [count byte][value byte]
- Type 3: Packed bits (1 bit per pixel, MSB first, row-major)
# Connect device via USB
adb devices
# You should see the D200 listed# Copy player and frames to device
adb push build/BadUStudio /userdata/
adb push data/frames /userdata/
# Make executable
adb shell chmod +x /userdata/BadUStudio# Run via ADB shell
adb shell
cd /userdata
chmod +x BadUStudio
./BadUStudio --path frames --fps 30 --loopError: No image files found in ./frames
Solution:
- Ensure frames are in
.rawor.pngformat - Check the path is correct:
--path frames - Verify files exist:
ls frames/
Check CPU usage on device:
adb shell topOn quad-core: ~22-25% = one core maxed out (normal)
Solutions:
- Use half-resolution with
--2xfor 60+ FPS - Use compressed .raw format instead of PNG
Solution:
- Reduce cache:
--cache 20 - Use half-resolution frames
- Check storage:
du -sh frames
Solution:
- Adjust threshold during conversion:
--threshold 150 - Try values between 0-255
- Lower = more black, higher = more white
.
├── src/
│ ├── main.cpp # Main player loop, argument parsing
│ ├── framebuffer.h/cpp # Optimized framebuffer with lookup tables
│ ├── image_scaler.h/cpp # PNG/raw loading, 2x upscaling, caching
│ └── stb_image.h # PNG decoder (header-only library)
├── utils/
│ ├── mp4_to_raw.py # Video to compressed 1-bit converter
│ └── convert_to_1bit_raw.py # PNG to compressed 1-bit converter
├── CMakeLists.txt # Build configuration
├── README.md # This file
└── CLAUDE.md # AI assistant guidance
- Lucas Teske (@racerxdl): For sharing about the D200's open ADB root shell, which inspired this project
- Ulanzi: For making a hackable device (intentionally or otherwise)
- Bad Apple!!: The iconic Touhou animation that makes all of this worthwhile
The code in this project is released into the public domain under the Unlicense.
Exceptions:
external/stb_image.h: Public domain (see file header for details)- Video data (Bad Apple!! frames): Subject to original copyright holders
Use this code however you want. No attribution required (but appreciated!).