My office has a big, blank wall. It’s the boring background of all my Zoom calls.
This wall is in dire need of… something. My first thought was my Buster Keaton poster:
But that’s too static. I want to change the background frequently and my basement can’t handle the amount of posters I would have to buy. So I bought a projector to show anything I want. Here’s what I’ve got so far:
Here’s how I got there, but if you’re more interested in coding with GitHub Copilot you can jump ahead to the code.
The Plan
I love movies, so I want to turn the blank wall into a silver screen. I want the ballet from Singing in the Rain, the safe-cracking from Thief, the graffiti from Candyman. Moments from Lone Wolf and Cub, Rear Window, Raiders of the Lost Ark, Deep Red and so much more.
While considering my options, I remembered the Very Slow Movie Player (VSMP). The VSMP is a small e-ink screen that plays movies at super slow speed. What if I did that, but projected the results onto my blank wall? I could have a much more dynamic background and, when bored, explore the details of these scenes one frame at a time. Besides, who wouldn’t want to watch Gene Kelly dancing on loop for an entire meeting.
So I bought a projector, specifically a cheap, refurbished Epson Brightlink 585wi. I can connect directly via HDMI, so I’ll host this on a raspberry pi and connect the two.
The Content
An easy way to achieve frame-by-frame playback is to extract frames from the video as images. Often people use VLC or MakeMKV to extract the video from a Blu-ray, and FFMPEG to convert the video into images. FFMPEG is a remarkable library that should always be your first stop when working with video. I used the following command:
./ffmpeg.exe -ss 00:00:53 -i ./hunter.mp4 -vf fps=1/5 ./images/hunter%d.png
Here’s more details on all the parameters
- ss: This parameter indicates where you will start recording. For Night of the Hunter I started 53 seconds in to skip past black screen and intro credits.
- i: The input file.
- vf: This is a “filtergraph” or a series of filters that modify the video. It’s incredibly powerful but I’m only using it to reduce the frame rate. I only want one frame every 5 seconds, hence 1/5th of a frame per second.
- ./images/hunter%d.png: This is a filename template. FFMPEG will create the images using this pattern, replacing %d with an ever growing number for each image created.
The Code
I’ve got all my images and a way to get them on the wall. Now I need a way to show the images and cycle through each frame. Being a web developer, I built a simple website and web server to manage everything.
For this project I wanted to work fast and save time, so I turned to GitHub Copilot. Copilot has an interesting feature where you write a comment describing how your code should work and it will write the code. After using it for a while, I think it’s witchcraft. Let me show you.
I started with basic express boilerplate (note that this is all in TypeScript).
import * as express from 'express';
import * as fs from 'fs';
const app = express();
const port = 8080;
app.listen(port, () => {
console.log('server started');
});
Next I built the landing page. This page will show all the possible scenes I can play, stored on the server as folders of images. To build this page I added the following comment between the port
definition and app.listen
…
/**
* This endpoint should scan the list of folders in the images directory and list them as html links
*/
…and GitHub Copilot wrote the following for me automatically:
app.get("/", (req, res) => {
fs.readdir("./images", (err, files) => {
if (err) {
res.send("Error reading directory");
} else {
// build a list of links
let links = "";
for (let i = 0; i < files.length; i++) {
links += `<a href="/${files[i]}">${files[i]}</a><br>`;
}
// send the list of links
res.send(links);
});
});
});
Witchcraft.
I didn’t have to look up how readdir
worked, it had error handling, it correctly guessed the directory ./images
, and set up links anticipating the next endpoint I needed to build. That’s so much time saved.
Then I pushed Copilot a little harder. I added another comment to create a new page:
/*
* This endpoint should return an html page containing links to all the images in the requested folder
*/
And GitHub Copilot created the following:
app.get("/:folder", (req, res) => {
fs.readdir(`./images/${req.params.folder}`, (err, files) => {
if (err) {
res.send("Error reading file");
} else {
// build a list of links
let links = "";
for (let i = 0; i < files.length; i++) {
links += `<img src="/${req.params.folder}/${encodeURIComponent(files[i])}" /><br>`;
}
// send the list of links
res.send(links);
}
});
});
The URL is right, the folder structure is right, and the output is right. Now, there may be a security vulnerability in using req.params.folder
directly in readdir
. That scares me in a different way than the cauldron-based magic that wrote this code. Security seems to be a common concern with Copilot, the programmer still needs to be on guard.
Lastly, I wrote:
/*
* This endpoint should return the requested image
*/
And sure enough, it created an endpoint that will load the image and serve it to the client when accessed.
Now I can see all the images associated with this project, but they’re all shown at once. I need to cycle through them one at a time. I was sure this was going to be too much for GitHub, but I tried it anyway:
/*
* Make all images on the page invisible, then show the first image and cycle through the images every 5 seconds
*/
And GitHub gave me:
<script>
document.querySelectorAll("img").forEach(img => {
img.style.visibility = "hidden";
});
document.querySelector("img").style.visibility = "visible";
setInterval(() => {
let current = document.querySelector("img");
let next = current.nextElementSibling;
if (next == null) {
next = current.parentElement.firstElementChild;
}
current.style.visibility = "hidden";
next.style.visibility = "visible";
}, 5000);
</script>
And I was proven wrong. Copilot was definitely up to the task and wrote a viable solution before I even tried to think of the right approach.
The Clean Up
With the latest code the website was flashing between images. It was very jarring, so now I fade the scene from image to image. I didn’t bother with Copilot here, and in the end it required a bunch of trial and error. Here’s my final process:
- Start with one image on the page, absolutely positioned.
- When you want to fade in the new image, add it to the page absolutely positioned with a CSS fade in animation.
- When the animation is complete, update the first image to show the contents of the new image, and remove the new image.
- After a set amount of time, repeat the process starting at step 2.
I made other changes along the way to improve the project, and you can see the code here: https://github.com/Dashron/projector-art.
The Final Results
In the end GitHub Copilot didn’t write everything for me, but it still saved me a lot of time. I think I’m going to put that extra time to good use, and watch a bunch of movies about witches.
This article is better than it was thanks to Eric, my favorite casual wordsmith.