Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
# @suaveplan/video-thumbnails - Video thumbnail extraction and sprite-sheet generation backed by an injectable FfmpegRunner (fluent-ffmpeg + @ffmpeg-installer by default).
Video thumbnail extraction and sprite-sheet generation backed by an injectable
`FfmpegRunner`. The default runner — `FluentFfmpegRunner` — wraps `fluent-ffmpeg` and pins
ffmpeg/ffprobe to the binaries shipped by `@ffmpeg-installer/ffmpeg` and
`@ffprobe-installer/ffprobe`, so the package works on any host without requiring those
binaries on `PATH`.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — `VIDEO_THUMBNAILS_CODES` is the stable, frozen registry of every failure mode raised by `@suaveplan/video-thumbnails`.…
- [error-codes](./src/error-codes/error-codes.md) — `VIDEO_THUMBNAILS_CODES` is the stable, frozen registry of every failure mode raised by `@suaveplan/video-thumbnails`.…
- [errors](./src/errors/video-thumbnails-error.md) — `VideoThumbnailsError` is the single error class raised by every public surface of `@suaveplan/video-thumbnails`. It ex…
- [video-thumbnails-error](./src/errors/video-thumbnails-error.md) — `VideoThumbnailsError` is the single error class raised by every public surface of `@suaveplan/video-thumbnails`. It ex…
- [runner](./src/runner/runner.md) — The runner module defines the `FfmpegRunner` interface plus a handful of pure helpers used by every implementation: `bu…
- [fluent-ffmpeg-runner](./src/runner/fluent-ffmpeg-runner.md) — Default `FfmpegRunner` implementation used by `ThumbnailGenerator` and `SpriteSheetGenerator` when no other runner is i…
- [runner](./src/runner/runner.md) — The runner module defines the `FfmpegRunner` interface plus a handful of pure helpers used by every implementation: `bu…
- [schemas](./src/schemas/schemas.md) — Defines every Zod schema and inferred TypeScript type that crosses the public boundary of `@suaveplan/video-thumbnails`…
- [schemas](./src/schemas/schemas.md) — Defines every Zod schema and inferred TypeScript type that crosses the public boundary of `@suaveplan/video-thumbnails`…
- [sprite-sheet](./src/sprite-sheet/sprite-sheet.md) — `SpriteSheetGenerator` renders an N×M grid of thumbnails into a single image using ffmpeg's `tile` filter. A sprite she…
- [sprite-sheet](./src/sprite-sheet/sprite-sheet.md) — `SpriteSheetGenerator` renders an N×M grid of thumbnails into a single image using ffmpeg's `tile` filter. A sprite she…
- [telemetry](./src/telemetry/telemetry.md) — Centralises the OpenTelemetry instruments owned by `@suaveplan/video-thumbnails`. A meter is acquired lazily — only whe…
- [telemetry](./src/telemetry/telemetry.md) — Centralises the OpenTelemetry instruments owned by `@suaveplan/video-thumbnails`. A meter is acquired lazily — only whe…
- [thumbnail-generator](./src/thumbnail-generator/thumbnail-generator.md) — `ThumbnailGenerator` is the primary class consumers reach for to extract still frames from a video file. It accepts a Z…
- [thumbnail-generator](./src/thumbnail-generator/thumbnail-generator.md) — `ThumbnailGenerator` is the primary class consumers reach for to extract still frames from a video file. It accepts a Z…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/video-thumbnails
```
## Quick start
```ts
import {
ThumbnailGenerator,
SpriteSheetGenerator,
} from "@suaveplan/video-thumbnails";
const thumb = new ThumbnailGenerator({ outputDir: "/tmp/thumbs" });
const result = await thumb.generate("/data/clip.mp4", { timestamp: "00:00:02" });
console.log(result.outputPath, result.sizeBytes);
const sprite = new SpriteSheetGenerator({ columns: 5, rows: 5, interval: 2 });
const sheet = await sprite.generate("/data/clip.mp4", "/tmp/sprite.jpg");
console.log(sheet.frameCount, sheet.width, sheet.height);
```
## Features
- Single-frame extract, multi-timestamp batch, sliding-window sampling.
- Sprite-sheet generation via ffmpeg's `tile` filter with auto-duration detection.
- jpeg / png / webp output for thumbnails; jpeg / png for sprite sheets.
- Zod-validated boundaries — `INVALID_DIMENSIONS`, `INVALID_TIMESTAMP`, `FORMAT_UNSUPPORTED`,
`INVALID_OPTIONS` are typed failures.
- `AbortSignal` propagation kills the underlying ffmpeg child process and raises `ABORTED`.
- OTel: one counter and three histograms keyed by `outcome`, `format`, `fit`.
## Modules
- [`error-codes`](./src/error-codes/error-codes.md) — `VIDEO_THUMBNAILS_CODES` registry.
- [`errors/video-thumbnails-error`](./src/errors/video-thumbnails-error.md) — typed error class.
- [`schemas`](./src/schemas/schemas.md) — Zod schemas + format / fit constants.
- [`telemetry`](./src/telemetry/telemetry.md) — OTel counter + histograms.
- [`runner`](./src/runner/runner.md) — `FfmpegRunner` interface + filter helpers.
- [`runner/fluent-ffmpeg-runner`](./src/runner/fluent-ffmpeg-runner.md) — default runner.
- [`thumbnail-generator`](./src/thumbnail-generator/thumbnail-generator.md) — single-frame +
batch + sliding-window extractor.
- [`sprite-sheet`](./src/sprite-sheet/sprite-sheet.md) — N×M sprite-sheet generator.
## Error codes
`VIDEO_THUMBNAILS.EXTRACT_FAILED`, `VIDEO_THUMBNAILS.PROBE_FAILED`,
`VIDEO_THUMBNAILS.VIDEO_INVALID`, `VIDEO_THUMBNAILS.INVALID_TIMESTAMP`,
`VIDEO_THUMBNAILS.INVALID_DIMENSIONS`, `VIDEO_THUMBNAILS.INVALID_OPTIONS`,
`VIDEO_THUMBNAILS.FORMAT_UNSUPPORTED`, `VIDEO_THUMBNAILS.RUNNER_UNAVAILABLE`,
`VIDEO_THUMBNAILS.ABORTED`.
## License
MIT

View File

@@ -0,0 +1,104 @@
{
"name": "@suaveplan/video-thumbnails",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Video thumbnail extraction and sprite-sheet generation backed by an injectable FfmpegRunner (fluent-ffmpeg + @ffmpeg-installer by default).",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
}
},
"./runner": {
"import": {
"types": "./dist/runner/index.d.ts",
"default": "./dist/runner/index.js"
}
},
"./thumbnail-generator": {
"import": {
"types": "./dist/thumbnail-generator/index.d.ts",
"default": "./dist/thumbnail-generator/index.js"
}
},
"./sprite-sheet": {
"import": {
"types": "./dist/sprite-sheet/index.d.ts",
"default": "./dist/sprite-sheet/index.js"
}
},
"./telemetry": {
"import": {
"types": "./dist/telemetry/index.d.ts",
"default": "./dist/telemetry/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@ffmpeg-installer/ffmpeg": "catalog:audio",
"@ffprobe-installer/ffprobe": "catalog:audio",
"fluent-ffmpeg": "catalog:audio",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "catalog:observability",
"@suaveplan/testing": "workspace:^",
"@types/fluent-ffmpeg": "catalog:audio",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,80 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`VIDEO_THUMBNAILS_CODES` is the stable, frozen registry of every failure mode raised by
`@suaveplan/video-thumbnails`. It is built with `defineCodes` from `@suaveplan/error` so the
namespace prefix (`VIDEO_THUMBNAILS.`) is encoded in the literal type — meaning consumers can
`switch` on a `VideoThumbnailsCode` and TypeScript will enforce exhaustiveness. The registry is
the single source of truth: every `throw new VideoThumbnailsError(...)` references a code from
this object, and the conformance test walks the registry to prove no code is dead.
## Features
- Frozen, namespaced registry built via `defineCodes("VIDEO_THUMBNAILS", [...])`.
- Literal union `VideoThumbnailsCode` derived from the registry values.
- Every code reachable from a real call site in `src/`, validated by `error-codes.test.ts`.
- Compatible with the project-wide `SuaveplanError` wire format, OTel span events, and
redaction.
## Usage: Basic
```ts
import { VIDEO_THUMBNAILS_CODES } from "@suaveplan/video-thumbnails/error-codes";
console.log(VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED); // "VIDEO_THUMBNAILS.EXTRACT_FAILED"
```
## Usage: Advanced
```ts
import {
VIDEO_THUMBNAILS_CODES,
type VideoThumbnailsCode,
} from "@suaveplan/video-thumbnails/error-codes";
function describe(code: VideoThumbnailsCode): string {
switch (code) {
case VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED:
return "ffmpeg failed mid-extract";
case VIDEO_THUMBNAILS_CODES.PROBE_FAILED:
return "ffprobe failed";
case VIDEO_THUMBNAILS_CODES.VIDEO_INVALID:
return "input is not a valid video";
case VIDEO_THUMBNAILS_CODES.INVALID_TIMESTAMP:
return "timestamp is not HH:MM:SS(.mmm)?";
case VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS:
return "width/height/count <= 0";
case VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS:
return "Zod boundary validation failed";
case VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED:
return "output format not in jpg|png|webp";
case VIDEO_THUMBNAILS_CODES.RUNNER_UNAVAILABLE:
return "ffmpeg binary not available";
case VIDEO_THUMBNAILS_CODES.ABORTED:
return "AbortSignal triggered";
}
}
```
## API Reference
- `VIDEO_THUMBNAILS_CODES` — `Readonly<Record<Reason, "VIDEO_THUMBNAILS.<Reason>">>` for the
nine codes `EXTRACT_FAILED`, `PROBE_FAILED`, `VIDEO_INVALID`, `INVALID_TIMESTAMP`,
`INVALID_DIMENSIONS`, `INVALID_OPTIONS`, `FORMAT_UNSUPPORTED`, `RUNNER_UNAVAILABLE`,
`ABORTED`. Frozen at construction.
- `VideoThumbnailsCode` — literal union of every value in the registry; use as the parameter
type for handlers that need to switch on failure mode.
## Implementation Notes
The registry is intentionally minimal — codes are added only when there is a corresponding
`throw` site in `src/`. Removing a code requires a deprecation window: keep the entry in the
registry, raise the new code from the call site, then drop the entry in a follow-up change so
external callers have one release to migrate `case` clauses. Codes never carry payload — that
lives on `SuaveplanError.context`. The frozen object means accidental mutation throws at
runtime in strict mode, which preserves the registry-as-contract invariant.

View File

@@ -0,0 +1,37 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VIDEO_THUMBNAILS_CODES } from "./error-codes.js";
describe("VIDEO_THUMBNAILS_CODES", () => {
it("exposes all nine codes under the VIDEO_THUMBNAILS namespace", {
timeout: 5000,
}, () => {
const values = Object.values(VIDEO_THUMBNAILS_CODES);
expect(values).toHaveLength(9);
for (const value of values) {
expect(value.startsWith("VIDEO_THUMBNAILS.")).toBe(true);
}
});
it("every code is thrown by a real call site", { timeout: 5000 }, () => {
const srcRoot = join(__dirname, "..");
const files = [
"runner/runner.ts",
"runner/fluent-ffmpeg-runner.ts",
"thumbnail-generator/thumbnail-generator.ts",
"sprite-sheet/sprite-sheet.ts",
];
const corpus = files
.map((relative) => readFileSync(join(srcRoot, relative), "utf8"))
.join("\n");
for (const [reason, code] of Object.entries(VIDEO_THUMBNAILS_CODES)) {
const accessor = `VIDEO_THUMBNAILS_CODES.${reason}`;
const hasUsage = corpus.includes(accessor);
expect(
hasUsage,
`expected ${code} (${accessor}) referenced in a src/ call site`
).toBe(true);
}
});
});

View File

@@ -0,0 +1,24 @@
import { defineCodes } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/video-thumbnails`.
*
* All codes are namespaced under `VIDEO_THUMBNAILS.` so they survive grep-based
* triage and OTel filtering across the monorepo without colliding with
* unrelated registries.
*/
export const VIDEO_THUMBNAILS_CODES = defineCodes("VIDEO_THUMBNAILS", [
"EXTRACT_FAILED",
"PROBE_FAILED",
"VIDEO_INVALID",
"INVALID_TIMESTAMP",
"INVALID_DIMENSIONS",
"INVALID_OPTIONS",
"FORMAT_UNSUPPORTED",
"RUNNER_UNAVAILABLE",
"ABORTED",
] as const);
/** Union of every error code emitted by `@suaveplan/video-thumbnails`. */
export type VideoThumbnailsCode =
(typeof VIDEO_THUMBNAILS_CODES)[keyof typeof VIDEO_THUMBNAILS_CODES];

View File

@@ -0,0 +1,4 @@
export {
VIDEO_THUMBNAILS_CODES,
type VideoThumbnailsCode,
} from "./error-codes.js";

View File

@@ -0,0 +1 @@
export { VideoThumbnailsError } from "./video-thumbnails-error.js";

View File

@@ -0,0 +1,75 @@
# `VideoThumbnailsError`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`VideoThumbnailsError` is the single error class raised by every public surface of
`@suaveplan/video-thumbnails`. It extends `SuaveplanError<VideoThumbnailsCode>` so it inherits
the project-wide wire format (`toJSON` / `fromJSON`), the OTel span-event adapter, the
redaction pipeline, the cause-chain walker, and the i18n resolver hook. Every constructor call
must reference a code from `VIDEO_THUMBNAILS_CODES`, which keeps the registry and the call
sites in lock-step.
## Features
- Single class for the entire failure surface — no parallel hierarchies to keep in sync.
- Code is parameterised, so TypeScript narrows on `error.code` at catch sites.
- Carries `context` (Zod issues, ffmpeg stderr, abort reason) without bloating the message.
- Compatible with `walkCauses`, `toSpanEvent`, and the JSON / binary serializers exported by
`@suaveplan/error`.
## Usage: Basic
```ts
import { VideoThumbnailsError } from "@suaveplan/video-thumbnails/errors";
import { VIDEO_THUMBNAILS_CODES } from "@suaveplan/video-thumbnails/error-codes";
throw new VideoThumbnailsError(
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
"videoThumbnails.errors.extractFailed",
);
```
## Usage: Advanced
```ts
import { VideoThumbnailsError } from "@suaveplan/video-thumbnails/errors";
import { VIDEO_THUMBNAILS_CODES } from "@suaveplan/video-thumbnails/error-codes";
try {
await generator.generate("/missing.mp4");
} catch (error) {
if (error instanceof VideoThumbnailsError) {
if (error.code === VIDEO_THUMBNAILS_CODES.PROBE_FAILED) {
await reportMetric("probe-failed", error.context);
return;
}
if (error.code === VIDEO_THUMBNAILS_CODES.ABORTED) {
return;
}
}
throw error;
}
```
## API Reference
- `class VideoThumbnailsError extends SuaveplanError<VideoThumbnailsCode>`
- `constructor(code, messageKey, options?)`
- `code` — one of `VIDEO_THUMBNAILS_CODES.*`
- `messageKey` — i18n key (caller resolves the human-readable string)
- `options` — `SuaveplanErrorOptions` (`{ cause?, context?, severity?, retryable?, httpStatus? }`)
- Inherited members: `code`, `messageKey`, `context`, `cause`, `severity`, `retryable`,
`httpStatus`, `toJSON()`, `toSpanEvent()`.
## Implementation Notes
The class sets `this.name = "VideoThumbnailsError"` so structured logs identify the class
even when prototypes are stripped (some test runners walk `name` rather than the prototype
chain). It does not override `toJSON` — the base `SuaveplanError` serializer already covers
the wire format. When a Zod validation fails the schema layer attaches the `ZodError.issues`
array under `context.issues`, which the redactor passes through unmodified because Zod issues
do not contain sensitive material in this package's option space.

View File

@@ -0,0 +1,25 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
import { VideoThumbnailsError } from "./video-thumbnails-error.js";
describe("VideoThumbnailsError", () => {
it("extends SuaveplanError", { timeout: 5000 }, () => {
const error = new VideoThumbnailsError(
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
"videoThumbnails.errors.extractFailed"
);
expect(error).toBeInstanceOf(SuaveplanError);
expect(error.code).toBe(VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED);
expect(error.name).toBe("VideoThumbnailsError");
});
it("carries context when supplied", { timeout: 5000 }, () => {
const error = new VideoThumbnailsError(
VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
"videoThumbnails.errors.probeFailed",
{ context: { stderr: "boom" } }
);
expect(error.context).toEqual({ stderr: "boom" });
});
});

View File

@@ -0,0 +1,21 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { VideoThumbnailsCode } from "../error-codes/error-codes.js";
/**
* Typed error for all `@suaveplan/video-thumbnails` failures.
*
* Extends `SuaveplanError` with a stable code drawn from `VIDEO_THUMBNAILS_CODES`.
* Callers can discriminate on `error.code` to handle specific failure modes and
* the error survives every OTel adapter, JSON wire-format, and redaction
* pipeline shipped by `@suaveplan/error`.
*/
export class VideoThumbnailsError extends SuaveplanError<VideoThumbnailsCode> {
public constructor(
code: VideoThumbnailsCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "VideoThumbnailsError";
}
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FluentFfmpegRunner,
SpriteSheetGenerator,
ThumbnailGenerator,
VIDEO_THUMBNAILS_CODES,
VideoThumbnailsError,
} from "./index.js";
describe("package barrel", () => {
it("feature subpaths resolve", { timeout: 5000 }, () => {
expect(ThumbnailGenerator).toBeTypeOf("function");
expect(SpriteSheetGenerator).toBeTypeOf("function");
expect(FluentFfmpegRunner).toBeTypeOf("function");
expect(VideoThumbnailsError).toBeTypeOf("function");
expect(VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED).toBe(
"VIDEO_THUMBNAILS.EXTRACT_FAILED"
);
});
});

View File

@@ -0,0 +1,64 @@
// Error codes
export {
VIDEO_THUMBNAILS_CODES,
type VideoThumbnailsCode,
} from "./error-codes/error-codes.js";
// Errors
export { VideoThumbnailsError } from "./errors/video-thumbnails-error.js";
// Runner
export {
FluentFfmpegRunner,
type FluentFfmpegRunnerOptions,
} from "./runner/fluent-ffmpeg-runner.js";
export {
buildQualityArgs,
buildScaleFilter,
classifyRunnerError,
} from "./runner/runner.js";
export type {
ExtractInput,
FfmpegRunner,
ProbeResult,
TileInput,
} from "./runner/runner.types.js";
// Schemas + types
export {
SPRITE_SHEET_FORMATS,
SpriteSheetConfigSchema,
SpriteSheetResultSchema,
THUMBNAIL_FITS,
THUMBNAIL_FORMATS,
ThumbnailConfigSchema,
ThumbnailOptionsSchema,
ThumbnailResultSchema,
TIMESTAMP_PATTERN,
VideoMetadataSchema,
} from "./schemas/schemas.js";
export type {
SpriteSheetConfig,
SpriteSheetFormat,
SpriteSheetResult,
ThumbnailConfig,
ThumbnailFit,
ThumbnailFormat,
ThumbnailOptions,
ThumbnailResult,
VideoMetadata,
} from "./schemas/schemas.types.js";
export { SpriteSheetGenerator } from "./sprite-sheet/sprite-sheet.js";
export type { SpriteSheetConfigInput } from "./sprite-sheet/sprite-sheet.types.js";
// Telemetry
export {
getTelemetry,
type VideoThumbnailsTelemetry,
} from "./telemetry/telemetry.js";
// Generators
export {
buildEvenlySpacedTimestamps,
ThumbnailGenerator,
} from "./thumbnail-generator/thumbnail-generator.js";
export type {
ThumbnailConfigInput,
ThumbnailOptionsInput,
} from "./thumbnail-generator/thumbnail-generator.types.js";

View File

@@ -0,0 +1,81 @@
# `FluentFfmpegRunner`
## Purpose
Default `FfmpegRunner` implementation used by `ThumbnailGenerator` and
`SpriteSheetGenerator` when no other runner is injected. It wraps `fluent-ffmpeg` and pins
ffmpeg to the binary shipped by `@ffmpeg-installer/ffmpeg`, and ffprobe to
`@ffprobe-installer/ffprobe`, so the package works on any host without requiring system
ffmpeg/ffprobe to be on `PATH`. Every operation respects an optional `AbortSignal` by killing
the underlying child process and rejecting with `VIDEO_THUMBNAILS.ABORTED`.
## Features
- Bundled ffmpeg + ffprobe binaries — zero system dependency.
- Override-friendly: pass `ffmpegPath` and/or `ffprobePath` to point at a custom binary.
- `extract` issues a single-frame ffmpeg pipeline with the canonical filter from
`buildScaleFilter` and quality from `buildQualityArgs`.
- `tile` issues the canonical `fps=…,scale=…,tile=COLSxROWS` sprite-sheet pipeline.
- `probe` returns a strict `ProbeResult` with duration, dimensions, codec, fps, bitrate.
- `isAvailable` spawns `ffmpeg -version` and resolves to `true` only on exit code 0.
- Every call honours `AbortSignal`: `command.kill("SIGKILL")` on abort, then reject with
`ABORTED`.
## Usage: Basic
```ts
import { FluentFfmpegRunner } from "@suaveplan/video-thumbnails/runner";
const runner = new FluentFfmpegRunner();
const ok = await runner.isAvailable();
```
## Usage: Advanced
```ts
import { FluentFfmpegRunner } from "@suaveplan/video-thumbnails/runner";
const runner = new FluentFfmpegRunner({
ffmpegPath: "/usr/local/bin/ffmpeg-hwaccel",
ffprobePath: "/usr/local/bin/ffprobe-hwaccel",
});
const controller = new AbortController();
setTimeout(() => controller.abort(), 250);
try {
await runner.extract(
{
inputPath: "/data/big.mkv",
outputPath: "/tmp/thumb.jpg",
timestamp: "00:00:10",
width: 640,
height: 360,
fit: "cover",
format: "jpg",
quality: 80,
},
controller.signal,
);
} catch (error) {
// VideoThumbnailsError { code: "VIDEO_THUMBNAILS.ABORTED" } when aborted.
}
```
## API Reference
- `class FluentFfmpegRunner implements FfmpegRunner`
- `constructor(options?: { ffmpegPath?: string; ffprobePath?: string })`
- `isAvailable(signal?): Promise<boolean>` — exit code 0 from `ffmpeg -version`.
- `extract(input, signal?): Promise<void>` — single-frame extraction.
- `tile(input, signal?): Promise<void>` — N×M sprite-sheet render.
- `probe(inputPath, signal?): Promise<ProbeResult>` — ffprobe metadata.
## Implementation Notes
`run` listens for `end` and `error` on the `FfmpegCommand`, tracks a `settled` flag so a late
abort cannot double-resolve, and attaches the abort listener with `{ once: true }` to keep
event-emitter memory bounded. `probe` re-pins the ffprobe path on the module-level
`fluent-ffmpeg` singleton before each call because `ffmpeg.ffprobe` (the top-level helper)
does not consult per-instance state — without the re-pin the singleton would happily fall
back to system `ffprobe`. The probe failure paths split into two: a real ffprobe crash
(`PROBE_FAILED`) and a structurally invalid response (`VIDEO_INVALID`, raised when the file
has no video stream or a non-positive duration).

View File

@@ -0,0 +1,512 @@
import { join } from "node:path";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
getFixtureVideo,
makeOutputDir,
safeRm,
} from "../../tests/setup/fixtures.js";
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
import {
FluentFfmpegRunner,
makeSettler,
toProbeResult,
} from "./fluent-ffmpeg-runner.js";
let runner: FluentFfmpegRunner;
let outDir: string;
let fixture: string;
beforeAll(async () => {
runner = new FluentFfmpegRunner();
outDir = await makeOutputDir("runner");
fixture = await getFixtureVideo();
});
afterAll(async () => {
await safeRm(outDir);
});
describe("FluentFfmpegRunner", () => {
it("reports ffmpeg available", { timeout: 30000 }, async () => {
expect(await runner.isAvailable()).toBe(true);
});
it("isAvailable returns false when signal is pre-aborted", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
controller.abort();
expect(await runner.isAvailable(controller.signal)).toBe(false);
});
it("isAvailable returns false when signal aborts mid-spawn", {
timeout: 30000,
}, async () => {
const controller = new AbortController();
const pending = runner.isAvailable(controller.signal);
// Fire abort on the next microtask, after spawn has hooked the listener.
queueMicrotask(() => controller.abort());
expect(await pending).toBe(false);
});
it("isAvailable returns false when the binary path is invalid", {
timeout: 30000,
}, async () => {
const broken = new FluentFfmpegRunner({
ffmpegPath: "/nonexistent/ffmpeg",
});
expect(await broken.isAvailable()).toBe(false);
});
it("extracts a JPEG frame from the real fixture", {
timeout: 30000,
}, async () => {
const output = join(outDir, "extract.jpg");
await runner.extract({
inputPath: fixture,
outputPath: output,
timestamp: "00:00:01",
width: 160,
height: 120,
fit: "cover",
format: "jpg",
quality: 80,
});
const { stat } = await import("node:fs/promises");
const fileStat = await stat(output);
expect(fileStat.size).toBeGreaterThan(0);
});
it("rejects extract with ABORTED when signal aborts pre-call", {
timeout: 30000,
}, async () => {
const controller = new AbortController();
controller.abort();
await expect(
runner.extract(
{
inputPath: fixture,
outputPath: join(outDir, "aborted.jpg"),
timestamp: "00:00:01",
width: 160,
height: 120,
fit: "cover",
format: "jpg",
quality: 80,
},
controller.signal
)
).rejects.toMatchObject({
code: VIDEO_THUMBNAILS_CODES.ABORTED,
});
});
it("rejects extract with EXTRACT_FAILED when the source is bogus", {
timeout: 30000,
}, async () => {
await expect(
runner.extract({
inputPath: "/this/file/does/not/exist.mp4",
outputPath: join(outDir, "missing.jpg"),
timestamp: "00:00:01",
width: 160,
height: 120,
fit: "cover",
format: "jpg",
quality: 80,
})
).rejects.toMatchObject({
code: VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
});
});
it("renders a tile sprite sheet", { timeout: 30000 }, async () => {
const output = join(outDir, "tile.jpg");
await runner.tile({
inputPath: fixture,
outputPath: output,
columns: 3,
rows: 2,
thumbnailWidth: 80,
thumbnailHeight: 60,
interval: 1,
format: "jpg",
});
const { stat } = await import("node:fs/promises");
expect((await stat(output)).size).toBeGreaterThan(0);
});
it("rejects tile with ABORTED when signal aborts pre-call", {
timeout: 30000,
}, async () => {
const controller = new AbortController();
controller.abort();
await expect(
runner.tile(
{
inputPath: fixture,
outputPath: join(outDir, "tile-abort.jpg"),
columns: 3,
rows: 2,
thumbnailWidth: 80,
thumbnailHeight: 60,
interval: 1,
format: "jpg",
},
controller.signal
)
).rejects.toMatchObject({
code: VIDEO_THUMBNAILS_CODES.ABORTED,
});
});
it("probe returns sensible metadata for the fixture", {
timeout: 30000,
}, async () => {
const probed = await runner.probe(fixture);
expect(probed.duration).toBeGreaterThan(0);
expect(probed.width).toBeGreaterThan(0);
expect(probed.height).toBeGreaterThan(0);
expect(probed.codec).toBeTypeOf("string");
expect(probed.fps).toBeGreaterThan(0);
});
it("probe rejects with ABORTED when signal is pre-aborted", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
controller.abort();
await expect(
runner.probe(fixture, controller.signal)
).rejects.toMatchObject({ code: VIDEO_THUMBNAILS_CODES.ABORTED });
});
it("probe rejects with PROBE_FAILED on a missing file", {
timeout: 30000,
}, async () => {
await expect(runner.probe("/no/such/file.mp4")).rejects.toMatchObject({
code: VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
});
});
it("probe rejects with VIDEO_INVALID on a non-video container", {
timeout: 30000,
}, async () => {
const audioOnly = join(outDir, "audio-only.mp4");
const ffmpegInstaller = (await import("@ffmpeg-installer/ffmpeg"))
.default;
const { spawn } = await import("node:child_process");
await new Promise<void>((resolve, reject) => {
const child = spawn(ffmpegInstaller.path, [
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=1",
"-c:a",
"aac",
audioOnly,
]);
child.once("error", reject);
child.once("exit", (code) =>
code === 0
? resolve()
: reject(new Error("audio fixture failed"))
);
});
await expect(runner.probe(audioOnly)).rejects.toBeInstanceOf(
VideoThumbnailsError
);
});
});
describe("toProbeResult", () => {
it("throws VIDEO_INVALID when no video stream is present", {
timeout: 5000,
}, () => {
expect(() =>
toProbeResult({
streams: [],
format: {},
} as never)
).toThrow(
expect.objectContaining({ code: "VIDEO_THUMBNAILS.VIDEO_INVALID" })
);
});
it("throws when streams is missing entirely", { timeout: 5000 }, () => {
expect(() => toProbeResult({ format: {} } as never)).toThrow();
});
it("falls back to 30/1 when r_frame_rate is missing", {
timeout: 5000,
}, () => {
const result = toProbeResult({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 320,
height: 240,
},
],
format: { duration: "5.0", bit_rate: "12345" },
} as never);
expect(result.fps).toBe(30);
});
it("yields fps = num when denominator is zero", {
timeout: 5000,
}, () => {
const result = toProbeResult({
streams: [
{
codec_type: "video",
codec_name: "vp9",
width: 100,
height: 100,
r_frame_rate: "24/0",
},
],
format: { duration: "1" },
} as never);
expect(result.fps).toBe(24);
});
it("handles non-finite duration as 0", { timeout: 5000 }, () => {
const result = toProbeResult({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 1,
height: 1,
r_frame_rate: "30/1",
},
],
format: { duration: "N/A" },
} as never);
expect(result.duration).toBe(0);
});
it("substitutes 0 for missing width/height", { timeout: 5000 }, () => {
const result = toProbeResult({
streams: [
{
codec_type: "video",
r_frame_rate: "30/1",
},
],
format: { duration: "1" },
} as never);
expect(result.width).toBe(0);
expect(result.height).toBe(0);
expect(result.codec).toBe("unknown");
});
it("treats missing format object as duration/bitrate 0", {
timeout: 5000,
}, () => {
const result = toProbeResult({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 1,
height: 1,
r_frame_rate: "30/1",
},
],
} as never);
expect(result.duration).toBe(0);
expect(result.bitrate).toBe(0);
});
it("returns bitrate 0 when not present", { timeout: 5000 }, () => {
const result = toProbeResult({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 16,
height: 9,
r_frame_rate: "30/1",
},
],
format: { duration: "1" },
} as never);
expect(result.bitrate).toBe(0);
});
it("handles non-numeric fps numerator", { timeout: 5000 }, () => {
const result = toProbeResult({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 16,
height: 9,
r_frame_rate: "abc/1",
},
],
format: { duration: "1" },
} as never);
expect(result.fps).toBe(0);
});
it("handles non-numeric fps denominator", { timeout: 5000 }, () => {
const result = toProbeResult({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 16,
height: 9,
r_frame_rate: "24/xyz",
},
],
format: { duration: "1" },
} as never);
expect(result.fps).toBe(24);
});
it("handles missing fps numerator (empty string)", {
timeout: 5000,
}, () => {
const result = toProbeResult({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 16,
height: 9,
r_frame_rate: "/1",
},
],
format: { duration: "1" },
} as never);
expect(result.fps).toBe(0);
});
});
describe("makeSettler", () => {
it("completes the promise on first complete", {
timeout: 5000,
}, async () => {
let resolved = false;
const settler = makeSettler(
() => {
resolved = true;
},
() => {
throw new Error("should not reject");
},
undefined
);
settler.complete();
expect(resolved).toBe(true);
});
it("ignores complete after settled", { timeout: 5000 }, () => {
let count = 0;
const settler = makeSettler(
() => {
count += 1;
},
() => {
/* unused */
},
undefined
);
settler.complete();
settler.complete();
settler.fail(new Error("late"));
settler.abort(() => {
/* unused */
});
expect(count).toBe(1);
});
it("fail rejects with the supplied error", { timeout: 5000 }, () => {
let rejection: unknown;
const settler = makeSettler(
() => {
/* unused */
},
(reason) => {
rejection = reason;
},
undefined
);
const cause = new Error("boom");
settler.fail(cause);
expect(rejection).toBe(cause);
});
it("ignores fail after settled", { timeout: 5000 }, () => {
let calls = 0;
const settler = makeSettler(
() => {
/* unused */
},
() => {
calls += 1;
},
undefined
);
settler.fail(new Error("first"));
settler.fail(new Error("ignored"));
expect(calls).toBe(1);
});
it("abort invokes the cancel callback then rejects with ABORTED", {
timeout: 5000,
}, () => {
const controller = new AbortController();
controller.abort("user-action");
let rejection: unknown;
let cancelCalled = 0;
const settler = makeSettler(
() => {
/* unused */
},
(reason) => {
rejection = reason;
},
controller.signal
);
settler.abort(() => {
cancelCalled += 1;
});
expect(cancelCalled).toBe(1);
expect(rejection).toBeInstanceOf(VideoThumbnailsError);
expect((rejection as VideoThumbnailsError).code).toBe(
"VIDEO_THUMBNAILS.ABORTED"
);
});
it("abort no-ops when already settled", { timeout: 5000 }, () => {
let cancelCalled = 0;
const settler = makeSettler(
() => {
/* unused */
},
() => {
/* unused */
},
undefined
);
settler.complete();
settler.abort(() => {
cancelCalled += 1;
});
expect(cancelCalled).toBe(0);
});
});

View File

@@ -0,0 +1,262 @@
import { spawn } from "node:child_process";
import ffmpegInstaller from "@ffmpeg-installer/ffmpeg";
import ffprobeInstaller from "@ffprobe-installer/ffprobe";
import ffmpeg, { type FfmpegCommand } from "fluent-ffmpeg";
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
import {
buildQualityArgs,
buildScaleFilter,
classifyRunnerError,
} from "./runner.js";
import type {
ExtractInput,
FfmpegRunner,
ProbeResult,
TileInput,
} from "./runner.types.js";
/** Construction options for {@link FluentFfmpegRunner}. */
export interface FluentFfmpegRunnerOptions {
/** Override the ffmpeg binary path. Defaults to `@ffmpeg-installer/ffmpeg`. */
readonly ffmpegPath?: string;
/** Override the ffprobe binary path. Defaults to `@ffprobe-installer/ffprobe`. */
readonly ffprobePath?: string;
}
/**
* Default `FfmpegRunner` implementation backed by `fluent-ffmpeg` and the
* binary shipped via `@ffmpeg-installer/ffmpeg`. Consumers can supply a
* different binary by passing `ffmpegPath`.
*/
export class FluentFfmpegRunner implements FfmpegRunner {
private readonly ffmpegPath: string;
private readonly ffprobePath: string;
public constructor(options: FluentFfmpegRunnerOptions = {}) {
this.ffmpegPath = options.ffmpegPath ?? ffmpegInstaller.path;
this.ffprobePath = options.ffprobePath ?? ffprobeInstaller.path;
}
public async isAvailable(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted) {
return false;
}
return new Promise<boolean>((resolve) => {
const child = spawn(this.ffmpegPath, ["-version"], {
stdio: "ignore",
});
const onAbort = (): void => {
try {
child.kill("SIGKILL");
} catch {
// ignored
}
resolve(false);
};
signal?.addEventListener("abort", onAbort, { once: true });
child.once("error", () => {
signal?.removeEventListener("abort", onAbort);
resolve(false);
});
child.once("exit", (code) => {
signal?.removeEventListener("abort", onAbort);
resolve(code === 0);
});
});
}
public async extract(
input: ExtractInput,
signal?: AbortSignal
): Promise<void> {
const filter = buildScaleFilter(input.width, input.height, input.fit);
const qualityArgs = buildQualityArgs(input.format, input.quality);
const outputOptions = [
"-ss",
input.timestamp,
"-vframes",
"1",
"-vf",
filter,
...qualityArgs,
"-y",
];
const command = this.command()
.input(input.inputPath)
.outputOptions(outputOptions)
.output(input.outputPath);
await this.run(command, signal, VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED);
}
public async tile(input: TileInput, signal?: AbortSignal): Promise<void> {
const vfFilter = [
`fps=1/${input.interval}`,
`scale=${input.thumbnailWidth}:${input.thumbnailHeight}`,
`tile=${input.columns}x${input.rows}`,
].join(",");
const command = this.command()
.input(input.inputPath)
.outputOptions(["-vf", vfFilter, "-frames:v", "1", "-y"])
.output(input.outputPath);
await this.run(command, signal, VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED);
}
public async probe(
inputPath: string,
signal?: AbortSignal
): Promise<ProbeResult> {
if (signal?.aborted) {
throw new VideoThumbnailsError(
VIDEO_THUMBNAILS_CODES.ABORTED,
"videoThumbnails.errors.aborted",
{ context: { reason: String(signal.reason) } }
);
}
ffmpeg.setFfprobePath(this.ffprobePath);
return new Promise<ProbeResult>((resolve, reject) => {
ffmpeg.ffprobe(inputPath, (error, data) => {
if (error != null) {
reject(
classifyRunnerError(
error,
signal,
VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
"videoThumbnails.errors.probeFailed"
)
);
return;
}
try {
resolve(toProbeResult(data));
} catch (parseError) {
reject(parseError);
}
});
});
}
private command(): FfmpegCommand {
const command = ffmpeg();
command.setFfmpegPath(this.ffmpegPath);
command.setFfprobePath(this.ffprobePath);
return command;
}
private run(
command: FfmpegCommand,
signal: AbortSignal | undefined,
failureCode: typeof VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const settler = makeSettler(resolve, reject, signal);
const onAbort = (): void =>
settler.abort(() => {
try {
command.kill("SIGKILL");
} catch {
// ignored — the command may already have exited
}
});
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener("abort", onAbort, { once: true });
command.on("end", () => settler.complete());
command.on("error", (error: unknown) => {
settler.fail(
classifyRunnerError(
error,
signal,
failureCode,
"videoThumbnails.errors.extractFailed"
)
);
});
command.run();
});
}
}
/** Internal — single-shot settle helper used by the run pipeline. Exported for testability. */
export interface Settler {
complete(): void;
fail(error: unknown): void;
abort(onFirstAbort: () => void): void;
}
/** Internal — exported for testability. */
export function makeSettler(
resolve: () => void,
reject: (reason: unknown) => void,
signal: AbortSignal | undefined
): Settler {
let settled = false;
return {
complete(): void {
if (settled) {
return;
}
settled = true;
resolve();
},
fail(error: unknown): void {
if (settled) {
return;
}
settled = true;
reject(error);
},
abort(onFirstAbort: () => void): void {
if (settled) {
return;
}
settled = true;
onFirstAbort();
reject(
new VideoThumbnailsError(
VIDEO_THUMBNAILS_CODES.ABORTED,
"videoThumbnails.errors.aborted",
{
context: {
reason: String(signal?.reason),
},
}
)
);
},
};
}
/** Internal — exported for testability. */
export function toProbeResult(data: ffmpeg.FfprobeData): ProbeResult {
const videoStream = (data.streams ?? []).find(
(stream) => stream.codec_type === "video"
);
if (videoStream === undefined) {
throw new VideoThumbnailsError(
VIDEO_THUMBNAILS_CODES.VIDEO_INVALID,
"videoThumbnails.errors.videoInvalid",
{ context: { reason: "no-video-stream" } }
);
}
const fpsRatio = videoStream.r_frame_rate ?? "30/1";
const parts = fpsRatio.split("/");
const rawNum = Number(parts[0]);
const rawDen = Number(parts[1]);
const num = Number.isFinite(rawNum) ? rawNum : 0;
const den = Number.isFinite(rawDen) ? rawDen : 0;
const fps = den === 0 ? num : num / den;
const rawDuration = Number(data.format?.duration ?? 0);
const duration =
Number.isFinite(rawDuration) && rawDuration > 0 ? rawDuration : 0;
return {
duration,
width: typeof videoStream.width === "number" ? videoStream.width : 0,
height: typeof videoStream.height === "number" ? videoStream.height : 0,
codec: videoStream.codec_name ?? "unknown",
fps,
bitrate: Number(data.format?.bit_rate ?? 0),
};
}

View File

@@ -0,0 +1,15 @@
export {
FluentFfmpegRunner,
type FluentFfmpegRunnerOptions,
} from "./fluent-ffmpeg-runner.js";
export {
buildQualityArgs,
buildScaleFilter,
classifyRunnerError,
} from "./runner.js";
export type {
ExtractInput,
FfmpegRunner,
ProbeResult,
TileInput,
} from "./runner.types.js";

View File

@@ -0,0 +1,91 @@
# `runner`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The runner module defines the `FfmpegRunner` interface plus a handful of pure helpers used by
every implementation: `buildScaleFilter`, `buildQualityArgs`, and `classifyRunnerError`.
Centralising filter construction means the generator classes stay focused on orchestration
while every implementation (the bundled `FluentFfmpegRunner` and any future native or browser
variant) shares the exact same algorithmic shape. `classifyRunnerError` is the single point
where an `AbortSignal` distinguishes an `ABORTED` failure from a real ffmpeg crash.
## Features
- `FfmpegRunner` interface — four methods (`isAvailable`, `extract`, `tile`, `probe`), each
taking an optional `AbortSignal`.
- Strong typed payloads: `ExtractInput`, `TileInput`, `ProbeResult`.
- `buildScaleFilter` — encodes the `cover | contain | fill` fit semantics into ffmpeg
filter-graph syntax once and only once.
- `buildQualityArgs` — translates the 1–100 quality scale into ffmpeg's inverted 1–31 `-q:v`
scale (PNG returns an empty arg list).
- `classifyRunnerError` — single guarded helper that turns any raw ffmpeg failure into a
typed `VideoThumbnailsError`, distinguishing `ABORTED` from `EXTRACT_FAILED` /
`PROBE_FAILED` based on the supplied `AbortSignal`.
## Usage: Basic
```ts
import { buildScaleFilter, buildQualityArgs } from "@suaveplan/video-thumbnails/runner";
buildScaleFilter(320, 180, "cover");
// "scale=320:180:force_original_aspect_ratio=increase,crop=320:180"
buildQualityArgs("jpg", 80);
// ["-q:v", "7"]
```
## Usage: Advanced
```ts
import {
classifyRunnerError,
type FfmpegRunner,
} from "@suaveplan/video-thumbnails/runner";
import { VIDEO_THUMBNAILS_CODES } from "@suaveplan/video-thumbnails/error-codes";
class CustomRunner implements FfmpegRunner {
async extract(input, signal) {
try {
await runViaCustomTransport(input, signal);
} catch (error) {
throw classifyRunnerError(
error,
signal,
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
"videoThumbnails.errors.extractFailed",
);
}
}
// ... isAvailable, tile, probe
}
```
## API Reference
- `FfmpegRunner` — `{ isAvailable(signal?), extract(input, signal?), tile(input, signal?),
probe(inputPath, signal?) }`.
- `ExtractInput` — `{ inputPath, outputPath, timestamp, width, height, fit, format,
quality }`.
- `TileInput` — `{ inputPath, outputPath, columns, rows, thumbnailWidth, thumbnailHeight,
interval, format }`.
- `ProbeResult` — `{ duration, width, height, codec, fps, bitrate }`.
- `buildScaleFilter(width, height, fit): string`.
- `buildQualityArgs(format, quality): readonly string[]`.
- `classifyRunnerError(cause, signal, failureCode, failureKey): VideoThumbnailsError` — when
`signal.aborted`, returns `ABORTED`; otherwise the supplied `failureCode`.
## Implementation Notes
Quality mapping uses ffmpeg's inverted scale where `1` is best and `31` is worst — the
inverse arithmetic (`31 - quality/100 × 30`) keeps the round-trip stable for the canonical
80% quality (returning `-q:v 7`, the most commonly cited "good MJPEG" value). The fit
strategies are deliberately limited to ffmpeg-native filter chains so no second-pass image
processing is needed; consumers wanting more exotic compositing (gravity, focal point) can
implement a custom `FfmpegRunner` and reuse `buildQualityArgs` while ignoring
`buildScaleFilter`. `classifyRunnerError` reads `signal.aborted` lazily and only after the
underlying call has thrown — this avoids a race where the signal aborts after the work
already finished successfully.

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
import {
buildQualityArgs,
buildScaleFilter,
classifyRunnerError,
} from "./runner.js";
describe("buildScaleFilter", () => {
it("emits the cover filter chain by default", { timeout: 5000 }, () => {
expect(buildScaleFilter(320, 180, "cover")).toBe(
"scale=320:180:force_original_aspect_ratio=increase,crop=320:180"
);
});
it("emits a contain (decrease) filter", { timeout: 5000 }, () => {
expect(buildScaleFilter(320, 180, "contain")).toBe(
"scale=320:180:force_original_aspect_ratio=decrease"
);
});
it("emits a fill (stretch) filter", { timeout: 5000 }, () => {
expect(buildScaleFilter(320, 180, "fill")).toBe("scale=320:180");
});
});
describe("buildQualityArgs", () => {
it("returns empty args for png", { timeout: 5000 }, () => {
expect(buildQualityArgs("png", 80)).toEqual([]);
});
it("maps quality 80 to -q:v 7 for jpg", { timeout: 5000 }, () => {
expect(buildQualityArgs("jpg", 80)).toEqual(["-q:v", "7"]);
});
it("maps quality 1 to -q:v 31 (worst) for webp", { timeout: 5000 }, () => {
expect(buildQualityArgs("webp", 1)).toEqual(["-q:v", "31"]);
});
});
describe("classifyRunnerError", () => {
it("returns ABORTED when signal is aborted", { timeout: 5000 }, () => {
const controller = new AbortController();
controller.abort("manual-cancel");
const error = classifyRunnerError(
new Error("boom"),
controller.signal,
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
"videoThumbnails.errors.extractFailed"
);
expect(error).toBeInstanceOf(VideoThumbnailsError);
expect(error.code).toBe(VIDEO_THUMBNAILS_CODES.ABORTED);
});
it("returns the requested failure code otherwise", {
timeout: 5000,
}, () => {
const error = classifyRunnerError(
new Error("ffmpeg crashed"),
undefined,
VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
"videoThumbnails.errors.probeFailed"
);
expect(error.code).toBe(VIDEO_THUMBNAILS_CODES.PROBE_FAILED);
expect(error.context?.stderr).toBe("ffmpeg crashed");
});
it("stringifies non-Error causes for the stderr context", {
timeout: 5000,
}, () => {
const error = classifyRunnerError(
"raw string failure",
undefined,
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
"videoThumbnails.errors.extractFailed"
);
expect(error.context?.stderr).toBe("raw string failure");
});
it("stringifies object causes whose message is non-string", {
timeout: 5000,
}, () => {
const error = classifyRunnerError(
{ message: 42 },
undefined,
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
"videoThumbnails.errors.extractFailed"
);
expect(typeof error.context?.stderr).toBe("string");
});
it("preserves the abort reason in context", { timeout: 5000 }, () => {
const controller = new AbortController();
controller.abort("user-clicked-cancel");
const error = classifyRunnerError(
new Error("anything"),
controller.signal,
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
"videoThumbnails.errors.extractFailed"
);
expect(error.context?.reason).toBe("user-clicked-cancel");
});
});

View File

@@ -0,0 +1,74 @@
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
/**
* Build the ffmpeg `scale=...` filter expression for the requested fit strategy.
*
* - `cover` upscales to cover the target then crops to the exact dimensions.
* - `contain` scales down preserving aspect ratio, leaving letterbox padding.
* - `fill` stretches the frame to the exact target dimensions.
*/
export function buildScaleFilter(
width: number,
height: number,
fit: "cover" | "contain" | "fill"
): string {
if (fit === "contain") {
return `scale=${width}:${height}:force_original_aspect_ratio=decrease`;
}
if (fit === "fill") {
return `scale=${width}:${height}`;
}
return `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}`;
}
/**
* Map a `quality` (1–100) into ffmpeg's inverted `-q:v` 1–31 scale for codecs
* that use it (MJPEG/JPEG/WebP). PNG ignores `-q:v`.
*/
export function buildQualityArgs(
format: "jpg" | "png" | "webp",
quality: number
): readonly string[] {
if (format === "png") {
return [];
}
const q = Math.round(31 - (quality / 100) * 30);
return ["-q:v", String(q)];
}
/**
* Guard helper: convert a raw `unknown` thrown from ffmpeg into the appropriate
* `VideoThumbnailsError`. Used by every runner implementation to keep the
* abort-vs-failure decision in one place.
*/
export function classifyRunnerError(
cause: unknown,
signal: AbortSignal | undefined,
failureCode:
| typeof VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED
| typeof VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
failureKey: string
): VideoThumbnailsError {
if (signal?.aborted) {
return new VideoThumbnailsError(
VIDEO_THUMBNAILS_CODES.ABORTED,
"videoThumbnails.errors.aborted",
{ cause, context: { reason: String(signal.reason) } }
);
}
return new VideoThumbnailsError(failureCode, failureKey, {
cause,
context: { stderr: extractStderr(cause) },
});
}
function extractStderr(cause: unknown): string {
if (typeof cause === "object" && cause !== null) {
const message = (cause as { message?: unknown }).message;
if (typeof message === "string") {
return message;
}
}
return String(cause);
}

View File

@@ -0,0 +1,46 @@
/** Arguments accepted by `FfmpegRunner.extract`. */
export interface ExtractInput {
readonly inputPath: string;
readonly outputPath: string;
readonly timestamp: string;
readonly width: number;
readonly height: number;
readonly fit: "cover" | "contain" | "fill";
readonly format: "jpg" | "png" | "webp";
readonly quality: number;
}
/** Arguments accepted by `FfmpegRunner.tile`. */
export interface TileInput {
readonly inputPath: string;
readonly outputPath: string;
readonly columns: number;
readonly rows: number;
readonly thumbnailWidth: number;
readonly thumbnailHeight: number;
readonly interval: number;
readonly format: "jpg" | "png";
}
/** Raw probe payload returned by `FfmpegRunner.probe`. */
export interface ProbeResult {
readonly duration: number;
readonly width: number;
readonly height: number;
readonly codec: string;
readonly fps: number;
readonly bitrate: number;
}
/**
* Abstraction over ffmpeg / ffprobe used by every generator.
*
* Implementations MUST honour the supplied `AbortSignal` by terminating the
* underlying ffmpeg process and rejecting with `VIDEO_THUMBNAILS.ABORTED`.
*/
export interface FfmpegRunner {
isAvailable(signal?: AbortSignal): Promise<boolean>;
extract(input: ExtractInput, signal?: AbortSignal): Promise<void>;
tile(input: TileInput, signal?: AbortSignal): Promise<void>;
probe(inputPath: string, signal?: AbortSignal): Promise<ProbeResult>;
}

View File

@@ -0,0 +1,23 @@
export {
SPRITE_SHEET_FORMATS,
SpriteSheetConfigSchema,
SpriteSheetResultSchema,
THUMBNAIL_FITS,
THUMBNAIL_FORMATS,
ThumbnailConfigSchema,
ThumbnailOptionsSchema,
ThumbnailResultSchema,
TIMESTAMP_PATTERN,
VideoMetadataSchema,
} from "./schemas.js";
export type {
SpriteSheetConfig,
SpriteSheetFormat,
SpriteSheetResult,
ThumbnailConfig,
ThumbnailFit,
ThumbnailFormat,
ThumbnailOptions,
ThumbnailResult,
VideoMetadata,
} from "./schemas.types.js";

View File

@@ -0,0 +1,76 @@
# `schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines every Zod schema and inferred TypeScript type that crosses the public boundary of
`@suaveplan/video-thumbnails`. Schemas validate caller-supplied configuration at the
constructor and method boundary, narrow defaults are applied at the same point, and the
matching TS types are inferred via `z.infer` so callers cannot drift the literal and runtime
shapes apart. Format and fit enums are co-exported as `const` tuples for runtime iteration
and for the result schemas to reference.
## Features
- One schema per public type: config, options, results, and probe metadata.
- Defaults applied at parse time (e.g. `width = 320`, `format = "jpg"`, `timestamp =
"00:00:01"`).
- Bounded numeric ranges (`width ≤ 8192`, `interval ≤ 3600 s`) catch fat-finger errors before
they reach ffmpeg.
- Tight `^HH:MM:SS(.mmm)?$` pattern for every timestamp string.
- `THUMBNAIL_FORMATS`, `SPRITE_SHEET_FORMATS`, and `THUMBNAIL_FITS` exported as `as const`
tuples — usable both as Zod source-of-truth and as runtime iteration keys.
## Usage: Basic
```ts
import { ThumbnailConfigSchema } from "@suaveplan/video-thumbnails/schemas";
const config = ThumbnailConfigSchema.parse({ outputDir: "/tmp/thumbs" });
// config.width === 320, config.format === "jpg"
```
## Usage: Advanced
```ts
import {
ThumbnailConfigSchema,
ThumbnailOptionsSchema,
type ThumbnailConfig,
type ThumbnailOptions,
} from "@suaveplan/video-thumbnails/schemas";
function buildSettings(
raw: unknown,
opts: unknown,
): { config: ThumbnailConfig; options: ThumbnailOptions } {
const config = ThumbnailConfigSchema.parse(raw);
const options = ThumbnailOptionsSchema.parse(opts);
return { config, options };
}
```
## API Reference
- `ThumbnailConfigSchema` — `{ outputDir, width?, height?, quality?, format? }`.
- `ThumbnailOptionsSchema` — `{ timestamp?, fit? }`.
- `SpriteSheetConfigSchema` — `{ columns?, rows?, thumbnailWidth?, thumbnailHeight?, interval?, format? }`.
- `ThumbnailResultSchema`, `SpriteSheetResultSchema`, `VideoMetadataSchema` — used both at
the boundary (to assert internal invariants) and as the canonical type source.
- `THUMBNAIL_FORMATS` (`["jpg", "png", "webp"]`), `SPRITE_SHEET_FORMATS` (`["jpg", "png"]`),
`THUMBNAIL_FITS` (`["cover", "contain", "fill"]`), `TIMESTAMP_PATTERN`.
- Types: `ThumbnailConfig`, `ThumbnailOptions`, `SpriteSheetConfig`, `ThumbnailResult`,
`SpriteSheetResult`, `VideoMetadata`, `ThumbnailFormat`, `SpriteSheetFormat`,
`ThumbnailFit`.
## Implementation Notes
`parse` (not `safeParse`) is used at the public boundary because failure must throw — the
caller wrapping the boundary translates the `ZodError` into a `VideoThumbnailsError` with the
issues attached under `context.issues`. Default values live on the schemas, so the
`ThumbnailConfig` type carries the defaults as required fields after parsing — this is the
intended ergonomic. The numeric upper bounds (8192, 32, 3600) reflect realistic ffmpeg
limits; values beyond them indicate a bug rather than a corner case worth supporting.

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SpriteSheetConfigSchema,
SpriteSheetResultSchema,
ThumbnailConfigSchema,
ThumbnailOptionsSchema,
ThumbnailResultSchema,
VideoMetadataSchema,
} from "./schemas.js";
describe("ThumbnailConfigSchema", () => {
it("applies defaults when only outputDir is supplied", {
timeout: 5000,
}, () => {
const parsed = ThumbnailConfigSchema.parse({ outputDir: "/tmp" });
expect(parsed).toEqual({
outputDir: "/tmp",
width: 320,
height: 180,
quality: 80,
format: "jpg",
});
});
it("rejects width = 0", { timeout: 5000 }, () => {
expect(() =>
ThumbnailConfigSchema.parse({ outputDir: "/tmp", width: 0 })
).toThrow();
});
it("rejects format=tiff", { timeout: 5000 }, () => {
expect(() =>
ThumbnailConfigSchema.parse({ outputDir: "/tmp", format: "tiff" })
).toThrow();
});
});
describe("ThumbnailOptionsSchema", () => {
it("defaults timestamp and fit", { timeout: 5000 }, () => {
expect(ThumbnailOptionsSchema.parse({})).toEqual({
timestamp: "00:00:01",
fit: "cover",
});
});
it("accepts milliseconds in timestamp", { timeout: 5000 }, () => {
expect(
ThumbnailOptionsSchema.parse({ timestamp: "00:00:01.500" })
.timestamp
).toBe("00:00:01.500");
});
it("rejects malformed timestamps", { timeout: 5000 }, () => {
expect(() =>
ThumbnailOptionsSchema.parse({ timestamp: "0:0:0" })
).toThrow();
});
});
describe("SpriteSheetConfigSchema", () => {
it("applies defaults", { timeout: 5000 }, () => {
expect(SpriteSheetConfigSchema.parse({})).toEqual({
columns: 5,
rows: 5,
thumbnailWidth: 160,
thumbnailHeight: 90,
interval: 10,
format: "jpg",
});
});
});
describe("result schemas", () => {
it("ThumbnailResultSchema validates a happy-path result", {
timeout: 5000,
}, () => {
const result = ThumbnailResultSchema.parse({
outputPath: "/tmp/a.jpg",
width: 320,
height: 180,
format: "jpg",
sizeBytes: 1024,
timestamp: "00:00:01",
});
expect(result.sizeBytes).toBe(1024);
});
it("SpriteSheetResultSchema validates a happy-path result", {
timeout: 5000,
}, () => {
const result = SpriteSheetResultSchema.parse({
outputPath: "/tmp/a.jpg",
width: 800,
height: 450,
columns: 5,
rows: 5,
frameCount: 25,
interval: 5,
sizeBytes: 2048,
});
expect(result.frameCount).toBe(25);
});
it("VideoMetadataSchema accepts zero-duration entries", {
timeout: 5000,
}, () => {
expect(
VideoMetadataSchema.parse({
duration: 0,
width: 0,
height: 0,
codec: "unknown",
fps: 0,
bitrate: 0,
}).codec
).toBe("unknown");
});
});

View File

@@ -0,0 +1,73 @@
import z from "zod";
/** Matches `HH:MM:SS` or `HH:MM:SS.mmm`. */
export const TIMESTAMP_PATTERN = /^\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
/** Output formats supported for single-frame extraction. */
export const THUMBNAIL_FORMATS = ["jpg", "png", "webp"] as const;
/** Output formats supported for sprite-sheet generation. */
export const SPRITE_SHEET_FORMATS = ["jpg", "png"] as const;
/** Fit strategies supported when scaling the extracted frame. */
export const THUMBNAIL_FITS = ["cover", "contain", "fill"] as const;
/** Zod schema validating `ThumbnailGenerator` construction config. */
export const ThumbnailConfigSchema = z.object({
outputDir: z.string().min(1, "outputDir must be non-empty"),
width: z.number().int().positive().max(8192).default(320),
height: z.number().int().positive().max(8192).default(180),
quality: z.number().int().min(1).max(100).default(80),
format: z.enum(THUMBNAIL_FORMATS).default("jpg"),
});
/** Zod schema validating per-call extraction options. */
export const ThumbnailOptionsSchema = z.object({
timestamp: z
.string()
.regex(TIMESTAMP_PATTERN, "timestamp must be HH:MM:SS(.mmm)?")
.default("00:00:01"),
fit: z.enum(THUMBNAIL_FITS).default("cover"),
});
/** Zod schema validating `SpriteSheetGenerator` construction config. */
export const SpriteSheetConfigSchema = z.object({
columns: z.number().int().positive().max(32).default(5),
rows: z.number().int().positive().max(32).default(5),
thumbnailWidth: z.number().int().positive().max(2048).default(160),
thumbnailHeight: z.number().int().positive().max(2048).default(90),
interval: z.number().positive().max(3600).default(10),
format: z.enum(SPRITE_SHEET_FORMATS).default("jpg"),
});
/** Zod schema for a successful single-frame extraction result. */
export const ThumbnailResultSchema = z.object({
outputPath: z.string(),
width: z.number().int().positive(),
height: z.number().int().positive(),
format: z.enum(THUMBNAIL_FORMATS),
sizeBytes: z.number().int().nonnegative(),
timestamp: z.string(),
});
/** Zod schema for a successful sprite-sheet generation result. */
export const SpriteSheetResultSchema = z.object({
outputPath: z.string(),
width: z.number().int().positive(),
height: z.number().int().positive(),
columns: z.number().int().positive(),
rows: z.number().int().positive(),
frameCount: z.number().int().nonnegative(),
interval: z.number().positive(),
sizeBytes: z.number().int().nonnegative(),
});
/** Zod schema for `VideoMetadata`. */
export const VideoMetadataSchema = z.object({
duration: z.number().nonnegative(),
width: z.number().int().nonnegative(),
height: z.number().int().nonnegative(),
codec: z.string(),
fps: z.number().nonnegative(),
bitrate: z.number().nonnegative(),
});

View File

@@ -0,0 +1,36 @@
import type { z } from "zod";
import type {
SpriteSheetConfigSchema,
SpriteSheetResultSchema,
ThumbnailConfigSchema,
ThumbnailOptionsSchema,
ThumbnailResultSchema,
VideoMetadataSchema,
} from "./schemas.js";
/** Output formats supported by single-frame extraction. */
export type ThumbnailFormat = "jpg" | "png" | "webp";
/** Output formats supported by sprite-sheet generation. */
export type SpriteSheetFormat = "jpg" | "png";
/** How the extracted thumbnail should fit the configured dimensions. */
export type ThumbnailFit = "cover" | "contain" | "fill";
/** Validated configuration for a `ThumbnailGenerator`. */
export type ThumbnailConfig = z.infer<typeof ThumbnailConfigSchema>;
/** Validated options for a single `generate` / `extractSlidingWindow` call. */
export type ThumbnailOptions = z.infer<typeof ThumbnailOptionsSchema>;
/** Validated configuration for a `SpriteSheetGenerator`. */
export type SpriteSheetConfig = z.infer<typeof SpriteSheetConfigSchema>;
/** Result of a successful single-frame extraction. */
export type ThumbnailResult = z.infer<typeof ThumbnailResultSchema>;
/** Result of a successful sprite-sheet generation. */
export type SpriteSheetResult = z.infer<typeof SpriteSheetResultSchema>;
/** Metadata returned by `ThumbnailGenerator.getMetadata`. */
export type VideoMetadata = z.infer<typeof VideoMetadataSchema>;

View File

@@ -0,0 +1,6 @@
export { SpriteSheetGenerator } from "./sprite-sheet.js";
export type {
SpriteSheetConfig,
SpriteSheetConfigInput,
SpriteSheetResult,
} from "./sprite-sheet.types.js";

View File

@@ -0,0 +1,81 @@
# `SpriteSheetGenerator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SpriteSheetGenerator` renders an N×M grid of thumbnails into a single image using ffmpeg's
`tile` filter. A sprite sheet is the canonical artefact for video scrubbers and timeline
previews — one HTTP fetch, one decode, then arbitrary in-page hovers slide a `background-
position`. The generator delegates the actual ffmpeg invocation to an injectable
`FfmpegRunner` (default: `FluentFfmpegRunner` with bundled ffmpeg/ffprobe binaries) and
auto-detects video duration via the runner's `probe` when the caller does not pass one
explicitly.
## Features
- Configurable grid: `columns`, `rows`, `thumbnailWidth`, `thumbnailHeight`, `interval`,
`format`.
- Auto-detection of video duration via `runner.probe` when caller omits `duration`.
- Frame count is capped at `columns × rows` and at `floor(duration / interval)` — whichever
is smaller — so short videos do not produce padded grids.
- Zod boundary validation with code-specific failures (`INVALID_DIMENSIONS`,
`FORMAT_UNSUPPORTED`, `INVALID_OPTIONS`).
- OTel: `video_thumbnails.sprite_sheet.duration_ms{outcome,format}` plus
`video_thumbnails.sprite_sheet.frames{format}`.
- `AbortSignal` propagated through every call; aborts surface as `VIDEO_THUMBNAILS.ABORTED`.
## Usage: Basic
```ts
import { SpriteSheetGenerator } from "@suaveplan/video-thumbnails/sprite-sheet";
const generator = new SpriteSheetGenerator();
const result = await generator.generate("/data/clip.mp4", "/tmp/sprite.jpg");
console.log(result.frameCount, result.columns, result.rows);
```
## Usage: Advanced
```ts
import { SpriteSheetGenerator } from "@suaveplan/video-thumbnails/sprite-sheet";
const generator = new SpriteSheetGenerator({
columns: 8,
rows: 6,
thumbnailWidth: 192,
thumbnailHeight: 108,
interval: 2,
format: "jpg",
});
const controller = new AbortController();
const result = await generator.generate(
"/data/long-movie.mp4",
"/tmp/movie-sprite.jpg",
7200,
controller.signal,
);
// result.width === 8 * 192, result.height === 6 * 108, result.frameCount === 48
```
## API Reference
- `class SpriteSheetGenerator`
- `constructor(config?: SpriteSheetConfigInput, runner?: FfmpegRunner)`
- `generate(inputPath, outputPath, duration?, signal?): Promise<SpriteSheetResult>`
Throws `VideoThumbnailsError` on `INVALID_DIMENSIONS`, `FORMAT_UNSUPPORTED`,
`INVALID_OPTIONS`, `VIDEO_INVALID`, `EXTRACT_FAILED`, `PROBE_FAILED`, or `ABORTED`.
## Implementation Notes
`rows` is recomputed against the actual `frameCount` (rather than blindly using the configured
upper bound) so a 5-second clip with a 10-frame grid does not produce four trailing black
cells. The generator never directly invokes ffmpeg — every call routes through the supplied
runner so consumers can swap in a hardware-accelerated or hosted variant without touching
this class. OTel records before throwing on failure so dashboards distinguish counts from
timings even on errors. `duration` is validated when passed explicitly: non-finite or
non-positive values are rejected with `VIDEO_INVALID`, mirroring the probe path.

View File

@@ -0,0 +1,187 @@
import { stat } from "node:fs/promises";
import { join } from "node:path";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
getFixtureVideo,
makeOutputDir,
safeRm,
} from "../../tests/setup/fixtures.js";
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
import { SpriteSheetGenerator } from "./sprite-sheet.js";
let fixture: string;
let outDir: string;
beforeAll(async () => {
fixture = await getFixtureVideo();
outDir = await makeOutputDir("sprite");
});
afterAll(async () => {
await safeRm(outDir);
});
describe("SpriteSheetGenerator", () => {
it("produces a 3x3 sprite sheet for the fixture", {
timeout: 30000,
}, async () => {
const generator = new SpriteSheetGenerator({
columns: 3,
rows: 3,
thumbnailWidth: 80,
thumbnailHeight: 60,
interval: 1,
format: "jpg",
});
const output = join(outDir, "sprite.jpg");
const result = await generator.generate(fixture, output, 5);
expect(result.width).toBe(240);
expect(result.columns).toBe(3);
expect(result.frameCount).toBeGreaterThanOrEqual(4);
expect(result.frameCount).toBeLessThanOrEqual(9);
expect((await stat(output)).size).toBeGreaterThan(0);
});
it("auto-detects duration via runner.probe", {
timeout: 30000,
}, async () => {
const generator = new SpriteSheetGenerator({
columns: 2,
rows: 2,
thumbnailWidth: 80,
thumbnailHeight: 60,
interval: 1,
format: "jpg",
});
const output = join(outDir, "auto.jpg");
const result = await generator.generate(fixture, output);
expect(result.frameCount).toBeGreaterThan(0);
});
it("rejects columns <= 0", { timeout: 5000 }, () => {
expect(() => new SpriteSheetGenerator({ columns: 0 })).toThrow(
expect.objectContaining({
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
})
);
});
it("rejects rows <= 0", { timeout: 5000 }, () => {
expect(() => new SpriteSheetGenerator({ rows: 0 })).toThrow(
expect.objectContaining({
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
})
);
});
it("rejects thumbnailHeight <= 0", { timeout: 5000 }, () => {
expect(() => new SpriteSheetGenerator({ thumbnailHeight: 0 })).toThrow(
expect.objectContaining({
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
})
);
});
it("rejects width <= 0", { timeout: 5000 }, () => {
expect(
() =>
new SpriteSheetGenerator({
thumbnailWidth: 0,
})
).toThrow(
expect.objectContaining({
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
})
);
});
it("rejects unsupported format", { timeout: 5000 }, () => {
expect(
() =>
new SpriteSheetGenerator({
// @ts-expect-error — runtime boundary
format: "webp",
})
).toThrow(
expect.objectContaining({
code: VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED,
})
);
});
it("rejects invalid interval as INVALID_OPTIONS", { timeout: 5000 }, () => {
expect(
() =>
new SpriteSheetGenerator({
interval: 0,
})
).toThrow(
expect.objectContaining({
code: VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS,
})
);
});
it("rejects explicit duration <= 0", { timeout: 30000 }, async () => {
const generator = new SpriteSheetGenerator({ interval: 1 });
await expect(
generator.generate(fixture, join(outDir, "no.jpg"), 0)
).rejects.toMatchObject({
code: VIDEO_THUMBNAILS_CODES.VIDEO_INVALID,
});
});
it("records failure metrics and rethrows when runner.tile throws", {
timeout: 5000,
}, async () => {
const generator = new SpriteSheetGenerator(
{ interval: 1 },
{
async isAvailable() {
return true;
},
async extract() {
/* unused */
},
async tile() {
throw new Error("tile failed");
},
async probe() {
return {
duration: 5,
width: 320,
height: 240,
codec: "h264",
fps: 10,
bitrate: 0,
};
},
}
);
await expect(
generator.generate(fixture, join(outDir, "fail.jpg"))
).rejects.toThrow(/tile failed/);
});
it("rejects pre-aborted signal with ABORTED", {
timeout: 5000,
}, async () => {
const generator = new SpriteSheetGenerator();
const controller = new AbortController();
controller.abort();
await expect(
generator.generate(
fixture,
join(outDir, "ab.jpg"),
5,
controller.signal
)
).rejects.toMatchObject({ code: VIDEO_THUMBNAILS_CODES.ABORTED });
});
});

View File

@@ -0,0 +1,163 @@
import { stat } from "node:fs/promises";
import type { ZodError } from "zod";
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
import { FluentFfmpegRunner } from "../runner/fluent-ffmpeg-runner.js";
import type { FfmpegRunner } from "../runner/runner.types.js";
import {
type SpriteSheetConfig,
SpriteSheetConfigSchema,
type SpriteSheetResult,
} from "../schemas/index.js";
import {
getTelemetry,
type VideoThumbnailsTelemetry,
} from "../telemetry/telemetry.js";
import type { SpriteSheetConfigInput } from "./sprite-sheet.types.js";
/**
* Generates N×M sprite sheets via ffmpeg's `tile` filter.
*
* Auto-detects video duration via the runner's `probe` when the caller does
* not pass one explicitly, then computes the actual frame count as the
* smaller of `columns × rows` and `floor(duration / interval)`.
*/
export class SpriteSheetGenerator {
private readonly config: SpriteSheetConfig;
private readonly runner: FfmpegRunner;
private readonly telemetry: VideoThumbnailsTelemetry;
public constructor(
config: SpriteSheetConfigInput = {},
runner: FfmpegRunner = new FluentFfmpegRunner()
) {
this.config = parseConfig(config);
this.runner = runner;
this.telemetry = getTelemetry();
}
public async generate(
inputPath: string,
outputPath: string,
duration?: number,
signal?: AbortSignal
): Promise<SpriteSheetResult> {
throwIfAborted(signal);
const start = performance.now();
const attrs = { format: this.config.format };
try {
const videoDuration =
duration !== undefined
? validateDuration(duration)
: (await this.runner.probe(inputPath, signal)).duration;
const {
columns,
thumbnailWidth,
thumbnailHeight,
format,
interval,
} = this.config;
const maxFrames = columns * this.config.rows;
const frameCount = Math.min(
maxFrames,
Math.max(0, Math.floor(videoDuration / interval))
);
const rows = Math.max(1, Math.ceil(frameCount / columns));
await this.runner.tile(
{
inputPath,
outputPath,
columns,
rows,
thumbnailWidth,
thumbnailHeight,
interval,
format,
},
signal
);
const fileStat = await stat(outputPath);
const result: SpriteSheetResult = {
outputPath,
width: columns * thumbnailWidth,
height: rows * thumbnailHeight,
columns,
rows,
frameCount,
interval,
sizeBytes: fileStat.size,
};
this.telemetry.spriteSheetDuration.record(
performance.now() - start,
{ ...attrs, outcome: "success" }
);
this.telemetry.spriteSheetFrames.record(frameCount, attrs);
return result;
} catch (error) {
this.telemetry.spriteSheetDuration.record(
performance.now() - start,
{ ...attrs, outcome: "failure" }
);
throw error;
}
}
}
function parseConfig(input: SpriteSheetConfigInput): SpriteSheetConfig {
const parsed = SpriteSheetConfigSchema.safeParse(input);
if (parsed.success) {
return parsed.data;
}
throw new VideoThumbnailsError(
pickCode(parsed.error),
"videoThumbnails.errors.invalidOptions",
{
cause: parsed.error,
context: { issues: parsed.error.issues },
}
);
}
function pickCode(
error: ZodError
):
| typeof VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS
| typeof VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED
| typeof VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS {
for (const issue of error.issues) {
const key = issue.path[0];
if (
key === "columns" ||
key === "rows" ||
key === "thumbnailWidth" ||
key === "thumbnailHeight"
) {
return VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS;
}
if (key === "format") {
return VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED;
}
}
return VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS;
}
function validateDuration(duration: number): number {
if (!Number.isFinite(duration) || duration <= 0) {
throw new VideoThumbnailsError(
VIDEO_THUMBNAILS_CODES.VIDEO_INVALID,
"videoThumbnails.errors.videoInvalid",
{ context: { duration } }
);
}
return duration;
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new VideoThumbnailsError(
VIDEO_THUMBNAILS_CODES.ABORTED,
"videoThumbnails.errors.aborted",
{ context: { reason: String(signal.reason) } }
);
}
}

View File

@@ -0,0 +1,6 @@
import type { SpriteSheetConfig, SpriteSheetResult } from "../schemas/index.js";
export type { SpriteSheetConfig, SpriteSheetResult };
/** Partial `SpriteSheetConfig` accepted by callers; defaults applied at parse. */
export type SpriteSheetConfigInput = Partial<SpriteSheetConfig>;

View File

@@ -0,0 +1 @@
export { getTelemetry, type VideoThumbnailsTelemetry } from "./telemetry.js";

View File

@@ -0,0 +1,83 @@
# `telemetry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Centralises the OpenTelemetry instruments owned by `@suaveplan/video-thumbnails`. A meter is
acquired lazily — only when `getTelemetry()` is called — so the package can keep
`sideEffects: false` and consumers that import a subpath such as `./schemas` do not pay for
metric creation. The bundle exposes one counter and three histograms covering the entire
externally-visible surface (single-frame extracts and sprite-sheet generation).
## Features
- Lazy meter acquisition via `metrics.getMeter("@suaveplan/video-thumbnails")`.
- Stable instrument names that mirror the spec delta (`video_thumbnails.extract.*`,
`video_thumbnails.sprite_sheet.*`).
- Each instrument carries a `description` so dashboards stay self-documenting.
- Returns a frozen `VideoThumbnailsTelemetry` view callable from any generator.
## Usage: Basic
```ts
import { getTelemetry } from "@suaveplan/video-thumbnails/telemetry";
const telemetry = getTelemetry();
telemetry.extractTotal.add(1, { outcome: "success", format: "jpg" });
```
## Usage: Advanced
```ts
import { getTelemetry } from "@suaveplan/video-thumbnails/telemetry";
async function instrumentedExtract<T>(
label: { format: string; fit: string },
body: () => Promise<T>,
): Promise<T> {
const telemetry = getTelemetry();
const start = performance.now();
try {
const result = await body();
telemetry.extractTotal.add(1, { ...label, outcome: "success" });
telemetry.extractDuration.record(performance.now() - start, {
...label,
outcome: "success",
});
return result;
} catch (error) {
telemetry.extractTotal.add(1, { ...label, outcome: "failure" });
telemetry.extractDuration.record(performance.now() - start, {
...label,
outcome: "failure",
});
throw error;
}
}
```
## API Reference
- `getTelemetry(): VideoThumbnailsTelemetry` — returns a fresh bundle from the global meter.
- `VideoThumbnailsTelemetry` — `{ extractDuration: Histogram, extractTotal: Counter,
spriteSheetDuration: Histogram, spriteSheetFrames: Histogram }`.
Instrument names:
- `video_thumbnails.extract.duration_ms` (histogram, ms; attrs `{format, fit, outcome}`)
- `video_thumbnails.extract.total` (counter; attrs `{outcome, format}`)
- `video_thumbnails.sprite_sheet.duration_ms` (histogram, ms; attrs `{outcome, format}`)
- `video_thumbnails.sprite_sheet.frames` (histogram; attrs `{format}`)
## Implementation Notes
The `metrics.getMeter` call returns a no-op meter when no OTel SDK has been registered, so
tests that do not install a meter provider still execute the recording paths without
exploding — the values are simply discarded. Tests that need to observe values install an
`InMemoryMetricExporter` via `MeterProvider` before instantiating the generator; the rest of
the call site stays identical. Instrument creation is cheap, but if it ever becomes a hot
path the bundle can be memoised behind a lazy module-scoped singleton without changing the
public contract.

View File

@@ -0,0 +1,76 @@
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
getFixtureVideo,
makeOutputDir,
safeRm,
} from "../../tests/setup/fixtures.js";
import { ThumbnailGenerator } from "../thumbnail-generator/thumbnail-generator.js";
import { getTelemetry } from "./telemetry.js";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 50,
});
const provider = new MeterProvider({ readers: [reader] });
let outDir: string;
beforeAll(async () => {
metrics.setGlobalMeterProvider(provider);
outDir = await makeOutputDir("telemetry");
});
afterAll(async () => {
await reader.shutdown();
await provider.shutdown();
metrics.disable();
await safeRm(outDir);
});
describe("telemetry", () => {
it("constructs a telemetry bundle with four instruments", {
timeout: 5000,
}, () => {
const telemetry = getTelemetry();
expect(telemetry.extractDuration).toBeDefined();
expect(telemetry.extractTotal).toBeDefined();
expect(telemetry.spriteSheetDuration).toBeDefined();
expect(telemetry.spriteSheetFrames).toBeDefined();
});
it("emits success counter after a real extract", {
timeout: 30000,
}, async () => {
const fixture = await getFixtureVideo();
const generator = new ThumbnailGenerator({ outputDir: outDir });
await generator.generate(fixture, { timestamp: "00:00:01" });
await reader.forceFlush();
const metricsData = exporter.getMetrics();
const flat = metricsData
.flatMap((entry) => entry.scopeMetrics)
.flatMap((scope) => scope.metrics);
const extractTotal = flat.find(
(metric) =>
metric.descriptor.name === "video_thumbnails.extract.total"
);
expect(extractTotal).toBeDefined();
const success = extractTotal?.dataPoints.find(
(point) => point.attributes.outcome === "success"
);
expect(success).toBeDefined();
});
});

View File

@@ -0,0 +1,50 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/** Bundle of every OTel instrument owned by `@suaveplan/video-thumbnails`. */
export interface VideoThumbnailsTelemetry {
readonly extractDuration: Histogram;
readonly extractTotal: Counter;
readonly spriteSheetDuration: Histogram;
readonly spriteSheetFrames: Histogram;
}
const METER_NAME = "@suaveplan/video-thumbnails";
/**
* Build a fresh telemetry bundle backed by the global `metrics` provider.
*
* Called lazily by each generator so the package retains `sideEffects: false`
* and consumers that only import a subpath (e.g. `./schemas`) do not pay the
* cost of acquiring a meter.
*/
export function getTelemetry(): VideoThumbnailsTelemetry {
const meter = metrics.getMeter(METER_NAME);
return {
extractDuration: meter.createHistogram(
"video_thumbnails.extract.duration_ms",
{
description:
"Single-frame thumbnail extraction duration in milliseconds.",
unit: "ms",
}
),
extractTotal: meter.createCounter("video_thumbnails.extract.total", {
description:
"Total thumbnail extractions, partitioned by outcome and format.",
}),
spriteSheetDuration: meter.createHistogram(
"video_thumbnails.sprite_sheet.duration_ms",
{
description:
"Sprite-sheet generation duration in milliseconds.",
unit: "ms",
}
),
spriteSheetFrames: meter.createHistogram(
"video_thumbnails.sprite_sheet.frames",
{
description: "Number of frames captured per sprite-sheet run.",
}
),
};
}

View File

@@ -0,0 +1,12 @@
export {
buildEvenlySpacedTimestamps,
ThumbnailGenerator,
} from "./thumbnail-generator.js";
export type {
ThumbnailConfig,
ThumbnailConfigInput,
ThumbnailOptions,
ThumbnailOptionsInput,
ThumbnailResult,
VideoMetadata,
} from "./thumbnail-generator.types.js";

View File

@@ -0,0 +1,91 @@
# `ThumbnailGenerator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ThumbnailGenerator` is the primary class consumers reach for to extract still frames from a
video file. It accepts a Zod-validated `ThumbnailConfig` (output directory, dimensions,
format, quality) and delegates the actual ffmpeg invocation to an injectable `FfmpegRunner`.
The default runner — `FluentFfmpegRunner` — bundles ffmpeg/ffprobe binaries so the package
runs unmodified on any host. Every public method honours an `AbortSignal`, validates input at
the boundary, and records OTel histograms + counters keyed by `format`, `fit`, and `outcome`.
## Features
- Single-frame extract at any `HH:MM:SS(.mmm)?` timestamp (`generate`).
- Multi-timestamp batch with abort-aware iteration (`generateMultiple`).
- Sliding-window extraction — N evenly-spaced frames across a duration
(`extractSlidingWindow`).
- ffprobe-backed metadata (`getMetadata`).
- Runner-availability probe (`isAvailable`).
- Zod boundary validation with code-specific failures (`INVALID_DIMENSIONS`,
`INVALID_TIMESTAMP`, `FORMAT_UNSUPPORTED`, `INVALID_OPTIONS`).
- OTel: `video_thumbnails.extract.duration_ms{format,fit,outcome}` +
`video_thumbnails.extract.total{outcome,format}`.
- `AbortSignal` propagated through every call into the runner; aborts surface as
`VIDEO_THUMBNAILS.ABORTED`.
## Usage: Basic
```ts
import { ThumbnailGenerator } from "@suaveplan/video-thumbnails/thumbnail-generator";
const generator = new ThumbnailGenerator({ outputDir: "/tmp/thumbs" });
const result = await generator.generate("/data/clip.mp4");
console.log(result.outputPath, result.sizeBytes);
```
## Usage: Advanced
```ts
import { ThumbnailGenerator } from "@suaveplan/video-thumbnails/thumbnail-generator";
import { FluentFfmpegRunner } from "@suaveplan/video-thumbnails/runner";
const generator = new ThumbnailGenerator(
{
outputDir: "/tmp/thumbs",
width: 640,
height: 360,
format: "webp",
quality: 90,
},
new FluentFfmpegRunner({ ffmpegPath: "/opt/ffmpeg/bin/ffmpeg" }),
);
const controller = new AbortController();
const metadata = await generator.getMetadata("/data/clip.mp4");
const frames = await generator.extractSlidingWindow(
"/data/clip.mp4",
8,
metadata.duration,
controller.signal,
);
```
## API Reference
- `class ThumbnailGenerator`
- `constructor(config: ThumbnailConfigInput, runner?: FfmpegRunner)`
- `generate(inputPath, options?, signal?): Promise<ThumbnailResult>`
- `generateMultiple(inputPath, timestamps, signal?): Promise<ThumbnailResult[]>`
- `extractSlidingWindow(inputPath, count, durationSeconds, signal?): Promise<ThumbnailResult[]>`
- `getMetadata(inputPath, signal?): Promise<VideoMetadata>`
- `isAvailable(signal?): Promise<boolean>`
- `buildEvenlySpacedTimestamps(count, durationSeconds): string[]` — utility re-used by
`extractSlidingWindow`.
Throws `VideoThumbnailsError` for every failure mode (`INVALID_*`, `FORMAT_UNSUPPORTED`,
`EXTRACT_FAILED`, `PROBE_FAILED`, `VIDEO_INVALID`, `ABORTED`).
## Implementation Notes
`extractSlidingWindow` divides the duration into `count + 1` slots and picks the boundary at
each interior slot — i.e. for `count = 4, duration = 5s` the timestamps are `1.0s`, `2.0s`,
`3.0s`, `4.0s`. This avoids the degenerate case of putting a frame at `0.0` (often a black
intro frame) or at the exact duration (frequently past the last decodable frame). The
generator never writes files itself — `stat()` reads back the file ffmpeg produced so the
returned `sizeBytes` reflects the actual on-disk size, not the requested dimensions
multiplied by an assumed bit depth. OTel records before throwing on failure so dashboards
distinguish counts from timings.

View File

@@ -0,0 +1,338 @@
import { stat } from "node:fs/promises";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
getFixtureVideo,
makeOutputDir,
safeRm,
} from "../../tests/setup/fixtures.js";
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
import {
buildEvenlySpacedTimestamps,
ThumbnailGenerator,
} from "./thumbnail-generator.js";
let fixture: string;
let outDir: string;
beforeAll(async () => {
fixture = await getFixtureVideo();
outDir = await makeOutputDir("thumb-gen");
});
afterAll(async () => {
await safeRm(outDir);
});
describe("ThumbnailGenerator", () => {
it("extracts a JPEG at the default timestamp", {
timeout: 30000,
}, async () => {
const generator = new ThumbnailGenerator({
outputDir: outDir,
format: "jpg",
width: 160,
height: 120,
});
const result = await generator.generate(fixture);
expect(result.format).toBe("jpg");
expect(result.timestamp).toBe("00:00:01");
expect((await stat(result.outputPath)).size).toBeGreaterThan(0);
});
it("supports png output", { timeout: 30000 }, async () => {
const generator = new ThumbnailGenerator({
outputDir: outDir,
format: "png",
});
const result = await generator.generate(fixture, {
timestamp: "00:00:02",
});
expect(result.outputPath.endsWith(".png")).toBe(true);
});
it("supports webp output", { timeout: 30000 }, async () => {
const generator = new ThumbnailGenerator({
outputDir: outDir,
format: "webp",
});
const result = await generator.generate(fixture, {
timestamp: "00:00:03",
});
expect(result.outputPath.endsWith(".webp")).toBe(true);
});
it("returns one result per timestamp", { timeout: 30000 }, async () => {
const generator = new ThumbnailGenerator({ outputDir: outDir });
const results = await generator.generateMultiple(fixture, [
"00:00:01",
"00:00:02",
"00:00:03",
]);
expect(results).toHaveLength(3);
for (const result of results) {
expect((await stat(result.outputPath)).size).toBeGreaterThan(0);
}
});
it("generateMultiple aborts mid-iteration", {
timeout: 30000,
}, async () => {
const generator = new ThumbnailGenerator({ outputDir: outDir });
const controller = new AbortController();
controller.abort();
await expect(
generator.generateMultiple(
fixture,
["00:00:01", "00:00:02"],
controller.signal
)
).rejects.toMatchObject({ code: VIDEO_THUMBNAILS_CODES.ABORTED });
});
it("produces exactly N evenly-spaced thumbnails", {
timeout: 60000,
}, async () => {
const generator = new ThumbnailGenerator({ outputDir: outDir });
const results = await generator.extractSlidingWindow(fixture, 4, 5);
expect(results).toHaveLength(4);
const timestamps = results.map((entry) => entry.timestamp);
expect(timestamps).toEqual([
"00:00:01.000",
"00:00:02.000",
"00:00:03.000",
"00:00:04.000",
]);
});
it("rejects sliding-window with INVALID_DIMENSIONS on count <= 0", {
timeout: 5000,
}, async () => {
const generator = new ThumbnailGenerator({ outputDir: outDir });
await expect(
generator.extractSlidingWindow(fixture, 0, 5)
).rejects.toMatchObject({
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
});
});
it("returns metadata for a real fixture", { timeout: 30000 }, async () => {
const generator = new ThumbnailGenerator({ outputDir: outDir });
const metadata = await generator.getMetadata(fixture);
expect(metadata.duration).toBeGreaterThan(4);
expect(metadata.duration).toBeLessThan(6);
expect(metadata.width).toBe(320);
expect(metadata.height).toBe(240);
expect(metadata.codec).toBe("h264");
});
it("getMetadata rejects when pre-aborted", { timeout: 5000 }, async () => {
const generator = new ThumbnailGenerator({ outputDir: outDir });
const controller = new AbortController();
controller.abort();
await expect(
generator.getMetadata(fixture, controller.signal)
).rejects.toMatchObject({ code: VIDEO_THUMBNAILS_CODES.ABORTED });
});
it("reports ffmpeg available when binary is installed", {
timeout: 30000,
}, async () => {
const generator = new ThumbnailGenerator({ outputDir: outDir });
expect(await generator.isAvailable()).toBe(true);
});
it("aborts a long-running extract", { timeout: 30000 }, async () => {
const generator = new ThumbnailGenerator({ outputDir: outDir });
const controller = new AbortController();
controller.abort();
await expect(
generator.generate(
fixture,
{ timestamp: "00:00:01" },
controller.signal
)
).rejects.toMatchObject({ code: VIDEO_THUMBNAILS_CODES.ABORTED });
});
it("rejects width <= 0", { timeout: 5000 }, () => {
expect(
() => new ThumbnailGenerator({ outputDir: outDir, width: 0 })
).toThrow(
expect.objectContaining({
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
})
);
});
it("rejects malformed timestamp", { timeout: 30000 }, async () => {
const generator = new ThumbnailGenerator({ outputDir: outDir });
await expect(
generator.generate(fixture, { timestamp: "not-a-time" })
).rejects.toMatchObject({
code: VIDEO_THUMBNAILS_CODES.INVALID_TIMESTAMP,
});
});
it("rejects unsupported output format", { timeout: 5000 }, () => {
expect(
() =>
new ThumbnailGenerator({
outputDir: outDir,
// @ts-expect-error — testing runtime boundary
format: "tiff",
})
).toThrow(
expect.objectContaining({
code: VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED,
})
);
});
it("rejects unknown option keys (INVALID_OPTIONS)", {
timeout: 5000,
}, () => {
// ZodSchema is strict only on keys it knows about — for fit, an invalid
// enum lands under INVALID_OPTIONS through the parseOptions catch-all.
const generator = new ThumbnailGenerator({ outputDir: outDir });
return expect(
// @ts-expect-error — testing runtime boundary
generator.generate(fixture, { fit: "nonsense" })
).rejects.toMatchObject({
code: VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS,
});
});
it("rejects empty outputDir at construction", { timeout: 5000 }, () => {
expect(() => new ThumbnailGenerator({ outputDir: "" })).toThrow(
expect.objectContaining({
code: VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS,
})
);
});
});
describe("generate failure path", () => {
it("records failure metrics and rethrows when runner.extract throws", {
timeout: 5000,
}, async () => {
const cause = new Error("runner exploded");
const generator = new ThumbnailGenerator(
{ outputDir: outDir },
{
async isAvailable() {
return true;
},
async extract() {
throw cause;
},
async tile() {
/* unused */
},
async probe() {
return {
duration: 0,
width: 0,
height: 0,
codec: "",
fps: 0,
bitrate: 0,
};
},
}
);
await expect(generator.generate(fixture)).rejects.toBe(cause);
});
});
describe("ensureAvailable", () => {
it("resolves when runner reports available", {
timeout: 30000,
}, async () => {
const generator = new ThumbnailGenerator({ outputDir: outDir });
await expect(generator.ensureAvailable()).resolves.toBeUndefined();
});
it("rejects with RUNNER_UNAVAILABLE when runner reports false", {
timeout: 5000,
}, async () => {
const generator = new ThumbnailGenerator(
{ outputDir: outDir },
{
async isAvailable() {
return false;
},
async extract() {
/* unused */
},
async tile() {
/* unused */
},
async probe() {
return {
duration: 0,
width: 0,
height: 0,
codec: "",
fps: 0,
bitrate: 0,
};
},
}
);
await expect(generator.ensureAvailable()).rejects.toMatchObject({
code: VIDEO_THUMBNAILS_CODES.RUNNER_UNAVAILABLE,
});
});
it("rejects with ABORTED when signal pre-aborted", {
timeout: 5000,
}, async () => {
const generator = new ThumbnailGenerator({ outputDir: outDir });
const controller = new AbortController();
controller.abort();
await expect(
generator.ensureAvailable(controller.signal)
).rejects.toMatchObject({
code: VIDEO_THUMBNAILS_CODES.ABORTED,
});
});
});
describe("buildEvenlySpacedTimestamps", () => {
it("evenly distributes across the supplied duration", {
timeout: 5000,
}, () => {
expect(buildEvenlySpacedTimestamps(3, 4)).toEqual([
"00:00:01.000",
"00:00:02.000",
"00:00:03.000",
]);
});
it("rounds milliseconds to three digits", { timeout: 5000 }, () => {
expect(buildEvenlySpacedTimestamps(1, 1)).toEqual(["00:00:00.500"]);
});
it("handles hour boundaries", { timeout: 5000 }, () => {
expect(buildEvenlySpacedTimestamps(1, 7200)).toEqual(["01:00:00.000"]);
});
});
describe("ts-expect-error guard", () => {
it("re-asserts INVALID_DIMENSIONS on extractSlidingWindow with non-finite duration", {
timeout: 5000,
}, async () => {
const generator = new ThumbnailGenerator({ outputDir: outDir });
await expect(
generator.extractSlidingWindow(fixture, 3, Number.POSITIVE_INFINITY)
).rejects.toMatchObject({
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
});
});
});

View File

@@ -0,0 +1,288 @@
import { stat } from "node:fs/promises";
import { join } from "node:path";
import type { ZodError } from "zod";
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
import { FluentFfmpegRunner } from "../runner/fluent-ffmpeg-runner.js";
import type { FfmpegRunner } from "../runner/runner.types.js";
import {
type ThumbnailConfig,
ThumbnailConfigSchema,
type ThumbnailOptions,
ThumbnailOptionsSchema,
type ThumbnailResult,
type VideoMetadata,
} from "../schemas/index.js";
import {
getTelemetry,
type VideoThumbnailsTelemetry,
} from "../telemetry/telemetry.js";
import type {
ThumbnailConfigInput,
ThumbnailOptionsInput,
} from "./thumbnail-generator.types.js";
/** Extension map per output format. */
const FORMAT_EXTENSIONS: Readonly<Record<ThumbnailConfig["format"], string>> = {
jpg: "jpg",
png: "png",
webp: "webp",
};
/**
* Generates single-frame video thumbnails through an injectable {@link FfmpegRunner}.
*
* Defaults to {@link FluentFfmpegRunner}, which bundles ffmpeg/ffprobe binaries
* via `@ffmpeg-installer/ffmpeg` and `@ffprobe-installer/ffprobe` so the
* package works on any host out of the box.
*/
export class ThumbnailGenerator {
private readonly config: ThumbnailConfig;
private readonly runner: FfmpegRunner;
private readonly telemetry: VideoThumbnailsTelemetry;
public constructor(
config: ThumbnailConfigInput,
runner: FfmpegRunner = new FluentFfmpegRunner()
) {
this.config = parseConfig(config);
this.runner = runner;
this.telemetry = getTelemetry();
}
/** Extract a single thumbnail at the requested timestamp. */
public async generate(
inputPath: string,
options?: ThumbnailOptionsInput,
signal?: AbortSignal
): Promise<ThumbnailResult> {
throwIfAborted(signal);
const parsedOptions = parseOptions(options);
const start = performance.now();
const attrs = {
format: this.config.format,
fit: parsedOptions.fit,
};
try {
const result = await this.runOne(inputPath, parsedOptions, signal);
this.telemetry.extractDuration.record(performance.now() - start, {
...attrs,
outcome: "success",
});
this.telemetry.extractTotal.add(1, {
outcome: "success",
format: attrs.format,
});
return result;
} catch (error) {
this.telemetry.extractDuration.record(performance.now() - start, {
...attrs,
outcome: "failure",
});
this.telemetry.extractTotal.add(1, {
outcome: "failure",
format: attrs.format,
});
throw error;
}
}
/** Extract one thumbnail per supplied timestamp. */
public async generateMultiple(
inputPath: string,
timestamps: readonly string[],
signal?: AbortSignal
): Promise<ThumbnailResult[]> {
const results: ThumbnailResult[] = [];
for (const timestamp of timestamps) {
throwIfAborted(signal);
results.push(await this.generate(inputPath, { timestamp }, signal));
}
return results;
}
/** Extract `count` thumbnails evenly distributed across the given duration. */
public async extractSlidingWindow(
inputPath: string,
count: number,
durationSeconds: number,
signal?: AbortSignal
): Promise<ThumbnailResult[]> {
if (
!Number.isFinite(count) ||
!Number.isInteger(count) ||
count <= 0 ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
throw new VideoThumbnailsError(
VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
"videoThumbnails.errors.invalidDimensions",
{
context: { count, durationSeconds },
}
);
}
const timestamps = buildEvenlySpacedTimestamps(count, durationSeconds);
return this.generateMultiple(inputPath, timestamps, signal);
}
/** Probe the video for metadata via ffprobe. */
public async getMetadata(
inputPath: string,
signal?: AbortSignal
): Promise<VideoMetadata> {
throwIfAborted(signal);
return this.runner.probe(inputPath, signal);
}
/** Whether the configured runner reports ffmpeg available. */
public async isAvailable(signal?: AbortSignal): Promise<boolean> {
return this.runner.isAvailable(signal);
}
/**
* Assert ffmpeg is available via the configured runner; throw
* `VIDEO_THUMBNAILS.RUNNER_UNAVAILABLE` otherwise. Useful as an early-fail
* guard before kicking off an expensive batch.
*/
public async ensureAvailable(signal?: AbortSignal): Promise<void> {
throwIfAborted(signal);
if (!(await this.runner.isAvailable(signal))) {
throw new VideoThumbnailsError(
VIDEO_THUMBNAILS_CODES.RUNNER_UNAVAILABLE,
"videoThumbnails.errors.runnerUnavailable",
{ context: { reason: "isAvailable-returned-false" } }
);
}
}
private async runOne(
inputPath: string,
options: ThumbnailOptions,
signal: AbortSignal | undefined
): Promise<ThumbnailResult> {
const ext = FORMAT_EXTENSIONS[this.config.format];
const filename = `thumb_${options.timestamp.replace(/[:.]/g, "-")}.${ext}`;
const outputPath = join(this.config.outputDir, filename);
await this.runner.extract(
{
inputPath,
outputPath,
timestamp: options.timestamp,
width: this.config.width,
height: this.config.height,
fit: options.fit,
format: this.config.format,
quality: this.config.quality,
},
signal
);
const fileStat = await stat(outputPath);
return {
outputPath,
width: this.config.width,
height: this.config.height,
format: this.config.format,
sizeBytes: fileStat.size,
timestamp: options.timestamp,
};
}
}
function parseConfig(input: ThumbnailConfigInput): ThumbnailConfig {
const parsed = ThumbnailConfigSchema.safeParse(input);
if (parsed.success) {
return parsed.data;
}
throw translateZod(parsed.error, classifyConfigError(parsed.error));
}
function parseOptions(
input: ThumbnailOptionsInput | undefined
): ThumbnailOptions {
const parsed = ThumbnailOptionsSchema.safeParse(input ?? {});
if (parsed.success) {
return parsed.data;
}
const issue = parsed.error.issues[0];
if (issue?.path[0] === "timestamp") {
throw new VideoThumbnailsError(
VIDEO_THUMBNAILS_CODES.INVALID_TIMESTAMP,
"videoThumbnails.errors.invalidTimestamp",
{ cause: parsed.error, context: { issues: parsed.error.issues } }
);
}
throw translateZod(parsed.error, VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS);
}
function classifyConfigError(
error: ZodError
):
| typeof VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS
| typeof VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED
| typeof VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS {
for (const issue of error.issues) {
const key = issue.path[0];
if (key === "width" || key === "height") {
return VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS;
}
if (key === "format") {
return VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED;
}
}
return VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS;
}
function translateZod(
error: ZodError,
code:
| typeof VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS
| typeof VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS
| typeof VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED
): VideoThumbnailsError {
return new VideoThumbnailsError(
code,
"videoThumbnails.errors.invalidOptions",
{
cause: error,
context: { issues: error.issues },
}
);
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new VideoThumbnailsError(
VIDEO_THUMBNAILS_CODES.ABORTED,
"videoThumbnails.errors.aborted",
{ context: { reason: String(signal.reason) } }
);
}
}
/** Build `count` evenly-spaced `HH:MM:SS.mmm` timestamps across `durationSeconds`. */
export function buildEvenlySpacedTimestamps(
count: number,
durationSeconds: number
): string[] {
const slot = durationSeconds / (count + 1);
const out: string[] = [];
for (let index = 1; index <= count; index += 1) {
out.push(formatTimestamp(slot * index));
}
return out;
}
function formatTimestamp(seconds: number): string {
const ms = Math.max(0, Math.round(seconds * 1000));
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.floor((ms % 3_600_000) / 60_000);
const secs = Math.floor((ms % 60_000) / 1000);
const millis = ms % 1000;
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(secs, 2)}.${pad(millis, 3)}`;
}
function pad(value: number, width: number): string {
return value.toString().padStart(width, "0");
}

View File

@@ -0,0 +1,21 @@
import type {
ThumbnailConfig,
ThumbnailOptions,
ThumbnailResult,
VideoMetadata,
} from "../schemas/index.js";
export type {
ThumbnailConfig,
ThumbnailOptions,
ThumbnailResult,
VideoMetadata,
};
/** Optional partial `ThumbnailConfig` accepted by callers (defaults applied at parse). */
export type ThumbnailConfigInput = Partial<ThumbnailConfig> & {
outputDir: string;
};
/** Optional partial `ThumbnailOptions` accepted by callers. */
export type ThumbnailOptionsInput = Partial<ThumbnailOptions>;

View File

@@ -0,0 +1,88 @@
import { spawn } from "node:child_process";
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import ffmpegInstaller from "@ffmpeg-installer/ffmpeg";
/** Path of the on-the-fly fixture video. Cached per Vitest worker. */
let fixturePath: string | undefined;
let fixtureDir: string | undefined;
/**
* Synthesise a 5-second 320×240 colour-gradient MP4 once per worker and return
* its absolute path. Subsequent calls return the cached path. Uses ffmpeg's
* `lavfi` virtual source so no checked-in binary is required.
*/
export async function getFixtureVideo(): Promise<string> {
if (fixturePath !== undefined) {
return fixturePath;
}
const dir = await mkdtemp(join(tmpdir(), "vt-fixture-"));
const out = join(dir, "fixture.mp4");
await new Promise<void>((resolve, reject) => {
const child = spawn(
ffmpegInstaller.path,
[
"-y",
"-f",
"lavfi",
"-i",
"testsrc=duration=5:size=320x240:rate=10",
"-pix_fmt",
"yuv420p",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-tune",
"zerolatency",
out,
],
{ stdio: "pipe" }
);
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`ffmpeg fixture build failed: ${stderr}`));
}
});
});
const fileStat = await stat(out);
if (fileStat.size === 0) {
throw new Error("fixture video has zero bytes");
}
fixtureDir = dir;
fixturePath = out;
return out;
}
/** Create a unique tmp output directory for one test. */
export async function makeOutputDir(label: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), `vt-${label}-`));
await mkdir(dir, { recursive: true });
return dir;
}
/** Best-effort cleanup; safe to ignore failures (Windows / locked files). */
export async function safeRm(path: string): Promise<void> {
try {
await rm(path, { recursive: true, force: true });
} catch {
// ignored
}
}
/** Clear the cached fixture (used by setup teardown). */
export async function clearFixtureCache(): Promise<void> {
if (fixtureDir !== undefined) {
await safeRm(fixtureDir);
}
fixturePath = undefined;
fixtureDir = undefined;
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/video-thumbnails",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@ffmpeg-installer/ffmpeg",
"fluent-ffmpeg",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 60000,
hookTimeout: 60000,
},
});