Playing Bad Apple on my Website
05/05/2025
A while back, I rebuilt my website. Among many technical and aesthetic changes, a new addition was an animated ASCII display inspired by old CRT monitors.
The display was pretty cool and could render all kinds of animations.
But can it play Bad Apple?
My friend after their first visit to the website
If you visit https://johnling.me?apple it does.
I'm not kidding.
Here's how I made it.
Encoding Bad Apple
Resizing
First thing I had to do was to resize the video into a resolution that could be rendered on my website.
My website can render ASCII via an "ASCII display" component which I wrote about here.
While the display can be of any size, I had to select a static resolution. I settled on an incredibly detailed 40 by 30 pixels.
Initally I attempted to use FFMPeg however it introduced artefacts into the resulting video that I would have to fix via post-processing later. So I settled on a Python script that used OpenCV.
resize.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import cv2
import time
def main():
cap = cv2.VideoCapture("bad_apple.mp4")
fourcc = cv2.VideoWriter_fourcc(*"MP4V")
out = cv2.VideoWriter("output.mp4", fourcc, 30, (40, 30))
if not cap.isOpened():
return
while cap.isOpened():
ret, frame = cap.read()
if ret:
cv2.imshow("frame", frame)
if cv2.waitKey(33) & 0xFF == rd('q'):
break
# 0.083 came from the scaling factor needed to reduce
# a 480x360 video to 40x30
resized = cv2.resize(frame, None, fx=0.083, fy=0.083, interpolation=cv2.INTER_NEAREST)
out.write(resized)
print("Done")
cap.release()
out.release()
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
You can now literally count the pixels in the video. However that is what we want.
Converting Bad Apple to ASCII
Next, I needed to convert the video into text to be rendered
Since the entirety of Bad Apple is black and white, we could just iterate through the pixels and convert them into either '*' or ' ' to represent black and white respectively.
convert.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import cv2
def main():
cap = cv2.VideoCapture("output.mp4")
file = open("out.txt", 'w')
while cap.isOpened():
ret, frame = cap.read()
if not ret:
print("Error")
break
output = ""
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
for i in range(30):
row = ""
for j in range(40):
if gray[i][j] > 153:
row += ' '
else:
row += '*'
output += row
cv2.imshow("frame", gray)
if cv2.waitKey(33) & 0xFF == ord('q'):
break
output += '
'
file.write(output)
file.close()
cap.release()
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
This script converts all the
Delivering the Content
Technically this article is a bit out of order as I decided on the method of rendering before writing the Python scripts.
I passed around a few ideas on how to do this most of them pretty over-engineered. Eventually, I converged on simply storing the frames in a single text file, compressing it then download, unzip and parse the file when needed.
The JSZip library came in handy and I wrote some code to download, unzip, split on newlines and load the frames into an array.
animations.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// ...
import JSZip from 'jszip';
let currentFrame: number = 0; // index into frames array
let frames: string[] = [];
export const bapple_init = async () => {
// if frames have already been unzipped
if (frames[0] !== undefined) {
return;
}
// change this to actual domain later
const zip: Response = await fetch("https://www.johnling.me/frames.zip");
const content: Blob = await zip.blob();
const jsZip = new JSZip();
const file = await jsZip.loadAsync(content);
const frameString: string = await file.file("frames.txt")!.async("string");
frames = await frameString.split("
");
currentFrame = 0;
return;
}
export const bapple_next_frame = (frameBuffer: string[][], width: number, height: number) => {
// if animation has not unzipped yet or already finished
if (frames[currentFrame] === undefined) {
return Array(height).fill(null).map(() => Array(width).fill('*'))
}
const frame: string = frames[currentFrame];
for (let i = 0; i < height; i++) {
for (let j = 0; j < width; j++) {
frameBuffer[i][j] = frame[(i * width) + j]
}
}
currentFrame += 1;
return frameBuffer;
}
export const bapple_cleanup = () => {
currentFrame = 0;
return;
}
// ...
Since I had already written a system of "nextFrame" functions for my ASCII display I simply made my "nextFrame" function iterate to the next frame in the array.