npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@kirigami/bestframe

v0.1.1

Published

Automatic thumbnail/still-frame selection for video, powered by a tiny embedded aesthetic AI model, compiled to WebAssembly for Node.js.

Readme


@kirigami/bestframe

Automatic thumbnail/still-frame selection for video, picked by a tiny embedded aesthetic AI model, compiled to WebAssembly for Node.js.
Built for the Kirigami static site generator's video player.

npm version License: LGPL-2.1-or-later Node.js >=24.0.0 Website


Overview

@kirigami/bestframe samples frames across a video, filters out the obviously bad ones, scores the rest with a small embedded aesthetic model, and returns the best one as a ready-to-use thumbnail image:

  • Node.js only, no browser target
  • Buffer or file-path input — file paths are read from disk; the WASM module is loaded lazily and reused, without temporary output files
  • Multithreaded decode, using the available CPU count up to the binary’s build-time limit
  • Four mainstream web video codecs: H.264, VP9, HEVC, AV1 — in MP4, Matroska (.mkv), and WebM containers
  • A real AI model picks the frame, not just "highest contrast" or "N seconds in" — a MobileNet-based NIMA aesthetic model, embedded directly in the WASM binary (no separate model file to fetch)
  • Fallback frame for decodable input — even if every sampled frame fails the technical filters, you get the least-bad one back instead of nothing
  • JPEG or PNG output, your choice
  • ✅ Comes back with more than just the image: the source video's own duration, dimensions, and container metadata tags

Built by libbestframe, which also documents the full build pipeline, the model, and every architecture decision behind it.

Part of the Kirigami project ecosystem.


Table of contents


Requirements

  • Node.js >= 24.0.0

Installation

npm install @kirigami/bestframe

Usage

import { bestFrame } from '@kirigami/bestframe';
import fs from 'node:fs';

const result = await bestFrame('movie.mp4');
if (result) {
	console.log(result.timestamp, result.score, result.width, result.height);
	fs.writeFileSync('thumbnail.jpg', result.data);
}

A Buffer/Uint8Array already in memory works just as well as a file path — nothing is written to disk internally either way:

const buffer = fs.readFileSync('clip.webm');
const result = await bestFrame(buffer, {
	samples: 48,
	width: 1280,
	format: 'png',
});

If the source video can't be decoded at all (unsupported codec/container, corrupt data), bestFrame() resolves to null when the native decoder returns no result. File reads, invalid input, and runtime failures can still reject the promise.


Options

| Option | Type | Default | Description | | --- | --- | --- | --- | | samples | number | 24 | How many timestamps to sample across the video. | | width | number | 640 | Target output width in pixels — height is computed to preserve the source's aspect ratio. | | format | 'jpeg' \| 'png' | 'jpeg' | Output image format. PNG is lossless. | | quality | number | 85 | JPEG quality, 0-100 (libjpeg-style, higher = better). Ignored for format: 'png'. |


Runtime and failure behavior

bestFrame(input, options = {}) is the only function export. input is a local path string or Uint8Array (including Buffer); URLs, ArrayBuffer, and streams are not accepted. Relative paths use the working directory at call time. A file is read fully into memory and then copied into WASM memory.

The wrapper does not validate numeric options. Use positive integer samples and width, JPEG quality from 0 to 100, and the documented format names. Only the exact string png selects PNG; any other runtime value selects JPEG. Defaults apply to omitted/undefined fields, not to null.

A native no-result pointer becomes null. Invalid input types, file read errors, module initialization failures, and native/WASM exceptions reject. The result image is copied into a Node Buffer before native result memory is freed. Metadata is a string-valued object, preserving the container's key case, or null; score is not a normalized percentage or fixed quality grade.

The package ships dist/libbestframe.js and dist/libbestframe.wasm. The loader also starts worker threads from libbestframe.js itself, so keep both files together when deploying. Run examples from a .mjs file or an ESM project. Node's --input-type=module option is inherited by those file-based workers and causes ERR_INPUT_TYPE_NOT_ALLOWED in the current setup.

The module is lazy and reused; no public reset/dispose API is provided. Although the function returns a Promise and decoding uses workers, the native processing call blocks the calling JavaScript thread until it returns.

The local 2026-09-21 check decoded a synthetic H.264/MP4 clip to both JPEG and PNG, checked Buffer/dimension/metadata results, and exercised invalid input. It did not rerun every codec combination listed below.


Result

{
	timestamp: 23.732,      // the winning frame's real timestamp, in seconds
	score: 0.421,           // its composite aesthetic score — higher is better
	width: 640,             // encoded output width
	height: 266,            // encoded output height
	data: Buffer,           // the encoded image bytes (JPEG or PNG)
	duration: 45.545,       // source video's total duration, in seconds
	sourceWidth: 1920,      // source video's own (pre-scaling) width
	sourceHeight: 800,      // source video's own (pre-scaling) height
	metadata: {             // container tags, exactly as the file carries them, or null
		encoder: 'Lavf62.3.100',
	},
}

Codec & container support

| | Supported | | --- | --- | | Video codecs | H.264, VP9, HEVC (H.265), AV1 | | Containers | MP4, Matroska (.mkv), WebM | | Audio | Not read at all — thumbnails don't need it |

Older/niche codecs (MPEG-4 part 2 / Xvid, etc.) aren't supported — bestFrame() resolves to null for those, same as any other undecodable input.


How the frame gets picked

  1. Sample — timestamps spread across the video (avoiding the very start/end, to dodge fade-ins, intro logos, and credits).
  2. Filter — each sampled frame is checked for being black, low-contrast/uniform, blurry, or a near-duplicate of an already-kept frame. Obviously bad frames never reach the model.
  3. Score — survivors are resized and run through a small embedded NIMA-based aesthetic model (a general photo-aesthetics model, not one trained specifically on "thumbnail-worthiness" — it's a genuinely good proxy for it in practice), combined with a couple of cheap technical signals (sharpness, exposure) into one composite score.
  4. Pick & encode — the highest-scoring frame is decoded fresh at the requested output size and encoded as JPEG or PNG.

If literally every sampled frame fails step 2's filters (a clip that stays dark or blurry throughout, for instance), the least-bad one is scored and returned anyway when decoding produced a usable frame. Completely undecodable input can return null.

See libbestframe's own CLAUDE.md for the full decision-by-decision history behind every piece of this — the model, the filters, the composite score formula, and every real bug found along the way.


License

LGPL-2.1-or-later — see LICENSE for the full text.


Author

Maxime Larrivée-Roy, 2026