UNPKG

@bigfootds/nodejs-trickplay

Version:

Generate trickplay images for a given video file, for usage in NodeJS environments.

212 lines (165 loc) 7.44 kB
import ffmpegInstaller from '@ffmpeg-installer/ffmpeg'; import ffprobeInstaller from '@ffprobe-installer/ffprobe'; import ffmpeg from 'fluent-ffmpeg'; import path from 'node:path'; import fs from "node:fs"; import { Jimp } from 'jimp'; ffmpeg.setFfmpegPath(ffmpegInstaller.path); ffmpeg.setFfprobePath(ffprobeInstaller.path); export type TrickplayGeneratorOptions = { trickplayOutputDir?: string; secondsBetweenFrames?: number; numberOfFramesToGrab?: number; frameTimestamps?: string[]; trickplayImageWidth?: number; trickplaySheetRows?: number; trickplaySheetColumns?: number; skipIndividualFrameGeneration?: boolean; individualFrameFileFormat?: string; tilesheetFileFormat?: string; } export async function generateTrickplay(targetVideoPath: string, options: TrickplayGeneratorOptions = {}){ //#region Function prep console.log("Performing trickplay on: " + targetVideoPath); // Make sure target video exists if (!fs.existsSync(targetVideoPath)){ console.log("Path does not lead to a file: " + targetVideoPath); } // Analyse video for metadata such as its duration. // We need things like duration to figure out how many images to make. let videoMetadata: ffmpeg.FfprobeData | null = null; videoMetadata = await new Promise((resolve, reject) => { ffmpeg(targetVideoPath).ffprobe((error: Error, metadata) => { if (error) { reject(error); } videoMetadata = metadata; resolve(videoMetadata); }); }); // If the video was invalid or no important metadata could be found, cancel. if (videoMetadata == null || videoMetadata.format.duration == undefined) return; console.log(videoMetadata.format.duration); let targetVideoFilename = path.basename(targetVideoPath); console.log(targetVideoFilename); //#endregion //#region Prepare the generator options let localOptions = { trickplayOutputDir: options.trickplayOutputDir || targetVideoPath.slice(0, targetVideoPath.indexOf(targetVideoFilename)) + targetVideoFilename.slice(0, targetVideoFilename.lastIndexOf(".")) + ".trickplay", secondsBetweenFrames: options.secondsBetweenFrames || 10, numberOfFramesToGrab: Math.floor(videoMetadata.format.duration / (options.secondsBetweenFrames || 10)), frameTimestamps: options.frameTimestamps || [], trickplayImageWidth: options.trickplayImageWidth || 320, trickplaySheetRows: options.trickplaySheetRows || 10, trickplaySheetColumns: options.trickplaySheetColumns || 10, skipIndividualFrameGeneration: options.skipIndividualFrameGeneration || false, individualFrameFileFormat: options.individualFrameFileFormat || "jpg", tilesheetFileFormat: options.tilesheetFileFormat || "jpg" }; localOptions.frameTimestamps = new Array(Math.floor(localOptions.numberOfFramesToGrab)).fill(0).map((_, index) => { return (index * localOptions.secondsBetweenFrames).toString(); }); console.log(JSON.stringify(localOptions, null, 4)); //#endregion //#region Generate the trickplay images if (!localOptions.skipIndividualFrameGeneration){ // Make sure raw frames directory exists let rawFramesDirectory = path.resolve(localOptions.trickplayOutputDir,"frames"); if (!fs.existsSync(rawFramesDirectory)){ console.log("Making raw frames directory at:\n" + rawFramesDirectory); fs.mkdirSync(rawFramesDirectory, {recursive: true}); } let createdTrickplayImagePaths: string[] = []; let screenshotOptions = { count: localOptions.frameTimestamps!.length, timemarks: localOptions.frameTimestamps, size: localOptions.trickplayImageWidth +"x?", filename: "%i." + localOptions.individualFrameFileFormat }; console.log("---"); console.log(JSON.stringify(screenshotOptions, null, 4)); console.log("---"); let resultViaMethods = await new Promise((resolve, reject) => { ffmpeg(targetVideoPath).takeScreenshots( screenshotOptions, localOptions.trickplayOutputDir + "/frames" ).on("filenames", (filenames) => { createdTrickplayImagePaths = [...filenames]; }).on("end", () => { resolve(true); }).on("error", () => { reject(false); }); }); if(resultViaMethods){ console.log("Image generation successful: " + resultViaMethods); console.log(createdTrickplayImagePaths); } else { return; } } //#endregion //#region Composite (a.k.a stitch) the raw trickplay images together into a trickplay tilesheet. // Prepare a tilesheet output directory let tilesheetDirectory = path.resolve(localOptions.trickplayOutputDir, `${localOptions.trickplayImageWidth} - ${localOptions.trickplaySheetColumns}x${localOptions.trickplaySheetRows}`); console.log("Preparing to put the trickplay tilesheet into:\n" + tilesheetDirectory); if (!fs.existsSync(tilesheetDirectory)){ fs.mkdirSync(tilesheetDirectory); } let individualFramePaths: string[] = []; let rawFramesDirectory = path.resolve(localOptions.trickplayOutputDir,"frames"); console.log("Raw frames should be expected at: \n" + rawFramesDirectory); fs.readdirSync(rawFramesDirectory, {withFileTypes: true}).forEach((foundFile) => { if (foundFile.isFile()){ let foundFileExtension = foundFile.name.slice(foundFile.name.lastIndexOf(".") + 1); if (foundFileExtension.toLocaleLowerCase() == localOptions.individualFrameFileFormat.toLocaleLowerCase()){ individualFramePaths.push(foundFile.name) } } }); individualFramePaths.sort((a, b) => { let aNoExt = Number.parseInt(a.slice(0, a.lastIndexOf("."))); let bNoExt = Number.parseInt(b.slice(0, b.lastIndexOf("."))); return aNoExt - bNoExt; }) console.log(individualFramePaths); let framePaths2dGrid = []; while(individualFramePaths.length) { framePaths2dGrid.push(individualFramePaths.splice(0, 10)); } console.log(framePaths2dGrid.length); let tilesheetCount = Math.ceil(framePaths2dGrid.length / localOptions.trickplaySheetRows); console.log(tilesheetCount); let sampleFrame = await Jimp.read(path.resolve(rawFramesDirectory, framePaths2dGrid[0][0])); let tilesheets = []; for (let index = 0; index < tilesheetCount; index++) { tilesheets.push(new Jimp({ width: localOptions.trickplayImageWidth * localOptions.trickplaySheetColumns, height: sampleFrame.height * localOptions.trickplaySheetRows, color: "0xffffffff" })); } console.log(`Tilesheet dimensions:\nwidth:${tilesheets[0].width}\nheight:${tilesheets[0].height}`); let imageOperations: Promise<boolean>[] = []; framePaths2dGrid.forEach(async (row, rowIndex) => { let targetTilesheetIndex = Math.floor(rowIndex / localOptions.trickplaySheetRows); console.log(targetTilesheetIndex); row.forEach(async (item, itemIndex) => { imageOperations.push(new Promise(async (resolve, reject) => { let targetItemPath = path.resolve(rawFramesDirectory,item); console.log(targetItemPath); let foundImage = await Jimp.read(targetItemPath); let xPosition = itemIndex * localOptions.trickplayImageWidth; let yPosition = (rowIndex - (targetTilesheetIndex * 10)) * sampleFrame.height; console.log(`Would place image ${item} at tilesheet position x: ${xPosition}, y: ${yPosition}, row ${rowIndex}, tilesheet ${targetTilesheetIndex}`); await tilesheets[targetTilesheetIndex].composite(foundImage, xPosition, yPosition); resolve(true); })) }) }); await Promise.all(imageOperations); tilesheets.forEach(async (tilesheet, index) => { await tilesheet.write(`${tilesheetDirectory}/${index}.${localOptions.tilesheetFileFormat}`); }) //#endregion }