Watch a movie, one frame at a time, over an extremely long period of time. This is inspired by a project Bryan Boyer's Slow Movie Player, but is designed to work on the TRMNL e-ink device.
This is a simple node.js script that uses ffmpeg to extract a single frame from a movie and turn it into a dithered 1-bit PNG suitable for use with a TRMNL device. The resulting PNG is written as a data url to a JSON file of your choosing, which can then be sucked in by your TRMNL and displayed.
To use this, you need to run it somewhere where the resulting JSON file is publicly accessible on the web. For example, I run it on my personal webserver as a cronjob, and it writes the json file to a public folder on one of my websites. It will create a file called timestamp.txt that includes the current timestamp in the format hh:mm:ss, you can modify this to get a specific time if you want. It will increment by 10 seconds every time it runs, and then loop when it ends.
To set this up, you will need a movie file, most formats should work. I set mine up with a DVD rip of Fight Club.
So:
<div class="layout bg-black">">
<img src=http://anonymouse.org/cgi-bin/anon-www.cgi/http://benbrown.com/hacks/"{{img}}">
</div>
<div class="title_bar">
<span class="title">{{movie_title}}</span>
<span class="instance">{{time}}</span>
</div>
require('dotenv').config();
const ffmpeg = require('fluent-ffmpeg');
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
// Create a temporary directory for frame extraction if it doesn't exist
const tempDir = path.join(__dirname, 'temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir);
}
// Path to the timestamp file
const timestampFile = path.join(__dirname, 'timestamp.txt');
/**
* Converts seconds to HH:MM:SS format
* @param {number} totalSeconds - Total seconds to convert
* @returns {string} - Time in HH:MM:SS format
*/
function secondsToTimeString(totalSeconds) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
/**
* Converts HH:MM:SS format to seconds
* @param {string} timeString - Time string in HH:MM:SS format
* @returns {number} - Total seconds
*/
function timeStringToSeconds(timeString) {
const [hours, minutes, seconds] = timeString.split(':').map(Number);
return hours * 3600 + minutes * 60 + seconds;
}
/**
* Gets the current timestamp from the file
* @returns {number} - Current timestamp in seconds
*/
function getCurrentTimestamp() {
if (!fs.existsSync(timestampFile)) {
// Initialize with 00:00:00 if file doesn't exist
fs.writeFileSync(timestampFile, '00:00:00');
return 0;
}
const timeString = fs.readFileSync(timestampFile, 'utf8').trim();
return timeStringToSeconds(timeString);
}
/**
* Gets the duration of a video file in seconds
* @param {string} videoPath - Path to the video file
* @returns {Promise<number>} - Duration in seconds
*/
function getVideoDuration(videoPath) {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(videoPath, (err, metadata) => {
if (err) {
reject(err);
return;
}
resolve(metadata.format.duration);
});
});
}
/**
* Updates the timestamp file with the next second
* @param {number} currentSeconds - Current timestamp in seconds
*/
async function updateTimestamp(currentSeconds) {
const videoPath = process.env.VIDEO_PATH;
const duration = await getVideoDuration(videoPath);
// If we've reached or exceeded the duration, reset to 0
const nextSeconds = currentSeconds + 10 >= duration ? 0 : currentSeconds + 10;
const timeString = secondsToTimeString(nextSeconds);
fs.writeFileSync(timestampFile, timeString);
}
/**
* Applies Bayer dithering to an image buffer
* @param {Buffer} imageBuffer - The input image buffer
* @returns {Promise<Buffer>} - The dithered image buffer
*/
async function applyBayerDithering(imageBuffer) {
// 8x8 Bayer matrix
const bayerMatrix = [
[ 0, 48, 12, 60, 3, 51, 15, 63],
[32, 16, 44, 28, 35, 19, 47, 31],
[ 8, 56, 4, 52, 11, 59, 7, 55],
[40, 24, 36, 20, 43, 27, 39, 23],
[ 2, 50, 14, 62, 1, 49, 13, 61],
[34, 18, 46, 30, 33, 17, 45, 29],
[10, 58, 6, 54, 9, 57, 5, 53],
[42, 26, 38, 22, 41, 25, 37, 21]
];
// Process the image with sharp
const image = sharp(imageBuffer);
const metadata = await image.metadata();
// Get raw pixel data
const { data } = await image
.raw()
.toBuffer({ resolveWithObject: true });
// Apply dithering
for (let y = 0; y < metadata.height; y++) {
for (let x = 0; x < metadata.width; x++) {
const idx = (y * metadata.width + x) * metadata.channels;
const bayerValue = bayerMatrix[y % 8][x % 8] / 64;
const threshold = 128 + (bayerValue * 64 - 32);
// Apply threshold to each channel
for (let c = 0; c < metadata.channels; c++) {
data[idx + c] = data[idx + c] < threshold ? 0 : 255;
}
}
}
// Convert back to PNG
return sharp(data, {
raw: {
width: metadata.width,
height: metadata.height,
channels: metadata.channels
}
}).png().toBuffer();
}
/**
* Extracts a frame from a video at the specified timestamp and converts it to a 1-bit image
* @param {string} videoPath - Path to the video file
* @param {number} timestamp - Timestamp in seconds
* @returns {Promise<Buffer>} - Promise resolving to the processed image buffer
*/
async function extractAndProcessFrame(videoPath, timestamp) {
const framePath = path.join(tempDir, 'frame.png');
return new Promise((resolve, reject) => {
ffmpeg(videoPath)
.seekInput(timestamp)
.frames(1)
.output(framePath)
.on('end', async () => {
try {
// Process the frame to create a dithered 1-bit image
const processedImage = await sharp(framePath)
.grayscale()
.modulate({
brightness: 1.5,
saturation: 1.0,
hue: 0
})
.toBuffer()
.then(applyBayerDithering);
// Clean up the temporary frame
fs.unlinkSync(framePath);
resolve(processedImage);
} catch (error) {
reject(error);
}
})
.on('error', (err) => {
reject(err);
})
.run();
});
}
/**
* Writes the current frame to a JSON file
* @returns {Promise<void>}
*/
async function writeFrameToFile() {
const videoPath = process.env.VIDEO_PATH;
const outputPath = process.env.OUTPUT_PATH;
if (!videoPath) {
throw new Error('VIDEO_PATH not set in .env file');
}
if (!outputPath) {
throw new Error('OUTPUT_PATH not set in .env file');
}
if (!fs.existsSync(videoPath)) {
throw new Error('Video file not found');
}
try {
const currentTimestamp = getCurrentTimestamp();
const imageBuffer = await extractAndProcessFrame(videoPath, currentTimestamp);
await updateTimestamp(currentTimestamp);
console.log('Writing frame at ', secondsToTimeString(currentTimestamp));
// Convert buffer to base64 data URI
const base64Image = `data:image/png;base64,${imageBuffer.toString('base64')}`;
// Write to JSON file
const data = {
timestamp: currentTimestamp,
time: secondsToTimeString(currentTimestamp),
img: base64Image,
movie_title: process.env.MOVIE_TITLE,
};
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2));
} catch (error) {
console.error('Error writing frame to file:', error);
throw error;
}
}
// Write immediately on start
writeFrameToFile().catch(console.error);
VIDEO_PATH=[VALUE GOES HERE]
OUTPUT_PATH=[VALUE GOES HERE]
MOVIE_TITLE=[VALUE GOES HERE]
{
"name": "slowmovie",
"version": "1.0.0",
"main": "slowmovie.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"dotenv": "^16.5.0",
"fluent-ffmpeg": "^2.1.3",
"sharp": "^0.34.2"
}
}