Slow Movie Generator

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:

  1. Get the files below onto your server in a folder
  2. Run npm install
  3. Write your .env file with the path to movie, path to output json file
  4. Set up a cronjob to execute it every 15 minutes
  5. Create a private plugin in TRMNL developer mode, give it the URL to your JSON file.
  6. Use the template below as the full screen layout.
<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>

Main JavaScript File

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);

Environment Variables

VIDEO_PATH=[VALUE GOES HERE]
OUTPUT_PATH=[VALUE GOES HERE]
MOVIE_TITLE=[VALUE GOES HERE]

Package Configuration

{
  "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"
  }
}
Anonymization by Anonymouse.org ~ Adverts
Anonymouse better ad-free, faster and with encryption?
X