first commit
This commit is contained in:
85
docs/media/video/video-thumbnails/README.md
Normal file
85
docs/media/video/video-thumbnails/README.md
Normal 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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
91
docs/media/video/video-thumbnails/src/runner/runner.md
Normal file
91
docs/media/video/video-thumbnails/src/runner/runner.md
Normal 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.
|
||||
76
docs/media/video/video-thumbnails/src/schemas/schemas.md
Normal file
76
docs/media/video/video-thumbnails/src/schemas/schemas.md
Normal 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.
|
||||
@@ -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.
|
||||
83
docs/media/video/video-thumbnails/src/telemetry/telemetry.md
Normal file
83
docs/media/video/video-thumbnails/src/telemetry/telemetry.md
Normal 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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user