@bigfootds/nodejs-trickplay
Version:
Generate trickplay images for a given video file, for usage in NodeJS environments.
170 lines (169 loc) • 9.9 kB
JavaScript
;
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateTrickplay = generateTrickplay;
const ffmpeg_1 = __importDefault(require("@ffmpeg-installer/ffmpeg"));
const ffprobe_1 = __importDefault(require("@ffprobe-installer/ffprobe"));
const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg"));
const node_path_1 = __importDefault(require("node:path"));
const node_fs_1 = __importDefault(require("node:fs"));
const jimp_1 = require("jimp");
fluent_ffmpeg_1.default.setFfmpegPath(ffmpeg_1.default.path);
fluent_ffmpeg_1.default.setFfprobePath(ffprobe_1.default.path);
function generateTrickplay(targetVideoPath_1) {
return __awaiter(this, arguments, void 0, function* (targetVideoPath, options = {}) {
//#region Function prep
console.log("Performing trickplay on: " + targetVideoPath);
// Make sure target video exists
if (!node_fs_1.default.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 = null;
videoMetadata = yield new Promise((resolve, reject) => {
(0, fluent_ffmpeg_1.default)(targetVideoPath).ffprobe((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 = node_path_1.default.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 = node_path_1.default.resolve(localOptions.trickplayOutputDir, "frames");
if (!node_fs_1.default.existsSync(rawFramesDirectory)) {
console.log("Making raw frames directory at:\n" + rawFramesDirectory);
node_fs_1.default.mkdirSync(rawFramesDirectory, { recursive: true });
}
let createdTrickplayImagePaths = [];
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 = yield new Promise((resolve, reject) => {
(0, fluent_ffmpeg_1.default)(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 = node_path_1.default.resolve(localOptions.trickplayOutputDir, `${localOptions.trickplayImageWidth} - ${localOptions.trickplaySheetColumns}x${localOptions.trickplaySheetRows}`);
console.log("Preparing to put the trickplay tilesheet into:\n" + tilesheetDirectory);
if (!node_fs_1.default.existsSync(tilesheetDirectory)) {
node_fs_1.default.mkdirSync(tilesheetDirectory);
}
let individualFramePaths = [];
let rawFramesDirectory = node_path_1.default.resolve(localOptions.trickplayOutputDir, "frames");
console.log("Raw frames should be expected at: \n" + rawFramesDirectory);
node_fs_1.default.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 = yield jimp_1.Jimp.read(node_path_1.default.resolve(rawFramesDirectory, framePaths2dGrid[0][0]));
let tilesheets = [];
for (let index = 0; index < tilesheetCount; index++) {
tilesheets.push(new jimp_1.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 = [];
framePaths2dGrid.forEach((row, rowIndex) => __awaiter(this, void 0, void 0, function* () {
let targetTilesheetIndex = Math.floor(rowIndex / localOptions.trickplaySheetRows);
console.log(targetTilesheetIndex);
row.forEach((item, itemIndex) => __awaiter(this, void 0, void 0, function* () {
imageOperations.push(new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () {
let targetItemPath = node_path_1.default.resolve(rawFramesDirectory, item);
console.log(targetItemPath);
let foundImage = yield jimp_1.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}`);
yield tilesheets[targetTilesheetIndex].composite(foundImage, xPosition, yPosition);
resolve(true);
})));
}));
}));
yield Promise.all(imageOperations);
tilesheets.forEach((tilesheet, index) => __awaiter(this, void 0, void 0, function* () {
yield tilesheet.write(`${tilesheetDirectory}/${index}.${localOptions.tilesheetFileFormat}`);
}));
//#endregion
});
}