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,14 @@
# `video`
Video encoding, decoding, and thumbnail generation.
<!-- begin:auto-docs -->
## Packages in `video`
2 packages.
| Package | Description |
|---|---|
| [`@suaveplan/video`](./video/README.md) | Universal video metadata extraction, magic-byte format detection, resolution math, transform-pipeline planning, and a fluent pipeline builder. Zero browse… |
| [`@suaveplan/video-thumbnails`](./video-thumbnails/README.md) | Video thumbnail extraction and sprite-sheet generation backed by an injectable `FfmpegRunner`. The default runner — `FluentFfmpegRunner` — wraps `fluent-ffmpeg… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Video encoding, decoding, and thumbnail generation.

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,
},
});

View File

@@ -0,0 +1,109 @@
# @suaveplan/video - Universal video metadata extraction, format detection, resolution math, transform pipeline planning, and a fluent pipeline builder
Universal video metadata extraction, magic-byte format detection,
resolution math, transform-pipeline planning, and a fluent pipeline
builder. Zero browser-only or Node-only imports — runs identically in
Bun, Node, and modern browsers.
The package owns the **planning** half of the video stack: it sniffs
container format from header bytes, validates user-supplied
`VideoMetadata`, walks a `VideoTransformPipeline` and projects the
output shape (dimensions, duration, format, audio presence). Actual
codec work (transcoding, frame extraction, thumbnail render) is
delegated to siblings — `@suaveplan/video-thumbnails` (ffmpeg) and
`@suaveplan/react-video` (browser playback).
<!-- begin:auto-docs -->
## Documentation tree
- [detection](./src/detection/detection.md) — `detectVideoFormat` is a zero-dependency byte-pattern sniffer that returns the container format of a video buffer based…
- [detection](./src/detection/detection.md) — `detectVideoFormat` is a zero-dependency byte-pattern sniffer that returns the container format of a video buffer based…
- [duration](./src/duration/duration.md) — This module owns the two-way conversion between a "seconds" number and a human-readable `HH:MM:SS` string. The pair is…
- [duration](./src/duration/duration.md) — This module owns the two-way conversion between a "seconds" number and a human-readable `HH:MM:SS` string. The pair is…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable, namespaced error-code registry for `@suaveplan/video`. Every failure thrown by the package…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable, namespaced error-code registry for `@suaveplan/video`. Every failure thrown by the package…
- [errors](./src/errors/errors.md) — This module exposes the typed `VideoError` class and the 16 factory functions every consumer of `@suaveplan/video` uses…
- [errors](./src/errors/errors.md) — This module exposes the typed `VideoError` class and the 16 factory functions every consumer of `@suaveplan/video` uses…
- [format](./src/format/format.md) — `videoFormatToExtension` is a pure lookup that returns the canonical file-extension (including the leading dot) for a s…
- [format](./src/format/format.md) — `videoFormatToExtension` is a pure lookup that returns the canonical file-extension (including the leading dot) for a s…
- [instruments](./src/instruments/instruments.md) — This module owns the OpenTelemetry instruments that `VideoProcessor` emits on every invocation. The instruments are laz…
- [instruments](./src/instruments/instruments.md) — This module owns the OpenTelemetry instruments that `VideoProcessor` emits on every invocation. The instruments are laz…
- [pipeline-builder](./src/pipeline-builder/pipeline-builder.md) — `VideoPipelineBuilder` is the fluent surface for assembling a `VideoTransformPipeline` without having to construct the…
- [pipeline-builder](./src/pipeline-builder/pipeline-builder.md) — `VideoPipelineBuilder` is the fluent surface for assembling a `VideoTransformPipeline` without having to construct the…
- [processor](./src/processor/processor.md) — `VideoProcessor` is the orchestration class for `@suaveplan/video`. It owns two operations — `extractMetadata` (read a…
- [processor](./src/processor/processor.md) — `VideoProcessor` is the orchestration class for `@suaveplan/video`. It owns two operations — `extractMetadata` (read a…
- [resize](./src/resize/resize.md) — `calculateVideoResize` is the pure-math helper used by `VideoProcessor.planTransform` to project a `resize` operation o…
- [resize](./src/resize/resize.md) — `calculateVideoResize` is the pure-math helper used by `VideoProcessor.planTransform` to project a `resize` operation o…
- [trim](./src/trim/trim.md) — `validateTrimRange` is the single source of truth for "does this trim range make sense for a video of length `duration`…
- [trim](./src/trim/trim.md) — `validateTrimRange` is the single source of truth for "does this trim range make sense for a video of length `duration`…
- [video-types](./src/types/video-types/video-types.md) — This module is the type foundation for the entire `@suaveplan/video` package. Every public surface — `VideoProcessor`,…
- [video-types](./src/types/video-types/video-types.md) — This module is the type foundation for the entire `@suaveplan/video` package. Every public surface — `VideoProcessor`,…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/video
```
## Quick start
```ts
import { VideoProcessor } from "@suaveplan/video";
const proc = new VideoProcessor();
const meta = proc.extractMetadata(headerBytes, {
width: 1920,
height: 1080,
duration: 60,
frameRate: 30,
});
const result = proc
.createPipeline()
.resize({ preset: "720p" })
.toFormat("webm")
.codec("vp9")
.apply(meta, proc);
console.log(result); // { width: 1280, height: 720, format: "webm", duration: 60, ... }
```
## Features
- 8-format magic-byte sniffer (MP4 / MOV / MKV / AVI / FLV / OGG plus
caller-hint paths for WMV / WEBM).
- 6-preset `RESOLUTION_PRESETS` and 8-entry `VIDEO_MIME_TYPES` lookup.
- 9-variant `VideoTransformOp` Zod discriminated union.
- Even-dimension-rounded `calculateVideoResize` math.
- Trim-range validator + duration round-trip string helpers.
- `VideoProcessor` + `VideoPipelineBuilder` with `EventBus`-backed
`on` / `once` / `off`.
- 16-code stable `VIDEO_CODES` registry plus `VIDEO_ERROR_METADATA`
HTTP/severity/retry table.
- OpenTelemetry counters + histograms + spans on every public method.
- `AbortSignal` propagation on every public method.
## Modules
- [error-codes](./src/error-codes/error-codes.md) — `VIDEO_CODES`,
`VIDEO_ERROR_METADATA`, `registerVideoErrorCodes`.
- [errors](./src/errors/errors.md) — `VideoError` + 16 factory functions.
- [types/video-types](./src/types/video-types/video-types.md) — Zod
schemas, inferred types, lookup tables.
- [detection](./src/detection/detection.md) — `detectVideoFormat`.
- [resize](./src/resize/resize.md) — `calculateVideoResize`.
- [trim](./src/trim/trim.md) — `validateTrimRange`.
- [duration](./src/duration/duration.md) — `formatDuration`,
`parseDuration`.
- [format](./src/format/format.md) — `videoFormatToExtension`.
- [instruments](./src/instruments/instruments.md) — OTel counters,
histograms, tracer.
- [processor](./src/processor/processor.md) — `VideoProcessor`.
- [pipeline-builder](./src/pipeline-builder/pipeline-builder.md) —
`VideoPipelineBuilder`.
## License
MIT

View File

@@ -0,0 +1,172 @@
{
"name": "@suaveplan/video",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal video metadata extraction, format detection, resolution math, transform pipeline planning, and a fluent pipeline builder",
"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"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
},
"require": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./errors/errors": {
"import": {
"types": "./dist/errors/errors.d.ts",
"default": "./dist/errors/errors.js"
},
"require": {
"types": "./dist/errors/errors.d.ts",
"default": "./dist/errors/errors.js"
}
},
"./types/video-types": {
"import": {
"types": "./dist/types/video-types.d.ts",
"default": "./dist/types/video-types.js"
},
"require": {
"types": "./dist/types/video-types.d.ts",
"default": "./dist/types/video-types.js"
}
},
"./detection/detection": {
"import": {
"types": "./dist/detection/detection.d.ts",
"default": "./dist/detection/detection.js"
},
"require": {
"types": "./dist/detection/detection.d.ts",
"default": "./dist/detection/detection.js"
}
},
"./resize/resize": {
"import": {
"types": "./dist/resize/resize.d.ts",
"default": "./dist/resize/resize.js"
},
"require": {
"types": "./dist/resize/resize.d.ts",
"default": "./dist/resize/resize.js"
}
},
"./trim/trim": {
"import": {
"types": "./dist/trim/trim.d.ts",
"default": "./dist/trim/trim.js"
},
"require": {
"types": "./dist/trim/trim.d.ts",
"default": "./dist/trim/trim.js"
}
},
"./duration/duration": {
"import": {
"types": "./dist/duration/duration.d.ts",
"default": "./dist/duration/duration.js"
},
"require": {
"types": "./dist/duration/duration.d.ts",
"default": "./dist/duration/duration.js"
}
},
"./format/format": {
"import": {
"types": "./dist/format/format.d.ts",
"default": "./dist/format/format.js"
},
"require": {
"types": "./dist/format/format.d.ts",
"default": "./dist/format/format.js"
}
},
"./instruments/instruments": {
"import": {
"types": "./dist/instruments/instruments.d.ts",
"default": "./dist/instruments/instruments.js"
},
"require": {
"types": "./dist/instruments/instruments.d.ts",
"default": "./dist/instruments/instruments.js"
}
},
"./processor/processor": {
"import": {
"types": "./dist/processor/processor.d.ts",
"default": "./dist/processor/processor.js"
},
"require": {
"types": "./dist/processor/processor.d.ts",
"default": "./dist/processor/processor.js"
}
},
"./pipeline-builder/pipeline-builder": {
"import": {
"types": "./dist/pipeline-builder/pipeline-builder.d.ts",
"default": "./dist/pipeline-builder/pipeline-builder.js"
},
"require": {
"types": "./dist/pipeline-builder/pipeline-builder.d.ts",
"default": "./dist/pipeline-builder/pipeline-builder.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": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@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,78 @@
# `detection` — magic-byte video format sniffer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`detectVideoFormat` is a zero-dependency byte-pattern sniffer that returns
the container format of a video buffer based on its leading header bytes.
It runs entirely on `Uint8Array`, never allocates, and is safe to call
inside hot paths (per-request validation, drag-and-drop pre-checks, upload
gatekeepers). The function is the universal counterpart to MIME-type
sniffing: it relies only on bytes-on-the-wire, never on the (often
spoofable) `Content-Type` header.
preserves the upstream sniffer byte-for-byte so consumers porting
from the legacy package see identical detection results. The supported
formats are MP4 (ISO Base Media `ftyp`), MOV (`ftyp` with `qt` brand),
Matroska / WebM (EBML `1A 45 DF A3`), RIFF AVI (`RIFF…AVI `), Adobe FLV
(`FLV`), and Ogg (`OggS`). Anything else returns `null` and the caller is
expected to surface `VideoError(VIDEO.UNKNOWN_FORMAT)` or accept a
caller-supplied format hint.
## Features
- Six container signatures: MP4, MOV, MKV, AVI, FLV, OGG.
- Returns `null` for buffers shorter than 12 bytes (insufficient signal).
- Pure function — no allocations, no IO, fully tree-shakeable.
## Usage: Basic
```ts
import { detectVideoFormat } from "@suaveplan/video/detection/detection";
const head = new Uint8Array(16);
head.set([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70]);
head.set([0x69, 0x73, 0x6f, 0x6d], 8);
console.log(detectVideoFormat(head)); // "mp4"
```
## Usage: Advanced
```ts
import { detectVideoFormat } from "@suaveplan/video/detection/detection";
import { videoFormatUnsupported } from "@suaveplan/video/errors/errors";
async function gate(file: File) {
const head = new Uint8Array(await file.slice(0, 32).arrayBuffer());
const format = detectVideoFormat(head);
if (!format) throw videoFormatUnsupported(file.type ?? "unknown");
return format;
}
```
## API Reference
- `detectVideoFormat(header: Uint8Array): VideoFormat | null` — returns
`"mp4" | "mov" | "mkv" | "avi" | "flv" | "ogg"` or `null` for unknown /
short input.
## Implementation Notes
The sniffer reads at most 12 bytes from the supplied buffer; callers can
pass the full file or any prefix `>= 12` bytes. The MOV branch is checked
inside the MP4 branch because both formats share the ISO Base Media
`ftyp` atom — the major-brand bytes 8..11 disambiguate. The function
returns `null` rather than throwing because format detection is a soft
signal: callers typically combine it with caller-supplied metadata
(`knownMetadata?.format`) inside `VideoProcessor.extractMetadata`, and a
hard throw would force every upstream call site to wrap in `try / catch`.
WebM is not detected separately because both WebM and full Matroska share
the EBML magic bytes; the `mkv` return covers both. Consumers needing to
distinguish WebM from MKV must inspect the EBML `DocType` element further
into the stream (out of scope for this package — typically delegated to
the ffmpeg-backed thumbnail sibling).

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { detectVideoFormat } from "./detection.js";
function header(...prefix: number[]): Uint8Array {
const buf = new Uint8Array(16);
buf.set(prefix);
return buf;
}
describe("detectVideoFormat", () => {
it("detects MP4 (ftyp)", { timeout: 5000 }, () => {
const h = header(0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70);
h.set([0x69, 0x73, 0x6f, 0x6d], 8);
expect(detectVideoFormat(h)).toBe("mp4");
});
it("detects MOV (qt brand)", { timeout: 5000 }, () => {
const h = header(0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70);
h.set([0x71, 0x74, 0x20, 0x20], 8);
expect(detectVideoFormat(h)).toBe("mov");
});
it("detects MKV", { timeout: 5000 }, () => {
expect(detectVideoFormat(header(0x1a, 0x45, 0xdf, 0xa3))).toBe("mkv");
});
it("detects AVI", { timeout: 5000 }, () => {
const h = header(0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00);
h.set([0x41, 0x56, 0x49, 0x20], 8);
expect(detectVideoFormat(h)).toBe("avi");
});
it("detects FLV", { timeout: 5000 }, () => {
expect(detectVideoFormat(header(0x46, 0x4c, 0x56, 0x01))).toBe("flv");
});
it("detects OGG", { timeout: 5000 }, () => {
expect(detectVideoFormat(header(0x4f, 0x67, 0x67, 0x53))).toBe("ogg");
});
it("returns null for unknown content", { timeout: 5000 }, () => {
expect(
detectVideoFormat(
new Uint8Array([
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09,
0x0a, 0x0b,
])
)
).toBeNull();
});
it("returns null for short header", { timeout: 5000 }, () => {
expect(detectVideoFormat(new Uint8Array([0x00, 0x01]))).toBeNull();
});
it("RIFF without AVI tag is unknown", { timeout: 5000 }, () => {
const h = header(0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00);
// WAVE — RIFF but not AVI.
h.set([0x57, 0x41, 0x56, 0x45], 8);
expect(detectVideoFormat(h)).toBeNull();
});
it("ftyp with bare 'q' (no second t) is mp4 not mov", {
timeout: 5000,
}, () => {
const h = header(0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70);
h.set([0x71, 0x00, 0x20, 0x20], 8);
// brand starts with "q\0" — startsWith("qt") fails so we get mp4.
expect(detectVideoFormat(h)).toBe("mp4");
});
});

View File

@@ -0,0 +1,69 @@
import type { VideoFormat } from "../types/video-types/video-types.js";
/**
* Detect the video container format from the magic bytes at the start of a
* buffer. Returns `null` when no known signature matches or the buffer is
* shorter than 12 bytes.
*/
export function detectVideoFormat(header: Uint8Array): VideoFormat | null {
if (header.length < 12) return null;
// ISO Base Media (MP4 / MOV) — bytes 4..7 must be "ftyp".
if (
header[4] === 0x66 &&
header[5] === 0x74 &&
header[6] === 0x79 &&
header[7] === 0x70
) {
// Length check above guarantees header[8..11] are defined.
const brand = String.fromCharCode(
header[8] as number,
header[9] as number,
header[10] as number,
header[11] as number
);
if (brand.startsWith("qt")) return "mov";
return "mp4";
}
// Matroska / WebM EBML header.
if (
header[0] === 0x1a &&
header[1] === 0x45 &&
header[2] === 0xdf &&
header[3] === 0xa3
) {
return "mkv";
}
// RIFF AVI — "RIFF" + four-byte size + "AVI ".
if (
header[0] === 0x52 &&
header[1] === 0x49 &&
header[2] === 0x46 &&
header[3] === 0x46 &&
header[8] === 0x41 &&
header[9] === 0x56 &&
header[10] === 0x49 &&
header[11] === 0x20
) {
return "avi";
}
// Adobe FLV — first three bytes are "FLV".
if (header[0] === 0x46 && header[1] === 0x4c && header[2] === 0x56) {
return "flv";
}
// Ogg "OggS".
if (
header[0] === 0x4f &&
header[1] === 0x67 &&
header[2] === 0x67 &&
header[3] === 0x53
) {
return "ogg";
}
return null;
}

View File

@@ -0,0 +1 @@
export { detectVideoFormat } from "./detection.js";

View File

@@ -0,0 +1,73 @@
# `duration` — duration string round-trip
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the two-way conversion between a "seconds" number and a
human-readable `HH:MM:SS` string. The pair is used in CLI output, log
messages, error messages, and form-field rendering — anywhere a numeric
duration would be more legible as a clock-style string. The pair is an
**exact** round-trip on integer-second inputs: `parseDuration(formatDuration(s))
=== s` for every `s ∈ ℕ`.
`formatDuration` always renders three colon-separated fields, even when
the duration is shorter than a minute or an hour. This makes the output
column-stable and trivial to align in logs. `parseDuration` accepts any
of `SS`, `MM:SS`, or `HH:MM:SS` so users can type the shortest form they
like and still get a numeric result.
## Features
- `formatDuration` always emits `HH:MM:SS` with zero-padded fields.
- `parseDuration` accepts 1-field, 2-field, or 3-field input.
- Round-trip-stable on integer-second inputs.
## Usage: Basic
```ts
import { formatDuration, parseDuration } from "@suaveplan/video/duration/duration";
console.log(formatDuration(125)); // "00:02:05"
console.log(parseDuration("02:30")); // 150
console.log(parseDuration(formatDuration(3661))); // 3661
```
## Usage: Advanced
```ts
import { formatDuration, parseDuration } from "@suaveplan/video/duration/duration";
function clampToInputAndRender(raw: string, max: number) {
const seconds = Math.min(parseDuration(raw), max);
return { seconds, display: formatDuration(seconds) };
}
console.log(clampToInputAndRender("99:99:99", 60));
// { seconds: 60, display: "00:01:00" }
```
## API Reference
- `formatDuration(seconds: number): string` — returns `"HH:MM:SS"`.
Fractional input is floored. Negative inputs produce nonsense output —
callers SHOULD pre-validate.
- `parseDuration(duration: string): number` — accepts `"SS"`, `"MM:SS"`,
or `"HH:MM:SS"`. Empty string returns `0`. Non-numeric components
return `NaN` (a deliberate signal — callers SHOULD pre-validate).
## Implementation Notes
`formatDuration` floors via `Math.floor` rather than `Math.round` so the
round-trip with `parseDuration` is exact for integer-second inputs. This
is the upstream behaviour preserved verbatim. For sub-second precision,
callers should multiply by 1000 and format separately — this module
deliberately stays in the integer-seconds domain.
`parseDuration`'s fallback (`return parts[0] ?? 0`) covers two cases:
the single-component form `"45"` (which returns `45`) and the empty
string (which splits to `[""]`, `Number("")` is `0`, so the function
returns `0`). Both are intentional. A malformed string like `"a:b:c"`
returns `NaN` for downstream callers to detect.

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { formatDuration, parseDuration } from "./duration.js";
describe("formatDuration", () => {
it("formats 0 seconds", { timeout: 5000 }, () => {
expect(formatDuration(0)).toBe("00:00:00");
});
it("formats seconds-only input", { timeout: 5000 }, () => {
expect(formatDuration(45)).toBe("00:00:45");
});
it("formats minutes and seconds", { timeout: 5000 }, () => {
expect(formatDuration(125)).toBe("00:02:05");
});
it("formats hours", { timeout: 5000 }, () => {
expect(formatDuration(3661)).toBe("01:01:01");
});
it("floors fractional seconds", { timeout: 5000 }, () => {
expect(formatDuration(125.9)).toBe("00:02:05");
});
});
describe("parseDuration", () => {
it("parses HH:MM:SS", { timeout: 5000 }, () => {
expect(parseDuration("01:30:00")).toBe(5400);
});
it("parses MM:SS", { timeout: 5000 }, () => {
expect(parseDuration("02:30")).toBe(150);
});
it("parses seconds only", { timeout: 5000 }, () => {
expect(parseDuration("45")).toBe(45);
});
it("empty string returns 0", { timeout: 5000 }, () => {
expect(parseDuration("")).toBe(0);
});
it("round-trips with formatDuration", { timeout: 5000 }, () => {
expect(parseDuration(formatDuration(3661))).toBe(3661);
});
});

View File

@@ -0,0 +1,38 @@
/**
* Format a non-negative seconds value as zero-padded `HH:MM:SS`.
*
* Fractional seconds are floored.
*/
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return [
h.toString().padStart(2, "0"),
m.toString().padStart(2, "0"),
s.toString().padStart(2, "0"),
].join(":");
}
/**
* Parse a duration string into a seconds total.
*
* Accepts `SS`, `MM:SS`, or `HH:MM:SS` forms. An empty string returns `0`.
* Components are parsed via `Number`, so non-numeric input yields `NaN`
* propagation — callers SHOULD pre-validate before passing untrusted input.
*/
export function parseDuration(duration: string): number {
const parts = duration.split(":").map(Number);
// split returns >=1 element; index access widths are guaranteed by length checks.
if (parts.length === 3) {
return (
(parts[0] as number) * 3600 +
(parts[1] as number) * 60 +
(parts[2] as number)
);
}
if (parts.length === 2) {
return (parts[0] as number) * 60 + (parts[1] as number);
}
return parts[0] as number;
}

View File

@@ -0,0 +1 @@
export { formatDuration, parseDuration } from "./duration.js";

View File

@@ -0,0 +1,96 @@
# `error-codes` — `VIDEO_CODES` registry and metadata table
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable, namespaced error-code registry for
`@suaveplan/video`. Every failure thrown by the package — from the
`VideoProcessor.extractMetadata` magic-byte sniffer up to the
`VideoPipelineBuilder` validation guards — discriminates on one of these
codes. The registry is minted via `defineCodes("VIDEO", […])` from
`@suaveplan/error`, so each code is namespaced (`VIDEO.DECODE_FAILED` rather
than the bare `DECODE_FAILED`) and is automatically picked up by the
error infrastructure for redaction, OTel span events, and i18n message
resolution.
A separate `VIDEO_ERROR_METADATA` table mirrors the upstream metadata
contract: every code is described by its canonical `httpStatus`, `severity`,
`retryable` flag (with an optional `retry` policy carrying `strategy` and
`maxAttempts`), `userMessageKey`, `category`, and prose `description`. This
table is the source of truth for problem+json HTTP responders, retry
wrappers, and i18n catalogs — consumers can iterate the registry and emit
matching mappings without re-implementing the metadata.
## Features
- 16-entry stable `VIDEO_CODES` registry minted via `defineCodes`.
- Typed `VideoCode` union derived from the registry — switch-exhaustive.
- `VideoCodeMetadata` interface covering HTTP status, severity, retry
policy, i18n key, category, and description.
- `VIDEO_ERROR_METADATA` table — one entry per code, all six fields filled.
- `registerVideoErrorCodes()` callable kept as a compatibility alias —
returns the metadata table, performs no side effects.
## Usage: Basic
```ts
import { VIDEO_CODES, type VideoCode } from "@suaveplan/video/error-codes/error-codes";
function isVideoFailure(code: string): code is VideoCode {
return Object.values(VIDEO_CODES).includes(code as VideoCode);
}
console.log(VIDEO_CODES.DECODE_FAILED); // "VIDEO.DECODE_FAILED"
```
## Usage: Advanced
```ts
import {
VIDEO_CODES,
VIDEO_ERROR_METADATA,
registerVideoErrorCodes,
} from "@suaveplan/video/error-codes/error-codes";
// Build a problem+json mapper from the metadata table.
function toProblemJson(code: keyof typeof VIDEO_ERROR_METADATA, detail: string) {
const meta = VIDEO_ERROR_METADATA[code];
return {
type: `urn:suaveplan:video:${code}`,
title: meta.userMessageKey,
status: meta.httpStatus,
detail,
category: meta.category,
};
}
// The compat alias is still callable for upstream code.
const table = registerVideoErrorCodes();
console.log(Object.keys(table).length); // 16
```
## API Reference
- `VIDEO_CODES` — `Readonly<Record<key, "VIDEO.<key>">>` minted via
`defineCodes`.
- `VideoCode` — `(typeof VIDEO_CODES)[keyof typeof VIDEO_CODES]`.
- `VideoCodeSeverity` — `"warning" | "error"`.
- `VideoCodeRetryPolicy` — `{ strategy: "exponential"; maxAttempts: number }`.
- `VideoCodeMetadata` — six readonly fields plus an optional `retry` policy.
- `VIDEO_ERROR_METADATA` — `Readonly<Record<VideoCode, VideoCodeMetadata>>`.
- `registerVideoErrorCodes()` — returns `VIDEO_ERROR_METADATA`. Throws
nothing, no side effects.
## Implementation Notes
The registry uses `defineCodes("VIDEO", […])` rather than the deprecated
`ErrorCodes.registerMany` pattern: namespacing happens at mint time, the
registry value is module-local, and there is no global mutable singleton to
re-register. The metadata table is exported separately so consumers can opt
into the HTTP/retry mapping without paying for it when they do not need it
(both modules ship with `sideEffects: false`). `TIMEOUT` and `ENCODE_FAILED`
are the only codes whose metadata carries a `retry` policy — they map to the
"may succeed on retry" semantics in the upstream contract.

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
registerVideoErrorCodes,
VIDEO_CODES,
VIDEO_ERROR_METADATA,
} from "./error-codes.js";
describe("VIDEO_CODES", () => {
it("namespaces every code under VIDEO.*", { timeout: 5000 }, () => {
for (const value of Object.values(VIDEO_CODES)) {
expect(value.startsWith("VIDEO.")).toBe(true);
}
});
it("VIDEO_CODES covers every upstream failure mode", {
timeout: 5000,
}, () => {
const required = [
"UNKNOWN_FORMAT",
"INVALID_DIMENSIONS",
"INVALID_TRIM_RANGE",
"METADATA_PARSE_FAILED",
"INVALID_TRANSFORM",
"DURATION_EXCEEDED",
"UNSUPPORTED_CODEC",
"NOT_FOUND",
"DECODE_FAILED",
"ENCODE_FAILED",
"FORMAT_UNSUPPORTED",
"TOO_LARGE",
"PROCESSING_FAILED",
"CODEC_UNSUPPORTED",
"TIMEOUT",
"CONFIG_INVALID",
];
for (const key of required) {
expect(VIDEO_CODES).toHaveProperty(key);
}
});
});
describe("VIDEO_ERROR_METADATA", () => {
it("VIDEO_ERROR_METADATA mirrors VIDEO_CODES", { timeout: 5000 }, () => {
const codes = Object.values(VIDEO_CODES);
const entries = Object.keys(
VIDEO_ERROR_METADATA
) as (keyof typeof VIDEO_ERROR_METADATA)[];
expect(entries.length).toBe(codes.length);
for (const code of codes) {
expect(VIDEO_ERROR_METADATA).toHaveProperty(code);
}
});
it("every entry has the full metadata shape", { timeout: 5000 }, () => {
for (const meta of Object.values(VIDEO_ERROR_METADATA)) {
expect(typeof meta.httpStatus).toBe("number");
expect(
meta.severity === "warning" || meta.severity === "error"
).toBe(true);
expect(typeof meta.retryable).toBe("boolean");
expect(typeof meta.userMessageKey).toBe("string");
expect(typeof meta.category).toBe("string");
expect(typeof meta.description).toBe("string");
}
});
it("TIMEOUT and ENCODE_FAILED carry an exponential retry policy", {
timeout: 5000,
}, () => {
const tm = VIDEO_ERROR_METADATA[VIDEO_CODES.TIMEOUT];
const en = VIDEO_ERROR_METADATA[VIDEO_CODES.ENCODE_FAILED];
expect(tm.retry?.strategy).toBe("exponential");
expect(tm.retry?.maxAttempts).toBe(3);
expect(en.retry?.strategy).toBe("exponential");
expect(en.retry?.maxAttempts).toBe(3);
});
});
describe("registerVideoErrorCodes", () => {
it("returns the metadata table without side effects", {
timeout: 5000,
}, () => {
const a = registerVideoErrorCodes();
const b = registerVideoErrorCodes();
expect(a).toBe(VIDEO_ERROR_METADATA);
expect(b).toBe(VIDEO_ERROR_METADATA);
});
});

View File

@@ -0,0 +1,211 @@
import { defineCodes } from "@suaveplan/error";
/**
* Stable error code registry for `@suaveplan/video`.
*
* Codes are namespaced under `VIDEO` so structured-error consumers
* (problem+json mappers, retry policies, OTel span events) can discriminate
* by prefix.
*/
export const VIDEO_CODES = defineCodes("VIDEO", [
"UNKNOWN_FORMAT",
"INVALID_DIMENSIONS",
"INVALID_TRIM_RANGE",
"METADATA_PARSE_FAILED",
"INVALID_TRANSFORM",
"DURATION_EXCEEDED",
"UNSUPPORTED_CODEC",
"NOT_FOUND",
"DECODE_FAILED",
"ENCODE_FAILED",
"FORMAT_UNSUPPORTED",
"TOO_LARGE",
"PROCESSING_FAILED",
"CODEC_UNSUPPORTED",
"TIMEOUT",
"CONFIG_INVALID",
] as const);
export type VideoCode = (typeof VIDEO_CODES)[keyof typeof VIDEO_CODES];
/** Severity level for a video error code. */
export type VideoCodeSeverity = "warning" | "error";
/** Retry policy descriptor for a retryable code. */
export interface VideoCodeRetryPolicy {
readonly strategy: "exponential";
readonly maxAttempts: number;
}
/**
* Metadata describing how an HTTP layer, retry wrapper, or i18n surface
* should react to a particular video error code.
*
* Mirrors the upstream `VIDEO_ERROR_METADATA` table so downstream consumers
* can keep their problem+json / retry policy mappings intact.
*/
export interface VideoCodeMetadata {
readonly httpStatus: number;
readonly severity: VideoCodeSeverity;
readonly retryable: boolean;
readonly retry?: VideoCodeRetryPolicy;
readonly userMessageKey: string;
readonly category: string;
readonly description: string;
}
/** Table of every {@link VideoCode} mapped to its consumer-facing metadata. */
export const VIDEO_ERROR_METADATA: Readonly<
Record<VideoCode, VideoCodeMetadata>
> = {
[VIDEO_CODES.UNKNOWN_FORMAT]: {
httpStatus: 415,
severity: "warning",
retryable: false,
userMessageKey: "video.errors.unknownFormat",
category: "video.validation",
description:
"The supplied buffer does not match any known video format.",
},
[VIDEO_CODES.INVALID_DIMENSIONS]: {
httpStatus: 422,
severity: "warning",
retryable: false,
userMessageKey: "video.errors.invalidDimensions",
category: "video.validation",
description:
"The supplied width/height values are not valid for a video frame.",
},
[VIDEO_CODES.INVALID_TRIM_RANGE]: {
httpStatus: 422,
severity: "warning",
retryable: false,
userMessageKey: "video.errors.invalidTrimRange",
category: "video.validation",
description:
"The supplied trim range is empty, inverted, or out of bounds.",
},
[VIDEO_CODES.METADATA_PARSE_FAILED]: {
httpStatus: 422,
severity: "error",
retryable: false,
userMessageKey: "video.errors.metadataParseFailed",
category: "video.processing",
description: "Failed to parse video metadata from the supplied buffer.",
},
[VIDEO_CODES.INVALID_TRANSFORM]: {
httpStatus: 422,
severity: "warning",
retryable: false,
userMessageKey: "video.errors.invalidTransform",
category: "video.validation",
description:
"The supplied transform operation is malformed or unsupported.",
},
[VIDEO_CODES.DURATION_EXCEEDED]: {
httpStatus: 413,
severity: "warning",
retryable: false,
userMessageKey: "video.errors.durationExceeded",
category: "video.validation",
description: "The video duration exceeds the configured maximum.",
},
[VIDEO_CODES.UNSUPPORTED_CODEC]: {
httpStatus: 415,
severity: "warning",
retryable: false,
userMessageKey: "video.errors.unsupportedCodec",
category: "video.validation",
description: "The codec named in the pipeline is not supported.",
},
[VIDEO_CODES.NOT_FOUND]: {
httpStatus: 404,
severity: "warning",
retryable: false,
userMessageKey: "video.errors.notFound",
category: "video.storage",
description: "The requested video could not be found.",
},
[VIDEO_CODES.DECODE_FAILED]: {
httpStatus: 422,
severity: "error",
retryable: false,
userMessageKey: "video.errors.decodeFailed",
category: "video.processing",
description:
"The video could not be decoded — the file may be corrupt or use an unreadable encoding.",
},
[VIDEO_CODES.ENCODE_FAILED]: {
httpStatus: 500,
severity: "error",
retryable: true,
retry: { strategy: "exponential", maxAttempts: 3 },
userMessageKey: "video.errors.encodeFailed",
category: "video.processing",
description: "The video encode step failed due to an internal error.",
},
[VIDEO_CODES.FORMAT_UNSUPPORTED]: {
httpStatus: 415,
severity: "warning",
retryable: false,
userMessageKey: "video.errors.formatUnsupported",
category: "video.validation",
description: "The supplied video container format is not supported.",
},
[VIDEO_CODES.TOO_LARGE]: {
httpStatus: 413,
severity: "warning",
retryable: false,
userMessageKey: "video.errors.tooLarge",
category: "video.validation",
description:
"The video file or stream exceeds the permitted size limit.",
},
[VIDEO_CODES.PROCESSING_FAILED]: {
httpStatus: 422,
severity: "error",
retryable: false,
userMessageKey: "video.errors.processingFailed",
category: "video.processing",
description:
"A general processing error occurred while handling the video.",
},
[VIDEO_CODES.CODEC_UNSUPPORTED]: {
httpStatus: 415,
severity: "warning",
retryable: false,
userMessageKey: "video.errors.codecUnsupported",
category: "video.validation",
description: "The video codec is not supported by this processor.",
},
[VIDEO_CODES.TIMEOUT]: {
httpStatus: 504,
severity: "error",
retryable: true,
retry: { strategy: "exponential", maxAttempts: 3 },
userMessageKey: "video.errors.timeout",
category: "video.processing",
description:
"The video processing operation timed out; the operation may succeed on retry.",
},
[VIDEO_CODES.CONFIG_INVALID]: {
httpStatus: 500,
severity: "error",
retryable: false,
userMessageKey: "video.errors.configInvalid",
category: "video.configuration",
description:
"The VideoProcessor was constructed with an invalid configuration.",
},
};
/**
* Returns the {@link VIDEO_ERROR_METADATA} table.
*
* Kept as a callable alias so upstream consumers that previously invoked
* `registerVideoErrorCodes()` keep compiling. Genesis `defineCodes` is the
* authoritative registry — no side-effect registration is needed.
*/
export function registerVideoErrorCodes(): typeof VIDEO_ERROR_METADATA {
return VIDEO_ERROR_METADATA;
}

View File

@@ -0,0 +1,9 @@
export {
registerVideoErrorCodes,
VIDEO_CODES,
VIDEO_ERROR_METADATA,
type VideoCode,
type VideoCodeMetadata,
type VideoCodeRetryPolicy,
type VideoCodeSeverity,
} from "./error-codes.js";

View File

@@ -0,0 +1,109 @@
# `errors` — `VideoError` and the 16 factories
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module exposes the typed `VideoError` class and the 16 factory functions
every consumer of `@suaveplan/video` uses to construct errors. `VideoError`
extends `SuaveplanError<VideoCode>` from `@suaveplan/error` so it inherits
the foundation contract: cause chains, stable `code` discriminants,
i18n-friendly message resolution, span-event compatibility, and a typed
`context` field carrying structured hints. Every factory in the upstream
package is preserved with its original signature and message phrasing so
consumers porting from the legacy `@suaveplan/video` continue to compile
without code changes.
The factories form the canonical surface for the rest of the package —
`VideoProcessor`, `VideoPipelineBuilder`, and helper functions all raise
through these factories rather than constructing `VideoError` instances
directly. This keeps the codes, messages, and context fields consistent and
makes it trivial to grep for "where does code X come from" across the
codebase.
## Features
- `VideoError extends SuaveplanError<VideoCode>` — full base-class
surface including `code`, `message`, `cause`, `context`, `timestamp`.
- 15 upstream factory functions with identical signatures.
- One factory: `videoConfigInvalid(message)` raising
`VIDEO.CONFIG_INVALID`.
- `VideoErrorContext` type with the structured hint fields the upstream
package threaded through `context`.
## Usage: Basic
```ts
import { unknownVideoFormat } from "@suaveplan/video/errors/errors";
try {
// some pipeline step
} catch {
throw unknownVideoFormat();
}
```
## Usage: Advanced
```ts
import {
VideoError,
videoMetadataParseFailed,
videoTooLarge,
} from "@suaveplan/video/errors/errors";
function ensureSize(size: number, max: number) {
if (size > max) throw videoTooLarge(size, max);
}
function safeParse(data: Uint8Array, parser: (b: Uint8Array) => unknown) {
try {
return parser(data);
} catch (err) {
throw videoMetadataParseFailed(err instanceof Error ? err : undefined);
}
}
function handle(err: unknown) {
if (err instanceof VideoError) {
// err.code is the discriminant; err.context carries typed hints.
console.error(err.code, err.context, err.cause);
}
}
```
## API Reference
- `class VideoError extends SuaveplanError<VideoCode>` — constructor
`(code, message, options?)`.
- `interface VideoErrorContext extends Record<string, unknown>` — optional
hints: `format`, `codec`, `duration`, `width`, `height`, `fileSize`,
`maxSize`, `operationMs`.
- `interface VideoErrorOptions extends SuaveplanErrorOptions` — adds the
typed `context`.
- Factories: `unknownVideoFormat()`, `invalidVideoDimensions(w, h)`,
`invalidTrimRange(start, end)`, `videoMetadataParseFailed(cause?)`,
`invalidVideoTransform(message)`, `durationExceeded(d, max)`,
`unsupportedCodec(c)`, `videoNotFound(format?)`, `videoDecodeFailed(cause?)`,
`videoEncodeFailed(cause?)`, `videoFormatUnsupported(format)`,
`videoTooLarge(fileSize, maxSize)`, `videoProcessingFailed(message,
cause?)`, `videoCodecUnsupported(c)`, `videoTimeout(operationMs)`,
`videoConfigInvalid(message)`.
## Implementation Notes
`VideoError` does NOT redeclare a local `context` field — it relies on the
one already declared on `SuaveplanError`, which is typed as
`Readonly<Record<string, unknown>>` and to which `VideoErrorContext` is
assignable. This avoids the field-shadowing footgun seen in early
implementations of similar packages.
The `videoConfigInvalid` factory is the only entry; it
replaces the upstream pattern of conflating constructor-time validation
failures with generic `VIDEO_PROCESSING_FAILED` errors by giving them a
discrete `VIDEO.CONFIG_INVALID` code so retry policies and HTTP mappers can
distinguish "your config is wrong" (500, non-retryable) from "the pipeline
failed at runtime" (422). All other factories preserve the upstream
message phrasing verbatim so log-scrapers and i18n catalogs keep matching.

View File

@@ -0,0 +1,144 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VIDEO_CODES } from "../error-codes/error-codes.js";
import {
durationExceeded,
invalidTrimRange,
invalidVideoDimensions,
invalidVideoTransform,
unknownVideoFormat,
unsupportedCodec,
VideoError,
videoCodecUnsupported,
videoConfigInvalid,
videoDecodeFailed,
videoEncodeFailed,
videoFormatUnsupported,
videoMetadataParseFailed,
videoNotFound,
videoProcessingFailed,
videoTimeout,
videoTooLarge,
} from "./errors.js";
describe("VideoError", () => {
it("VideoError carries code, message, cause, and context", {
timeout: 5000,
}, () => {
const cause = new Error("root");
const err = new VideoError(VIDEO_CODES.METADATA_PARSE_FAILED, "wrap", {
context: { duration: 9 },
cause,
});
expect(err.code).toBe(VIDEO_CODES.METADATA_PARSE_FAILED);
expect(err.message).toBe("wrap");
expect(err.cause).toBe(cause);
expect(err.context?.duration).toBe(9);
expect(err).toBeInstanceOf(Error);
});
it("VideoError omits context when none supplied", { timeout: 5000 }, () => {
const err = new VideoError(VIDEO_CODES.NOT_FOUND, "nope");
expect(err.context).toBeUndefined();
});
});
describe("error factories", () => {
it("every upstream factory is exported with matching shape", {
timeout: 5000,
}, () => {
const cases: Array<[() => VideoError, string]> = [
[() => unknownVideoFormat(), VIDEO_CODES.UNKNOWN_FORMAT],
[
() => invalidVideoDimensions(-1, 0),
VIDEO_CODES.INVALID_DIMENSIONS,
],
[() => invalidTrimRange(10, 5), VIDEO_CODES.INVALID_TRIM_RANGE],
[
() => videoMetadataParseFailed(new Error("x")),
VIDEO_CODES.METADATA_PARSE_FAILED,
],
[
() => videoMetadataParseFailed(),
VIDEO_CODES.METADATA_PARSE_FAILED,
],
[() => invalidVideoTransform("bad"), VIDEO_CODES.INVALID_TRANSFORM],
[() => durationExceeded(100, 60), VIDEO_CODES.DURATION_EXCEEDED],
[() => unsupportedCodec("theora"), VIDEO_CODES.UNSUPPORTED_CODEC],
[() => videoNotFound(), VIDEO_CODES.NOT_FOUND],
[() => videoNotFound("mp4"), VIDEO_CODES.NOT_FOUND],
[
() => videoDecodeFailed(new Error("x")),
VIDEO_CODES.DECODE_FAILED,
],
[() => videoDecodeFailed(), VIDEO_CODES.DECODE_FAILED],
[
() => videoEncodeFailed(new Error("x")),
VIDEO_CODES.ENCODE_FAILED,
],
[() => videoEncodeFailed(), VIDEO_CODES.ENCODE_FAILED],
[
() => videoFormatUnsupported("xyz"),
VIDEO_CODES.FORMAT_UNSUPPORTED,
],
[() => videoTooLarge(2_000, 1_000), VIDEO_CODES.TOO_LARGE],
[
() => videoProcessingFailed("step", new Error("x")),
VIDEO_CODES.PROCESSING_FAILED,
],
[
() => videoProcessingFailed("step"),
VIDEO_CODES.PROCESSING_FAILED,
],
[
() => videoCodecUnsupported("theora"),
VIDEO_CODES.CODEC_UNSUPPORTED,
],
[() => videoTimeout(5000), VIDEO_CODES.TIMEOUT],
[
() => videoConfigInvalid("bad config"),
VIDEO_CODES.CONFIG_INVALID,
],
];
for (const [factory, code] of cases) {
const err = factory();
expect(err).toBeInstanceOf(VideoError);
expect(err.code).toBe(code);
expect(err.message.length).toBeGreaterThan(0);
}
});
it("contextful factories populate the typed context", {
timeout: 5000,
}, () => {
expect(invalidVideoDimensions(-1, 0).context).toEqual({
width: -1,
height: 0,
});
expect(durationExceeded(100, 60).context).toEqual({ duration: 100 });
expect(unsupportedCodec("theora").context).toEqual({
codec: "theora",
});
expect(videoFormatUnsupported("xyz").context).toEqual({
format: "xyz",
});
expect(videoTooLarge(2_000, 1_000).context).toEqual({
fileSize: 2_000,
maxSize: 1_000,
});
expect(videoCodecUnsupported("theora").context).toEqual({
codec: "theora",
});
expect(videoTimeout(5000).context).toEqual({ operationMs: 5000 });
expect(videoNotFound("mp4").context).toEqual({ format: "mp4" });
expect(videoNotFound().context).toEqual({});
});
it("factories with cause attach it", { timeout: 5000 }, () => {
const cause = new Error("inner");
expect(videoMetadataParseFailed(cause).cause).toBe(cause);
expect(videoDecodeFailed(cause).cause).toBe(cause);
expect(videoEncodeFailed(cause).cause).toBe(cause);
expect(videoProcessingFailed("x", cause).cause).toBe(cause);
});
});

View File

@@ -0,0 +1,175 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import { VIDEO_CODES, type VideoCode } from "../error-codes/error-codes.js";
export interface VideoErrorContext extends Record<string, unknown> {
format?: string;
codec?: string;
duration?: number;
width?: number;
height?: number;
fileSize?: number;
maxSize?: number;
operationMs?: number;
}
export interface VideoErrorOptions extends SuaveplanErrorOptions {
context?: VideoErrorContext;
}
/**
* Typed error for every failure thrown by `@suaveplan/video`.
*
* Carries a stable `code` drawn from {@link VIDEO_CODES} plus an optional
* `context` field (inherited from {@link SuaveplanError}) with typed hints
* (format, codec, dimensions, etc.).
*/
export class VideoError extends SuaveplanError<VideoCode> {
public constructor(
code: VideoCode,
message: string,
options: VideoErrorOptions = {}
) {
super(code, message, options);
this.name = "VideoError";
}
}
// ---------------------------------------------------------------------------
// Upstream factories — preserved 1:1.
// ---------------------------------------------------------------------------
export function unknownVideoFormat(): VideoError {
return new VideoError(
VIDEO_CODES.UNKNOWN_FORMAT,
"Unable to determine video format"
);
}
export function invalidVideoDimensions(
width: number,
height: number
): VideoError {
return new VideoError(
VIDEO_CODES.INVALID_DIMENSIONS,
`Invalid dimensions: ${width}x${height}`,
{ context: { width, height } }
);
}
export function invalidTrimRange(
startTime: number,
endTime: number
): VideoError {
return new VideoError(
VIDEO_CODES.INVALID_TRIM_RANGE,
`Invalid trim range: ${startTime}s to ${endTime}s`
);
}
export function videoMetadataParseFailed(cause?: Error): VideoError {
return new VideoError(
VIDEO_CODES.METADATA_PARSE_FAILED,
"Failed to parse video metadata",
{ context: {}, cause }
);
}
export function invalidVideoTransform(message: string): VideoError {
return new VideoError(
VIDEO_CODES.INVALID_TRANSFORM,
`Invalid transform: ${message}`
);
}
export function durationExceeded(
duration: number,
maxDuration: number
): VideoError {
return new VideoError(
VIDEO_CODES.DURATION_EXCEEDED,
`Duration ${duration}s exceeds maximum of ${maxDuration}s`,
{ context: { duration } }
);
}
export function unsupportedCodec(codec: string): VideoError {
return new VideoError(
VIDEO_CODES.UNSUPPORTED_CODEC,
`Unsupported codec: ${codec}`,
{ context: { codec } }
);
}
export function videoNotFound(format?: string): VideoError {
return new VideoError(
VIDEO_CODES.NOT_FOUND,
"The requested video could not be found",
{ context: format ? { format } : {} }
);
}
export function videoDecodeFailed(cause?: Error): VideoError {
return new VideoError(
VIDEO_CODES.DECODE_FAILED,
"Failed to decode video — file may be corrupt or unreadable",
{ context: {}, cause }
);
}
export function videoEncodeFailed(cause?: Error): VideoError {
return new VideoError(
VIDEO_CODES.ENCODE_FAILED,
"Video encode step failed due to an internal error",
{ context: {}, cause }
);
}
export function videoFormatUnsupported(format: string): VideoError {
return new VideoError(
VIDEO_CODES.FORMAT_UNSUPPORTED,
`Video format not supported: ${format}`,
{ context: { format } }
);
}
export function videoTooLarge(fileSize: number, maxSize: number): VideoError {
return new VideoError(
VIDEO_CODES.TOO_LARGE,
`Video size ${fileSize} bytes exceeds maximum of ${maxSize} bytes`,
{ context: { fileSize, maxSize } }
);
}
export function videoProcessingFailed(
message: string,
cause?: Error
): VideoError {
return new VideoError(
VIDEO_CODES.PROCESSING_FAILED,
`Video processing failed: ${message}`,
{ context: {}, cause }
);
}
export function videoCodecUnsupported(codec: string): VideoError {
return new VideoError(
VIDEO_CODES.CODEC_UNSUPPORTED,
`Video codec not supported: ${codec}`,
{ context: { codec } }
);
}
export function videoTimeout(operationMs: number): VideoError {
return new VideoError(
VIDEO_CODES.TIMEOUT,
`Video processing timed out after ${operationMs}ms`,
{ context: { operationMs } }
);
}
export function videoConfigInvalid(message: string): VideoError {
return new VideoError(
VIDEO_CODES.CONFIG_INVALID,
`Invalid VideoProcessor config: ${message}`
);
}

View File

@@ -0,0 +1,21 @@
export {
durationExceeded,
invalidTrimRange,
invalidVideoDimensions,
invalidVideoTransform,
unknownVideoFormat,
unsupportedCodec,
VideoError,
type VideoErrorContext,
type VideoErrorOptions,
videoCodecUnsupported,
videoConfigInvalid,
videoDecodeFailed,
videoEncodeFailed,
videoFormatUnsupported,
videoMetadataParseFailed,
videoNotFound,
videoProcessingFailed,
videoTimeout,
videoTooLarge,
} from "./errors.js";

View File

@@ -0,0 +1,64 @@
# `format` — canonical file-extension mapping
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`videoFormatToExtension` is a pure lookup that returns the canonical
file-extension (including the leading dot) for a supported
`VideoFormat`. The module lives separately from `VIDEO_MIME_TYPES`
because filesystem extensions and HTTP MIME strings are independent
concerns — both happen to map 1:1 from `VideoFormat` today, but
extensions are file-system-correct (always lowercase, no charset) where
MIME strings carry RFC-2045 grammar.
The function is the canonical place to emit a filename for a video that
has been planned through `VideoProcessor.planTransform`. It is also
useful to client-side download UIs that pre-compute a suggested filename
before initiating a save.
## Features
- 8-entry mapping covering every supported `VideoFormat`.
- Returns the extension with a leading `.` (consistent with `node:path`
conventions).
- Pure lookup — constant-time, no allocations.
## Usage: Basic
```ts
import { videoFormatToExtension } from "@suaveplan/video/format/format";
console.log(videoFormatToExtension("mp4")); // ".mp4"
console.log(videoFormatToExtension("webm")); // ".webm"
```
## Usage: Advanced
```ts
import { videoFormatToExtension } from "@suaveplan/video/format/format";
import type { VideoTransformResult } from "@suaveplan/video";
function suggestedFilename(result: VideoTransformResult, base: string) {
return `${base}${videoFormatToExtension(result.format)}`;
}
```
## API Reference
- `videoFormatToExtension(format: VideoFormat): string` — returns the
canonical extension prefixed with `.`. Throws nothing because
`VideoFormat` is a closed union; an invalid runtime value would surface
as `undefined` at the call site (caught by the consumer's Zod
validation upstream).
## Implementation Notes
The extension table lives at module-scope as a frozen `Record` so the
function compiles down to a single property lookup — no switch, no
indirection. The extensions are byte-for-byte the upstream values
preserved verbatim. Note that `mov` returns `.mov` (not `.qt`) and
`webm` returns `.webm` (not `.mkv`) — these are the de-facto extensions
even though `webm` is technically a Matroska profile.

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { VideoFormat } from "../types/video-types/video-types.js";
import { videoFormatToExtension } from "./format.js";
describe("videoFormatToExtension", () => {
it("mp4 -> .mp4", { timeout: 5000 }, () => {
expect(videoFormatToExtension("mp4")).toBe(".mp4");
});
it("webm -> .webm", { timeout: 5000 }, () => {
expect(videoFormatToExtension("webm")).toBe(".webm");
});
it("covers every VideoFormat", { timeout: 5000 }, () => {
const formats: VideoFormat[] = [
"mp4",
"webm",
"avi",
"mkv",
"mov",
"flv",
"wmv",
"ogg",
];
for (const f of formats) {
const ext = videoFormatToExtension(f);
expect(ext.startsWith(".")).toBe(true);
expect(ext.length).toBeGreaterThan(1);
}
});
});

View File

@@ -0,0 +1,20 @@
import type { VideoFormat } from "../types/video-types/video-types.js";
const EXTENSION_MAP: Readonly<Record<VideoFormat, string>> = {
mp4: ".mp4",
webm: ".webm",
avi: ".avi",
mkv: ".mkv",
mov: ".mov",
flv: ".flv",
wmv: ".wmv",
ogg: ".ogg",
};
/**
* Return the canonical file-extension (including leading dot) for a
* supported {@link VideoFormat}.
*/
export function videoFormatToExtension(format: VideoFormat): string {
return EXTENSION_MAP[format];
}

View File

@@ -0,0 +1 @@
export { videoFormatToExtension } from "./format.js";

View File

@@ -0,0 +1,74 @@
export { detectVideoFormat } from "./detection/index.js";
export { formatDuration, parseDuration } from "./duration/index.js";
export {
registerVideoErrorCodes,
VIDEO_CODES,
VIDEO_ERROR_METADATA,
type VideoCode,
type VideoCodeMetadata,
type VideoCodeRetryPolicy,
type VideoCodeSeverity,
} from "./error-codes/index.js";
export {
durationExceeded,
invalidTrimRange,
invalidVideoDimensions,
invalidVideoTransform,
unknownVideoFormat,
unsupportedCodec,
VideoError,
type VideoErrorContext,
type VideoErrorOptions,
videoCodecUnsupported,
videoConfigInvalid,
videoDecodeFailed,
videoEncodeFailed,
videoFormatUnsupported,
videoMetadataParseFailed,
videoNotFound,
videoProcessingFailed,
videoTimeout,
videoTooLarge,
} from "./errors/index.js";
export { videoFormatToExtension } from "./format/index.js";
export {
getExtractCounter,
getExtractDuration,
getPlanCounter,
getPlanDuration,
getTracer,
resetInstruments,
} from "./instruments/index.js";
export { VideoPipelineBuilder } from "./pipeline-builder/index.js";
export { VideoProcessor } from "./processor/index.js";
export { calculateVideoResize } from "./resize/index.js";
export { validateTrimRange } from "./trim/index.js";
export {
type AudioCodec,
AudioCodecSchema,
RESOLUTION_PRESETS,
type ResolutionPreset,
ResolutionPresetSchema,
type TrimRange,
TrimRangeSchema,
VIDEO_MIME_TYPES,
type VideoCodec,
VideoCodecSchema,
type VideoDimensions,
VideoDimensionsSchema,
type VideoEventMap,
type VideoFormat,
VideoFormatSchema,
type VideoMetadata,
VideoMetadataSchema,
type VideoProcessorConfig,
VideoProcessorConfigSchema,
type VideoResizeOptions,
VideoResizeOptionsSchema,
type VideoTransformOp,
VideoTransformOpSchema,
type VideoTransformPipeline,
VideoTransformPipelineSchema,
type VideoTransformResult,
VideoTransformResultSchema,
} from "./types/video-types/index.js";

View File

@@ -0,0 +1,8 @@
export {
getExtractCounter,
getExtractDuration,
getPlanCounter,
getPlanDuration,
getTracer,
resetInstruments,
} from "./instruments.js";

View File

@@ -0,0 +1,83 @@
# `instruments` — OpenTelemetry counter/histogram/tracer wiring
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the OpenTelemetry instruments that `VideoProcessor`
emits on every invocation. The instruments are lazily cached behind
accessor functions (`getExtractCounter()` etc.) so the first call after
a `MeterProvider` / `TracerProvider` swap resolves against the new
provider — which is exactly what the spec-conformance tests rely on.
The cache also keeps repeated calls cheap: instrument creation is
allocation-heavy in the OTel SDK and would otherwise dominate hot-path
profiles for `extractMetadata` and `planTransform`.
Four instruments + one tracer are exposed:
- `video.metadata.extracted_total` (counter) — every `extractMetadata` call.
- `video.metadata.extract_duration_ms` (histogram) — wall-clock duration.
- `video.transform.planned_total` (counter) — every `planTransform` call.
- `video.transform.plan_duration_ms` (histogram) — wall-clock duration.
- Tracer `@suaveplan/video` — used for the `video.metadata.extract` and
`video.transform.plan` spans.
Every instrument records on success AND failure paths with the `outcome`
attribute carrying one of the standard values
(`"success"`, `"unknown_format"`, `"duration_exceeded"`, `"error"`,
`"invalid_pipeline"`, `"aborted"`).
## Features
- Lazy-cached instrument handles via four accessor functions.
- `resetInstruments()` test helper for clearing the cache between
provider swaps.
- Constant meter + tracer names matching the package import path.
## Usage: Basic
```ts
import { getExtractCounter } from "@suaveplan/video/instruments/instruments";
getExtractCounter().add(1, { outcome: "success" });
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import {
resetInstruments,
getExtractCounter,
} from "@suaveplan/video/instruments/instruments";
beforeEach(() => {
metrics.setGlobalMeterProvider(testMeterProvider);
resetInstruments();
});
// First call now resolves against testMeterProvider.
getExtractCounter().add(1, { outcome: "success" });
```
## API Reference
- `getExtractCounter(): Counter` — `video.metadata.extracted_total`.
- `getExtractDuration(): Histogram` — `video.metadata.extract_duration_ms`
with `unit: "ms"`.
- `getPlanCounter(): Counter` — `video.transform.planned_total`.
- `getPlanDuration(): Histogram` — `video.transform.plan_duration_ms`
with `unit: "ms"`.
- `getTracer(): Tracer` — `@suaveplan/video` tracer for spans.
- `resetInstruments(): void` — clear the cache; test-only.
## Implementation Notes
The accessors deliberately return concrete `Counter` / `Histogram`
instances rather than wrapping a `MeterProvider` because the OTel API
forbids re-resolving instruments after they are created against a
provider. `resetInstruments()` is the only seam by which the cache can
be invalidated, and it is exported specifically for the
`spec-conformance` tests; production code should never call it.

View File

@@ -0,0 +1,79 @@
import type { Counter, Histogram, Tracer } from "@opentelemetry/api";
import { metrics, trace } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/video";
const TRACER_NAME = "@suaveplan/video";
const METRIC_EXTRACT_TOTAL = "video.metadata.extracted_total";
const METRIC_EXTRACT_DURATION_MS = "video.metadata.extract_duration_ms";
const METRIC_PLAN_TOTAL = "video.transform.planned_total";
const METRIC_PLAN_DURATION_MS = "video.transform.plan_duration_ms";
let extractCounter: Counter | undefined;
let extractDuration: Histogram | undefined;
let planCounter: Counter | undefined;
let planDuration: Histogram | undefined;
export function getExtractCounter(): Counter {
if (!extractCounter) {
extractCounter = metrics
.getMeter(METER_NAME)
.createCounter(METRIC_EXTRACT_TOTAL, {
description: "Total VideoProcessor.extractMetadata invocations",
});
}
return extractCounter;
}
export function getExtractDuration(): Histogram {
if (!extractDuration) {
extractDuration = metrics
.getMeter(METER_NAME)
.createHistogram(METRIC_EXTRACT_DURATION_MS, {
description: "Duration of VideoProcessor.extractMetadata in ms",
unit: "ms",
});
}
return extractDuration;
}
export function getPlanCounter(): Counter {
if (!planCounter) {
planCounter = metrics
.getMeter(METER_NAME)
.createCounter(METRIC_PLAN_TOTAL, {
description: "Total VideoProcessor.planTransform invocations",
});
}
return planCounter;
}
export function getPlanDuration(): Histogram {
if (!planDuration) {
planDuration = metrics
.getMeter(METER_NAME)
.createHistogram(METRIC_PLAN_DURATION_MS, {
description: "Duration of VideoProcessor.planTransform in ms",
unit: "ms",
});
}
return planDuration;
}
export function getTracer(): Tracer {
return trace.getTracer(TRACER_NAME);
}
/**
* Reset the cached instrument handles.
*
* Tests that swap the global `MeterProvider` / `TracerProvider` MUST call
* this between `installTestProviders()` and the unit under test so the
* next `getExtractCounter()` etc. resolves against the new provider.
*/
export function resetInstruments(): void {
extractCounter = undefined;
extractDuration = undefined;
planCounter = undefined;
planDuration = undefined;
}

View File

@@ -0,0 +1 @@
export { VideoPipelineBuilder } from "./pipeline-builder.js";

View File

@@ -0,0 +1,105 @@
# `pipeline-builder` — `VideoPipelineBuilder`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`VideoPipelineBuilder` is the fluent surface for assembling a
`VideoTransformPipeline` without having to construct the operations array
by hand. Each method appends one operation to the pipeline and returns
`this`, so callers chain `resize().trim().toFormat().build()` and get
back the canonical pipeline shape ready to feed into
`VideoProcessor.planTransform`. The builder is universal and
allocates only the operations array plus the final pipeline object.
A builder created via `VideoProcessor.createPipeline()` carries a
reference to the processor's `EventBus`, and `build()` emits the
`pipeline.built` event on that bus — useful for downstream observers
(logging, audit trails, UI refresh) that want to see every pipeline as
it is sealed. A builder instantiated directly (`new
VideoPipelineBuilder()`) skips the emission silently so the builder can
be used in pure-construction contexts (snapshot tests, code generators).
## Features
- Nine fluent methods covering every `VideoTransformOp` variant:
`resize`, `trim`, `rotate`, `flip`, `speed`, `mute`, `toFormat`,
`codec`, `bitrate`.
- `build()` returns a fresh pipeline (the internal ops array is copied).
- `build()` emits `pipeline.built` when the builder was created via
`VideoProcessor.createPipeline()`.
- `apply(metadata, processor)` — convenience that builds the pipeline
and immediately runs it through `processor.planTransform`.
## Usage: Basic
```ts
import { VideoPipelineBuilder } from "@suaveplan/video";
const pipeline = new VideoPipelineBuilder()
.resize({ preset: "720p" })
.trim({ startTime: 0, endTime: 30 })
.toFormat("webm")
.build();
```
## Usage: Advanced
```ts
import { VideoProcessor } from "@suaveplan/video";
const proc = new VideoProcessor();
proc.on("pipeline.built", (p) =>
console.log("sealed", p.operations.length, "ops")
);
const meta = proc.extractMetadata(headerBytes, {
width: 1920,
height: 1080,
duration: 60,
frameRate: 30,
});
// Builder is created via the processor — `pipeline.built` fires on build().
const result = proc
.createPipeline()
.resize({ preset: "1080p" })
.codec("h265")
.bitrate(5_000_000)
.toFormat("mp4")
.apply(meta, proc);
```
## API Reference
- `new VideoPipelineBuilder(bus?: EventBus<VideoEventMap>)` — internal
constructor; consumers normally use `VideoProcessor.createPipeline()`.
- `resize(options: VideoResizeOptions): this`.
- `trim(range: TrimRange): this`.
- `rotate(degrees: number): this`.
- `flip(direction: "horizontal" | "vertical"): this`.
- `speed(factor: number): this`.
- `mute(): this`.
- `toFormat(format: VideoFormat): this`.
- `codec(codec: VideoCodec): this`.
- `bitrate(value: number): this`.
- `build(): VideoTransformPipeline` — returns a fresh pipeline; emits
`pipeline.built` when bound to a processor.
- `apply(metadata: VideoMetadata, processor: VideoProcessor):
VideoTransformResult`.
## Implementation Notes
`build()` copies the internal `ops` array (`[...this.ops]`) so the
returned pipeline is detached from the builder — subsequent fluent calls
on the same builder do NOT mutate already-built pipelines. The builder
itself remains mutable so callers can compose, build, then add more ops
and build again; this matches the upstream contract and is exercised by
the test suite.
The `pipeline.built` event is the only fluent-emission path in the
package. The upstream package declared the topic in its `VideoEventMap`
but never fired it; This package closes that gap so observers can rely on the
contract.

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VideoProcessor } from "../processor/processor.js";
import type {
VideoMetadata,
VideoTransformPipeline,
} from "../types/video-types/video-types.js";
import { VideoPipelineBuilder } from "./pipeline-builder.js";
const baseMeta: VideoMetadata = {
format: "mp4",
width: 1920,
height: 1080,
duration: 120,
frameRate: 30,
hasAudio: true,
};
describe("VideoPipelineBuilder fluent methods", () => {
it("chains every method", { timeout: 5000 }, () => {
const b = new VideoPipelineBuilder();
expect(b.resize({ preset: "720p" })).toBe(b);
expect(b.trim({ startTime: 0, endTime: 10 })).toBe(b);
expect(b.rotate(90)).toBe(b);
expect(b.flip("horizontal")).toBe(b);
expect(b.speed(2)).toBe(b);
expect(b.mute()).toBe(b);
expect(b.toFormat("webm")).toBe(b);
expect(b.codec("h265")).toBe(b);
expect(b.bitrate(5_000_000)).toBe(b);
});
});
describe("VideoPipelineBuilder.build", () => {
it("build returns pipeline", { timeout: 5000 }, () => {
const p = new VideoPipelineBuilder()
.resize({ preset: "720p" })
.trim({ startTime: 0, endTime: 30 })
.toFormat("webm")
.build();
expect(p.operations).toHaveLength(3);
expect(p.outputFormat).toBe("webm");
});
it("build with no ops returns empty pipeline", { timeout: 5000 }, () => {
const p = new VideoPipelineBuilder().build();
expect(p.operations).toHaveLength(0);
expect(p.outputFormat).toBeUndefined();
});
it("successive builds detach from later mutations", {
timeout: 5000,
}, () => {
const b = new VideoPipelineBuilder().resize({ preset: "720p" });
const first = b.build();
b.trim({ startTime: 0, endTime: 5 });
expect(first.operations).toHaveLength(1);
});
});
describe("VideoPipelineBuilder bound to processor", () => {
it("emits pipeline.built when bound to a processor", {
timeout: 5000,
}, () => {
const proc = new VideoProcessor();
let received: VideoTransformPipeline | undefined;
proc.on("pipeline.built", (p) => {
received = p;
});
proc.createPipeline().resize({ preset: "720p" }).build();
expect(received?.operations).toHaveLength(1);
});
it("apply runs the pipeline", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const r = proc
.createPipeline()
.resize({ preset: "720p" })
.apply(baseMeta, proc);
expect(r.width).toBe(1280);
expect(r.height).toBe(720);
});
});
describe("VideoPipelineBuilder unbound", () => {
it("does not emit when no bus is attached", { timeout: 5000 }, () => {
// The default constructor takes no bus — building must not throw.
const p = new VideoPipelineBuilder().toFormat("ogg").build();
expect(p.outputFormat).toBe("ogg");
});
});

View File

@@ -0,0 +1,95 @@
import type { EventBus } from "@suaveplan/events";
import type { VideoProcessor } from "../processor/processor.js";
import type {
TrimRange,
VideoCodec,
VideoEventMap,
VideoFormat,
VideoMetadata,
VideoResizeOptions,
VideoTransformOp,
VideoTransformPipeline,
VideoTransformResult,
} from "../types/video-types/video-types.js";
/**
* Fluent builder for `VideoTransformPipeline`.
*
* The builder is normally created via `VideoProcessor.createPipeline()` so
* `build()` can fire the `pipeline.built` event on the processor's bus.
* It can also be instantiated directly when no event emission is needed.
*/
export class VideoPipelineBuilder {
private readonly ops: VideoTransformOp[] = [];
private outFormat?: VideoFormat;
private readonly bus?: EventBus<VideoEventMap>;
public constructor(bus?: EventBus<VideoEventMap>) {
this.bus = bus;
}
public resize(options: VideoResizeOptions): this {
this.ops.push({ type: "resize", options });
return this;
}
public trim(range: TrimRange): this {
this.ops.push({ type: "trim", range });
return this;
}
public rotate(degrees: number): this {
this.ops.push({ type: "rotate", degrees });
return this;
}
public flip(direction: "horizontal" | "vertical"): this {
this.ops.push({ type: "flip", direction });
return this;
}
public speed(factor: number): this {
this.ops.push({ type: "speed", factor });
return this;
}
public mute(): this {
this.ops.push({ type: "mute" });
return this;
}
public toFormat(format: VideoFormat): this {
this.outFormat = format;
this.ops.push({ type: "format", format });
return this;
}
public codec(codec: VideoCodec): this {
this.ops.push({ type: "codec", codec });
return this;
}
public bitrate(value: number): this {
this.ops.push({ type: "bitrate", value });
return this;
}
public build(): VideoTransformPipeline {
const pipeline: VideoTransformPipeline = {
operations: [...this.ops],
outputFormat: this.outFormat,
};
if (this.bus) {
this.bus.emit("pipeline.built", pipeline);
}
return pipeline;
}
public apply(
metadata: VideoMetadata,
processor: VideoProcessor
): VideoTransformResult {
const pipeline = this.build();
return processor.planTransform(metadata, pipeline);
}
}

View File

@@ -0,0 +1 @@
export { VideoProcessor } from "./processor.js";

View File

@@ -0,0 +1,117 @@
# `processor` — `VideoProcessor`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`VideoProcessor` is the orchestration class for `@suaveplan/video`. It owns
two operations — `extractMetadata` (read a header buffer and merge with
caller-supplied probe data) and `planTransform` (walk a
`VideoTransformPipeline` against a `VideoMetadata` and return the projected
output shape) — plus the event bus that consumers subscribe to via
`on` / `once` / `off`. The class is universal: it has no Node-only or
browser-only imports and no IO; heavy codec work is delegated to consumer
pipelines (ffmpeg, WebCodecs, hardware encoders) and this processor only
plans the resulting shape.
Configuration is validated at construction time via Zod and any failure
raises `VideoError(VIDEO.CONFIG_INVALID)`. Both public methods accept an
optional `AbortSignal` and short-circuit with
`VideoError(VIDEO.PROCESSING_FAILED, cause: AbortError)` when the signal
is already aborted at call time. Every invocation records two OTel
instruments (a counter and a histogram, both tagged with an `outcome`
attribute) and opens a span that ends in a `finally` block — so failure
paths still produce a complete trace.
## Features
- `extractMetadata(data, knownMetadata?, signal?)` — sniff format, merge
with caller-supplied hints, validate against `maxDuration`, emit
`metadata.extracted`.
- `planTransform(metadata, pipeline, signal?)` — validate pipeline shape
via Zod, walk the 9-variant `VideoTransformOp` union, emit
`transform.planned`.
- `createPipeline()` — return a fluent `VideoPipelineBuilder` bound to
this processor's bus so its `build()` emits `pipeline.built`.
- `on` / `once` / `off` — event-bus surface for the three topics.
- `AbortSignal` support on every public method.
- OTel counters + histograms + spans on every invocation.
## Usage: Basic
```ts
import { VideoProcessor } from "@suaveplan/video";
const proc = new VideoProcessor();
const meta = proc.extractMetadata(headerBytes, {
width: 1920,
height: 1080,
duration: 60,
frameRate: 30,
});
const result = proc.planTransform(meta, {
operations: [{ type: "resize", options: { preset: "720p" } }],
});
console.log(result.width, result.height); // 1280 720
```
## Usage: Advanced
```ts
import { VideoProcessor, VideoError } from "@suaveplan/video";
const proc = new VideoProcessor({ maxDuration: 600 });
proc.on("metadata.extracted", (m) => console.log("got", m.format));
proc.on("transform.planned", (r) => console.log("plan", r.duration));
const ac = new AbortController();
setTimeout(() => ac.abort(), 10);
try {
proc.extractMetadata(headerBytes, undefined, ac.signal);
} catch (err) {
if (err instanceof VideoError) {
console.error(err.code, err.context, err.cause);
}
}
```
## API Reference
- `new VideoProcessor(config?: VideoProcessorConfig)` — `maxDuration`
(default `7200`s), `defaultFormat` (default `"mp4"`). Throws
`VideoError(VIDEO.CONFIG_INVALID)` on Zod failure.
- `extractMetadata(data: Uint8Array, knownMetadata?:
Partial<VideoMetadata>, signal?: AbortSignal): VideoMetadata`.
- `planTransform(metadata: VideoMetadata, pipeline:
VideoTransformPipeline, signal?: AbortSignal): VideoTransformResult`.
- `createPipeline(): VideoPipelineBuilder` — bound to this processor.
- `on<K>(type: K, listener: (payload) => void): () => void`.
- `once<K>(type: K, listener: (payload) => void): () => void`.
- `off<K>(type: K, listener: (payload) => void): void`.
## Implementation Notes
The processor composes `EventBus<VideoEventMap>` rather than extending it
because the upstream `EventEmitter<VideoEventMap>` shape used array
tuples (`[VideoMetadata]`) while `EventBus` uses single
payloads (`VideoMetadata`). Listeners are unchanged in shape (one
parameter receives the payload) but the underlying map type differs.
`planTransform`'s `outcome` is initialised to `"success"` and overwritten
only on the schema-validation branch (`"invalid_pipeline"`) or in the
generic `catch` (`"error"`). The Zod failure branch sets `outcome`
**before** throwing so the OTel counter records the correct outcome —
this is a deliberate ordering and the spec-conformance test exercises
it. The final `if (outcome === "success") outcome = "error"` guard
covers paths where an operator throws (e.g. `invalidTrimRange`) without
a chance to update `outcome` inline.
`format` falls back through three layers: pipeline `outputFormat`, then
the metadata's `format`, then the processor's `defaultFormat`. This
chain matters when the pipeline doesn't choose a format and the metadata
came from an upstream system that left `format` unset — the processor's
default is the last line of defence.

View File

@@ -0,0 +1,263 @@
import type {
Counter,
Histogram,
MeterProvider,
Span,
Tracer,
TracerProvider,
} from "@opentelemetry/api";
import { metrics, trace } from "@opentelemetry/api";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { resetInstruments } from "../instruments/instruments.js";
import type { VideoMetadata } from "../types/video-types/video-types.js";
import { VideoProcessor } from "./processor.js";
interface MetricObs {
readonly name: string;
readonly value: number;
readonly attributes: Record<string, unknown>;
}
interface SpanObs {
readonly name: string;
ended: boolean;
}
const counters: MetricObs[] = [];
const histograms: MetricObs[] = [];
const spans: SpanObs[] = [];
function installTestProviders(): void {
counters.length = 0;
histograms.length = 0;
spans.length = 0;
const meterProvider = {
getMeter: () => ({
createCounter: (name: string) =>
({
add: (value: number, attrs?: Record<string, unknown>) => {
counters.push({
name,
value,
attributes: attrs ?? {},
});
},
}) as unknown as Counter,
createHistogram: (name: string) =>
({
record: (
value: number,
attrs?: Record<string, unknown>
) => {
histograms.push({
name,
value,
attributes: attrs ?? {},
});
},
}) as unknown as Histogram,
}),
} as unknown as MeterProvider;
const tracerProvider = {
getTracer: () =>
({
startSpan: (name: string) => {
const span: SpanObs = { name, ended: false };
const apiSpan = {
end: () => {
span.ended = true;
},
setAttribute: () => apiSpan,
setAttributes: () => apiSpan,
addEvent: () => apiSpan,
setStatus: () => apiSpan,
updateName: () => apiSpan,
recordException: () => apiSpan,
isRecording: () => !span.ended,
spanContext: () => ({
traceId: "00000000000000000000000000000000",
spanId: "0000000000000000",
traceFlags: 0,
}),
} as unknown as Span;
spans.push(span);
return apiSpan;
},
startActiveSpan: () => {
throw new Error("unused in tests");
},
}) as unknown as Tracer,
} as unknown as TracerProvider;
metrics.setGlobalMeterProvider(meterProvider);
trace.setGlobalTracerProvider(tracerProvider);
}
function makeMp4(): Uint8Array {
const buf = new Uint8Array(16);
buf.set([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70]);
buf.set([0x69, 0x73, 0x6f, 0x6d], 8);
return buf;
}
const meta: VideoMetadata = {
format: "mp4",
width: 1920,
height: 1080,
duration: 60,
frameRate: 30,
hasAudio: true,
};
describe("VideoProcessor spec conformance", () => {
beforeEach(() => {
installTestProviders();
resetInstruments();
});
describe("OTel instrumentation", () => {
it("increments extracted counter on success", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
proc.extractMetadata(makeMp4(), {
width: 1,
height: 1,
duration: 1,
frameRate: 1,
});
const success = counters.find(
(c) =>
c.name === "video.metadata.extracted_total" &&
c.attributes.outcome === "success"
);
expect(success).toBeDefined();
expect(success?.value).toBe(1);
});
it("records failure outcome on unknown_format", {
timeout: 5000,
}, () => {
const proc = new VideoProcessor();
try {
proc.extractMetadata(new Uint8Array(16));
} catch {
/* expected */
}
const failure = counters.find(
(c) =>
c.name === "video.metadata.extracted_total" &&
c.attributes.outcome === "unknown_format"
);
expect(failure).toBeDefined();
});
it("records duration_exceeded outcome", { timeout: 5000 }, () => {
const proc = new VideoProcessor({ maxDuration: 1 });
try {
proc.extractMetadata(makeMp4(), {
width: 1,
height: 1,
duration: 60,
frameRate: 1,
});
} catch {
/* expected */
}
const obs = counters.find(
(c) => c.attributes.outcome === "duration_exceeded"
);
expect(obs).toBeDefined();
});
it("records extract histogram", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
proc.extractMetadata(makeMp4(), {
width: 1,
height: 1,
duration: 1,
frameRate: 1,
});
const obs = histograms.find(
(h) => h.name === "video.metadata.extract_duration_ms"
);
expect(obs).toBeDefined();
expect(obs?.value).toBeGreaterThanOrEqual(0);
});
it("increments planned counter on success", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
proc.planTransform(meta, { operations: [] });
const obs = counters.find(
(c) =>
c.name === "video.transform.planned_total" &&
c.attributes.outcome === "success"
);
expect(obs).toBeDefined();
});
it("records invalid_pipeline outcome on bad pipeline", {
timeout: 5000,
}, () => {
const proc = new VideoProcessor();
try {
proc.planTransform(meta, {
operations: [{ type: "explode" } as unknown as never],
});
} catch {
/* expected */
}
const obs = counters.find(
(c) =>
c.name === "video.transform.planned_total" &&
c.attributes.outcome === "invalid_pipeline"
);
expect(obs).toBeDefined();
});
it("records error outcome when an operator throws", {
timeout: 5000,
}, () => {
const proc = new VideoProcessor();
try {
proc.planTransform(meta, {
operations: [{ type: "rotate", degrees: 45 }],
});
} catch {
/* expected */
}
const obs = counters.find(
(c) =>
c.name === "video.transform.planned_total" &&
c.attributes.outcome === "error"
);
expect(obs).toBeDefined();
});
it("ends every started span", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
proc.extractMetadata(makeMp4(), {
width: 1,
height: 1,
duration: 1,
frameRate: 1,
});
proc.planTransform(meta, { operations: [] });
expect(spans).toHaveLength(2);
for (const s of spans) expect(s.ended).toBe(true);
});
});
describe("AbortSignal", () => {
it("aborts extractMetadata before work", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const ac = new AbortController();
ac.abort();
expect(() =>
proc.extractMetadata(makeMp4(), undefined, ac.signal)
).toThrow();
// No span should have been started: assertion runs before tracer call.
expect(spans).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,501 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VIDEO_CODES } from "../error-codes/error-codes.js";
import { VideoError } from "../errors/errors.js";
import { VideoPipelineBuilder } from "../pipeline-builder/pipeline-builder.js";
import type { VideoMetadata } from "../types/video-types/video-types.js";
import { VideoProcessor } from "./processor.js";
function makeMp4Header(): Uint8Array {
const buf = new Uint8Array(16);
buf.set([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70]);
buf.set([0x69, 0x73, 0x6f, 0x6d], 8);
return buf;
}
function makeMkvHeader(): Uint8Array {
const buf = new Uint8Array(16);
buf.set([0x1a, 0x45, 0xdf, 0xa3]);
return buf;
}
const baseMeta: VideoMetadata = {
format: "mp4",
width: 1920,
height: 1080,
duration: 120,
frameRate: 30,
hasAudio: true,
};
describe("VideoProcessor.constructor", () => {
it("accepts an empty config", { timeout: 5000 }, () => {
expect(() => new VideoProcessor()).not.toThrow();
});
it("accepts a typed config", { timeout: 5000 }, () => {
expect(
() =>
new VideoProcessor({
maxDuration: 600,
defaultFormat: "webm",
})
).not.toThrow();
});
it("throws CONFIG_INVALID for invalid config", { timeout: 5000 }, () => {
try {
new VideoProcessor({
maxDuration: -1 as unknown as number,
});
throw new Error("expected to throw");
} catch (err) {
expect(err).toBeInstanceOf(VideoError);
expect((err as VideoError).code).toBe(VIDEO_CODES.CONFIG_INVALID);
}
});
});
describe("VideoProcessor.extractMetadata", () => {
it("extracts from MP4 header", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const meta = proc.extractMetadata(makeMp4Header(), {
width: 1920,
height: 1080,
duration: 60,
frameRate: 30,
});
expect(meta.format).toBe("mp4");
expect(meta.width).toBe(1920);
expect(meta.fileSize).toBe(16);
});
it("detects MKV", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const meta = proc.extractMetadata(makeMkvHeader(), {
width: 1280,
height: 720,
duration: 30,
frameRate: 24,
});
expect(meta.format).toBe("mkv");
});
it("uses knownMetadata.format when header is opaque", {
timeout: 5000,
}, () => {
const proc = new VideoProcessor();
const meta = proc.extractMetadata(new Uint8Array(16), {
format: "webm",
width: 640,
height: 480,
duration: 10,
frameRate: 25,
});
expect(meta.format).toBe("webm");
});
it("throws UNKNOWN_FORMAT when no format can be determined", {
timeout: 5000,
}, () => {
const proc = new VideoProcessor();
try {
proc.extractMetadata(new Uint8Array(16));
throw new Error("expected to throw");
} catch (err) {
expect(err).toBeInstanceOf(VideoError);
expect((err as VideoError).code).toBe(VIDEO_CODES.UNKNOWN_FORMAT);
}
});
it("throws for duration exceeding max", { timeout: 5000 }, () => {
const proc = new VideoProcessor({ maxDuration: 60 });
try {
proc.extractMetadata(makeMp4Header(), {
width: 1920,
height: 1080,
duration: 120,
frameRate: 30,
});
throw new Error("expected to throw");
} catch (err) {
expect(err).toBeInstanceOf(VideoError);
expect((err as VideoError).code).toBe(
VIDEO_CODES.DURATION_EXCEEDED
);
}
});
it("defaults hasAudio to true", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const meta = proc.extractMetadata(makeMp4Header(), {
width: 640,
height: 480,
duration: 10,
frameRate: 30,
});
expect(meta.hasAudio).toBe(true);
});
it("respects hasAudio: false override", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const meta = proc.extractMetadata(makeMp4Header(), {
width: 640,
height: 480,
duration: 10,
frameRate: 30,
hasAudio: false,
});
expect(meta.hasAudio).toBe(false);
});
it("emits metadata.extracted", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
let captured: VideoMetadata | undefined;
proc.on("metadata.extracted", (m) => {
captured = m;
});
proc.extractMetadata(makeMp4Header(), {
width: 640,
height: 480,
duration: 10,
frameRate: 30,
});
expect(captured?.format).toBe("mp4");
});
it("extracts with no knownMetadata (uses defaults)", {
timeout: 5000,
}, () => {
const proc = new VideoProcessor();
const meta = proc.extractMetadata(makeMp4Header());
expect(meta.format).toBe("mp4");
expect(meta.width).toBe(0);
expect(meta.height).toBe(0);
expect(meta.duration).toBe(0);
expect(meta.frameRate).toBe(0);
expect(meta.hasAudio).toBe(true);
});
it("wraps non-Error throws as METADATA_PARSE_FAILED with undefined cause", {
timeout: 5000,
}, () => {
const proc = new VideoProcessor();
// Deliberately throw a non-Error to exercise the
// `err instanceof Error ? err : undefined` false branch.
// Using a pattern biome cannot statically reject (the value's
// type is `unknown`, so the lint rule cannot prove it isn't
// Error-like).
const data = new Proxy(makeMp4Header(), {
get(target, prop) {
if (prop === "byteLength") {
const failure: unknown = "string-failure";
throw failure;
}
return Reflect.get(target, prop);
},
}) as Uint8Array;
try {
proc.extractMetadata(data, {
width: 1,
height: 1,
duration: 1,
frameRate: 1,
});
throw new Error("expected to throw");
} catch (err) {
expect(err).toBeInstanceOf(VideoError);
expect((err as VideoError).code).toBe(
VIDEO_CODES.METADATA_PARSE_FAILED
);
expect((err as VideoError).cause).toBeUndefined();
}
});
it("aborts before any work", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const ac = new AbortController();
ac.abort();
try {
proc.extractMetadata(makeMp4Header(), undefined, ac.signal);
throw new Error("expected to throw");
} catch (err) {
expect(err).toBeInstanceOf(VideoError);
expect((err as VideoError).code).toBe(
VIDEO_CODES.PROCESSING_FAILED
);
expect((err as VideoError).cause).toBeInstanceOf(Error);
}
});
it("wraps unexpected throws as METADATA_PARSE_FAILED", {
timeout: 5000,
}, () => {
const proc = new VideoProcessor();
// Pass a non-Uint8Array-shaped object — accessing .byteLength is fine
// but accessing indexed bytes throws inside detectVideoFormat path
// is hard to provoke here; instead, monkey-poison the bus.
// We trigger the catch by passing in a Proxy that throws on byteLength.
const data = new Proxy(makeMp4Header(), {
get(target, prop) {
if (prop === "byteLength") {
throw new Error("byteLength boom");
}
return Reflect.get(target, prop);
},
}) as Uint8Array;
try {
proc.extractMetadata(data, {
width: 1,
height: 1,
duration: 1,
frameRate: 1,
});
throw new Error("expected to throw");
} catch (err) {
expect(err).toBeInstanceOf(VideoError);
expect((err as VideoError).code).toBe(
VIDEO_CODES.METADATA_PARSE_FAILED
);
}
});
});
describe("VideoProcessor.planTransform", () => {
it("applies resize", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const r = proc.planTransform(baseMeta, {
operations: [{ type: "resize", options: { preset: "720p" } }],
});
expect(r.width).toBe(1280);
expect(r.height).toBe(720);
});
it("applies trim", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const r = proc.planTransform(baseMeta, {
operations: [
{ type: "trim", range: { startTime: 10, endTime: 60 } },
],
});
expect(r.duration).toBe(50);
});
it("throws for invalid trim", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
try {
proc.planTransform(baseMeta, {
operations: [
{ type: "trim", range: { startTime: 60, endTime: 10 } },
],
});
throw new Error("expected to throw");
} catch (err) {
expect((err as VideoError).code).toBe(
VIDEO_CODES.INVALID_TRIM_RANGE
);
}
});
it("applies 90-degree rotation", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const r = proc.planTransform(baseMeta, {
operations: [{ type: "rotate", degrees: 90 }],
});
expect(r.width).toBe(1080);
expect(r.height).toBe(1920);
});
it("applies 180-degree rotation", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const r = proc.planTransform(baseMeta, {
operations: [{ type: "rotate", degrees: 180 }],
});
expect(r.width).toBe(1920);
expect(r.height).toBe(1080);
});
it("throws for non-90 rotation", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
try {
proc.planTransform(baseMeta, {
operations: [{ type: "rotate", degrees: 45 }],
});
throw new Error("expected to throw");
} catch (err) {
expect((err as VideoError).code).toBe(
VIDEO_CODES.INVALID_TRANSFORM
);
}
});
it("flip preserves dimensions", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const r = proc.planTransform(baseMeta, {
operations: [{ type: "flip", direction: "vertical" }],
});
expect(r.width).toBe(1920);
expect(r.height).toBe(1080);
});
it("applies speed factor", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const r = proc.planTransform(baseMeta, {
operations: [{ type: "speed", factor: 2 }],
});
expect(r.duration).toBe(60);
});
it("throws for zero speed", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
try {
proc.planTransform(baseMeta, {
operations: [{ type: "speed", factor: 0 }],
});
throw new Error("expected to throw");
} catch (err) {
expect((err as VideoError).code).toBe(
VIDEO_CODES.INVALID_TRANSFORM
);
}
});
it("mute removes audio", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const r = proc.planTransform(baseMeta, {
operations: [{ type: "mute" }],
});
expect(r.hasAudio).toBe(false);
});
it("format op changes output format", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const r = proc.planTransform(baseMeta, {
operations: [{ type: "format", format: "webm" }],
});
expect(r.format).toBe("webm");
});
it("uses pipeline outputFormat", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const r = proc.planTransform(baseMeta, {
operations: [],
outputFormat: "mkv",
});
expect(r.format).toBe("mkv");
});
it("codec op is a no-op", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const r = proc.planTransform(baseMeta, {
operations: [{ type: "codec", codec: "h265" }],
});
expect(r.width).toBe(1920);
});
it("bitrate op is a no-op", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const r = proc.planTransform(baseMeta, {
operations: [{ type: "bitrate", value: 5_000_000 }],
});
expect(r.width).toBe(1920);
});
it("emits transform.planned", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
let captured = false;
proc.on("transform.planned", () => {
captured = true;
});
proc.planTransform(baseMeta, { operations: [] });
expect(captured).toBe(true);
});
it("invalid pipeline throws INVALID_TRANSFORM", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
try {
proc.planTransform(baseMeta, {
operations: [
// Unknown discriminant — Zod rejects.
{ type: "explode" } as unknown as never,
],
});
throw new Error("expected to throw");
} catch (err) {
expect((err as VideoError).code).toBe(
VIDEO_CODES.INVALID_TRANSFORM
);
}
});
it("aborted signal short-circuits planTransform", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
const ac = new AbortController();
ac.abort();
try {
proc.planTransform(baseMeta, { operations: [] }, ac.signal);
throw new Error("expected to throw");
} catch (err) {
expect((err as VideoError).code).toBe(
VIDEO_CODES.PROCESSING_FAILED
);
}
});
it("falls back to processor defaultFormat", { timeout: 5000 }, () => {
const proc = new VideoProcessor({ defaultFormat: "ogg" });
const meta = {
...baseMeta,
format: undefined as unknown as VideoMetadata["format"],
};
const r = proc.planTransform(meta, { operations: [] });
expect(r.format).toBe("ogg");
});
});
describe("VideoProcessor.createPipeline", () => {
it("returns a VideoPipelineBuilder", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
expect(proc.createPipeline()).toBeInstanceOf(VideoPipelineBuilder);
});
});
describe("VideoProcessor event surface", () => {
it("once subscribes for one event only", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
let count = 0;
proc.once("metadata.extracted", () => {
count += 1;
});
const sample = makeMp4Header();
proc.extractMetadata(sample, {
width: 1,
height: 1,
duration: 1,
frameRate: 1,
});
proc.extractMetadata(sample, {
width: 1,
height: 1,
duration: 1,
frameRate: 1,
});
expect(count).toBe(1);
});
it("off unsubscribes", { timeout: 5000 }, () => {
const proc = new VideoProcessor();
let count = 0;
const listener = () => {
count += 1;
};
proc.on("metadata.extracted", listener);
proc.off("metadata.extracted", listener);
proc.extractMetadata(makeMp4Header(), {
width: 1,
height: 1,
duration: 1,
frameRate: 1,
});
expect(count).toBe(0);
});
});

View File

@@ -0,0 +1,247 @@
import { EventBus } from "@suaveplan/events";
import { detectVideoFormat } from "../detection/detection.js";
import {
durationExceeded,
invalidTrimRange,
invalidVideoTransform,
unknownVideoFormat,
VideoError,
videoConfigInvalid,
videoMetadataParseFailed,
videoProcessingFailed,
} from "../errors/errors.js";
import {
getExtractCounter,
getExtractDuration,
getPlanCounter,
getPlanDuration,
getTracer,
} from "../instruments/instruments.js";
import { VideoPipelineBuilder } from "../pipeline-builder/pipeline-builder.js";
import { calculateVideoResize } from "../resize/resize.js";
import { validateTrimRange } from "../trim/trim.js";
import {
type VideoEventMap,
type VideoFormat,
type VideoMetadata,
type VideoProcessorConfig,
VideoProcessorConfigSchema,
type VideoTransformPipeline,
VideoTransformPipelineSchema,
type VideoTransformResult,
} from "../types/video-types/video-types.js";
function assertNotAborted(
signal: AbortSignal | undefined,
operation: string
): void {
if (signal?.aborted) {
const abortError = new Error("AbortError");
abortError.name = "AbortError";
throw videoProcessingFailed(`${operation} aborted`, abortError);
}
}
/**
* Extracts video metadata and plans transform pipelines.
*
* Universal: no Node-only or browser-only imports. Codec work is
* delegated to consumer-side ffmpeg / WebCodecs wrappers — this class
* only sniffs headers and plans the shape of the output.
*/
export class VideoProcessor {
private readonly maxDuration: number;
private readonly defaultFormat: VideoFormat;
private readonly bus: EventBus<VideoEventMap>;
public constructor(config?: VideoProcessorConfig) {
const parsed = VideoProcessorConfigSchema.safeParse(config ?? {});
if (!parsed.success) {
throw videoConfigInvalid(parsed.error.message);
}
this.maxDuration = parsed.data.maxDuration ?? 7200;
this.defaultFormat = parsed.data.defaultFormat ?? "mp4";
this.bus = new EventBus<VideoEventMap>();
}
public on<K extends keyof VideoEventMap>(
type: K,
listener: (payload: VideoEventMap[K]) => void
): () => void {
return this.bus.on(type, listener);
}
public once<K extends keyof VideoEventMap>(
type: K,
listener: (payload: VideoEventMap[K]) => void
): () => void {
return this.bus.once(type, listener);
}
public off<K extends keyof VideoEventMap>(
type: K,
listener: (payload: VideoEventMap[K]) => void
): void {
this.bus.off(type, listener);
}
public extractMetadata(
data: Uint8Array,
knownMetadata?: Partial<VideoMetadata>,
signal?: AbortSignal
): VideoMetadata {
assertNotAborted(signal, "extractMetadata");
const start = Date.now();
const span = getTracer().startSpan("video.metadata.extract");
let outcome = "success";
try {
const detected = detectVideoFormat(data);
const format = knownMetadata?.format ?? detected;
if (!format) {
outcome = "unknown_format";
throw unknownVideoFormat();
}
const metadata: VideoMetadata = {
format,
width: knownMetadata?.width ?? 0,
height: knownMetadata?.height ?? 0,
duration: knownMetadata?.duration ?? 0,
frameRate: knownMetadata?.frameRate ?? 0,
bitrate: knownMetadata?.bitrate,
videoCodec: knownMetadata?.videoCodec,
audioCodec: knownMetadata?.audioCodec,
hasAudio: knownMetadata?.hasAudio ?? true,
fileSize: data.byteLength,
};
if (metadata.duration > this.maxDuration) {
outcome = "duration_exceeded";
throw durationExceeded(metadata.duration, this.maxDuration);
}
this.bus.emit("metadata.extracted", metadata);
return metadata;
} catch (err) {
if (err instanceof VideoError) {
throw err;
}
outcome = "error";
throw videoMetadataParseFailed(
err instanceof Error ? err : undefined
);
} finally {
const durationMs = Date.now() - start;
getExtractCounter().add(1, { outcome });
getExtractDuration().record(durationMs, { outcome });
span.end();
}
}
public planTransform(
metadata: VideoMetadata,
pipeline: VideoTransformPipeline,
signal?: AbortSignal
): VideoTransformResult {
assertNotAborted(signal, "planTransform");
const start = Date.now();
const span = getTracer().startSpan("video.transform.plan");
let outcome = "success";
try {
const parsed = VideoTransformPipelineSchema.safeParse(pipeline);
if (!parsed.success) {
outcome = "invalid_pipeline";
throw invalidVideoTransform(parsed.error.message);
}
let width = metadata.width;
let height = metadata.height;
let duration = metadata.duration;
let format =
pipeline.outputFormat ?? metadata.format ?? this.defaultFormat;
let hasAudio = metadata.hasAudio;
for (const op of pipeline.operations) {
switch (op.type) {
case "resize": {
const dims = calculateVideoResize(
{ width, height },
op.options
);
width = dims.width;
height = dims.height;
break;
}
case "trim": {
if (!validateTrimRange(duration, op.range)) {
throw invalidTrimRange(
op.range.startTime,
op.range.endTime
);
}
duration = op.range.endTime - op.range.startTime;
break;
}
case "rotate": {
if (op.degrees % 90 !== 0) {
throw invalidVideoTransform(
`Rotation must be multiple of 90, got ${op.degrees}`
);
}
if (op.degrees % 180 !== 0) {
const tmp = width;
width = height;
height = tmp;
}
break;
}
case "flip":
break;
case "speed": {
if (op.factor <= 0) {
throw invalidVideoTransform(
`Speed factor must be positive, got ${op.factor}`
);
}
duration = duration / op.factor;
break;
}
case "mute":
hasAudio = false;
break;
case "format":
format = op.format;
break;
case "codec":
case "bitrate":
break;
}
}
const result: VideoTransformResult = {
width,
height,
format,
duration,
operations: pipeline.operations,
hasAudio,
};
this.bus.emit("transform.planned", result);
return result;
} catch (err) {
if (outcome === "success") outcome = "error";
throw err;
} finally {
const durationMs = Date.now() - start;
getPlanCounter().add(1, { outcome });
getPlanDuration().record(durationMs, { outcome });
span.end();
}
}
public createPipeline(): VideoPipelineBuilder {
return new VideoPipelineBuilder(this.bus);
}
}

View File

@@ -0,0 +1 @@
export { calculateVideoResize } from "./resize.js";

View File

@@ -0,0 +1,83 @@
# `resize` — video dimension math
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`calculateVideoResize` is the pure-math helper used by `VideoProcessor.planTransform`
to project a `resize` operation onto concrete output dimensions. It runs no
IO, allocates nothing beyond the returned object, and gives consumers a way
to compute the same dimensions a pipeline plan would produce without having
to construct a full `VideoProcessor`. This is the single place in the
package where aspect-ratio preservation lives, so any change to the
math must be reflected here — the processor delegates to this function and
does not duplicate logic.
The function honours three modes: preset (canonical resolution table),
explicit dimensions with aspect-ratio preservation, and explicit dimensions
without preservation. Every returned `{ width, height }` pair is rounded up
to the next even number because most codecs (H.264, H.265, VP9, AV1)
require even chroma sub-sampling alignment.
## Features
- Preset short-circuit: when `options.preset` is set, the corresponding
entry of `RESOLUTION_PRESETS` is returned verbatim.
- Aspect-ratio preservation by default — pass `maintainAspectRatio: false`
to opt out.
- Forced even-dimension rounding (`x + 1` when odd) ahead of the codec.
- Single-axis input: pass only `width` (or only `height`) and the other
axis is computed from `original.width / original.height`.
## Usage: Basic
```ts
import { calculateVideoResize } from "@suaveplan/video/resize/resize";
const dims = calculateVideoResize(
{ width: 1920, height: 1080 },
{ preset: "720p" }
);
console.log(dims); // { width: 1280, height: 720 }
```
## Usage: Advanced
```ts
import { calculateVideoResize } from "@suaveplan/video/resize/resize";
// Single-axis with aspect-ratio preservation.
const dims = calculateVideoResize(
{ width: 1920, height: 1080 },
{ width: 960 }
);
console.log(dims); // { width: 960, height: 540 }
// Explicit non-AR-preserving resize — the helper still rounds up to even.
const stretched = calculateVideoResize(
{ width: 1920, height: 1080 },
{ width: 641, height: 481, maintainAspectRatio: false }
);
console.log(stretched); // { width: 642, height: 482 }
```
## API Reference
- `calculateVideoResize(original: VideoDimensions, options: VideoResizeOptions): VideoDimensions`
— returns a fresh `{ width, height }` object. Inputs are not mutated.
## Implementation Notes
The aspect-ratio branch only fires when exactly one of `options.width`
or `options.height` is set. When both are set the function trusts the
caller (subject to the even-rounding guard). When neither is set the
original dimensions pass through, again subject to the even-rounding
guard. The preset branch bypasses the aspect-ratio code entirely because
the preset table is the canonical source of truth.
Odd-to-even rounding always increments by one rather than decrementing —
this matches the upstream behaviour and avoids accidentally clipping
content when the caller asked for a tight crop. Consumers that need the
opposite (round down) can pre-round their dimensions before calling.

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RESOLUTION_PRESETS } from "../types/video-types/video-types.js";
import { calculateVideoResize } from "./resize.js";
describe("calculateVideoResize", () => {
it("uses preset dimensions", { timeout: 5000 }, () => {
const r = calculateVideoResize(
{ width: 1920, height: 1080 },
{ preset: "720p" }
);
expect(r).toEqual({ width: 1280, height: 720 });
});
it("maintains aspect ratio by default for single-axis width", {
timeout: 5000,
}, () => {
const r = calculateVideoResize(
{ width: 1920, height: 1080 },
{ width: 960 }
);
expect(r).toEqual({ width: 960, height: 540 });
});
it("maintains aspect ratio by default for single-axis height", {
timeout: 5000,
}, () => {
const r = calculateVideoResize(
{ width: 1920, height: 1080 },
{ height: 540 }
);
expect(r).toEqual({ width: 960, height: 540 });
});
it("ensures even dimensions", { timeout: 5000 }, () => {
const r = calculateVideoResize(
{ width: 1920, height: 1080 },
{ width: 641, height: 481, maintainAspectRatio: false }
);
expect(r.width % 2).toBe(0);
expect(r.height % 2).toBe(0);
expect(r).toEqual({ width: 642, height: 482 });
});
it("returns original when no options supplied", { timeout: 5000 }, () => {
const r = calculateVideoResize({ width: 1920, height: 1080 }, {});
expect(r).toEqual({ width: 1920, height: 1080 });
});
it("skips aspect-ratio when both axes are supplied", {
timeout: 5000,
}, () => {
const r = calculateVideoResize(
{ width: 1920, height: 1080 },
{ width: 800, height: 800 }
);
expect(r).toEqual({ width: 800, height: 800 });
});
it("honours maintainAspectRatio: false", { timeout: 5000 }, () => {
const r = calculateVideoResize(
{ width: 1920, height: 1080 },
{ width: 960, maintainAspectRatio: false }
);
expect(r).toEqual({ width: 960, height: 1080 });
});
it("all presets are even", { timeout: 5000 }, () => {
for (const preset of Object.keys(RESOLUTION_PRESETS) as Array<
keyof typeof RESOLUTION_PRESETS
>) {
const r = calculateVideoResize(
{ width: 1920, height: 1080 },
{ preset }
);
expect(r.width % 2).toBe(0);
expect(r.height % 2).toBe(0);
}
});
});

View File

@@ -0,0 +1,49 @@
import {
RESOLUTION_PRESETS,
type VideoDimensions,
type VideoResizeOptions,
} from "../types/video-types/video-types.js";
/**
* Compute target dimensions for a resize operation.
*
* - Preset (when supplied) takes precedence and is used verbatim.
* - Otherwise width/height are honoured, with the missing dimension
* computed from the source aspect ratio unless
* `maintainAspectRatio === false`.
* - The result is always rounded UP to the next even pair — most codecs
* require even chroma sub-sampling alignment.
*/
export function calculateVideoResize(
original: VideoDimensions,
options: VideoResizeOptions
): VideoDimensions {
let tw: number;
let th: number;
if (options.preset) {
const preset = RESOLUTION_PRESETS[options.preset];
tw = preset.width;
th = preset.height;
} else {
tw = options.width ?? original.width;
th = options.height ?? original.height;
}
if (options.maintainAspectRatio !== false && !options.preset) {
const ratio = original.width / original.height;
if (options.width !== undefined && options.height === undefined) {
th = Math.round(tw / ratio);
} else if (
options.width === undefined &&
options.height !== undefined
) {
tw = Math.round(th * ratio);
}
}
tw = tw % 2 === 0 ? tw : tw + 1;
th = th % 2 === 0 ? th : th + 1;
return { width: tw, height: th };
}

View File

@@ -0,0 +1 @@
export { validateTrimRange } from "./trim.js";

View File

@@ -0,0 +1,73 @@
# `trim` — trim-range validation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`validateTrimRange` is the single source of truth for "does this trim
range make sense for a video of length `duration`". It returns a plain
boolean so callers can decide whether to surface
`VideoError(VIDEO.INVALID_TRIM_RANGE)`, return a 4xx to a user, or fall
back to a default range. The function lives in its own module so it can
be tree-shaken into bundles that only need range validation (e.g.
client-side form validators for a trim UI) without dragging in the full
processor + builder graph.
The rules are intentionally strict: the start must be non-negative,
the end must be strictly greater than the start, and the end must not
exceed the supplied duration. The function does not coerce, clamp, or
normalise — it answers the boolean question and lets the caller decide
what to do.
## Features
- Three independent boundary checks: start non-negative, end greater
than start, end within duration.
- Pure function — runs in any tier (universal).
## Usage: Basic
```ts
import { validateTrimRange } from "@suaveplan/video/trim/trim";
console.log(validateTrimRange(60, { startTime: 10, endTime: 30 })); // true
console.log(validateTrimRange(60, { startTime: 70, endTime: 80 })); // false (end > duration)
```
## Usage: Advanced
```ts
import { validateTrimRange } from "@suaveplan/video/trim/trim";
import { invalidTrimRange } from "@suaveplan/video/errors/errors";
function assertTrimRange(duration: number, start: number, end: number) {
const range = { startTime: start, endTime: end };
if (!validateTrimRange(duration, range)) {
throw invalidTrimRange(start, end);
}
return range;
}
```
## API Reference
- `validateTrimRange(duration: number, range: TrimRange): boolean`
## Implementation Notes
The function uses `>` (not `>=`) for the end-vs-start comparison so a
zero-length trim returns `false` — a trim with `start === end` would
produce a zero-frame clip and is virtually never the caller's intent.
The function deliberately does not validate that `duration` itself is
non-negative; supplying a negative `duration` will cause the third check
to always fail, which is the correct behaviour (no range is valid for a
nonsense duration).
The trim-range validator is intentionally separate from
`VideoTransformPipelineSchema`: the schema validates the **shape** of a
trim op, while `validateTrimRange` validates the **semantics** against a
specific source duration. Both layers are required at runtime — the
processor calls the validator while walking the pipeline because the
shape-validation step does not know the source video's duration.

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { validateTrimRange } from "./trim.js";
describe("validateTrimRange", () => {
it("accepts valid range", { timeout: 5000 }, () => {
expect(validateTrimRange(60, { startTime: 10, endTime: 30 })).toBe(
true
);
});
it("accepts full duration trim", { timeout: 5000 }, () => {
expect(validateTrimRange(60, { startTime: 0, endTime: 60 })).toBe(true);
});
it("rejects negative start", { timeout: 5000 }, () => {
expect(validateTrimRange(60, { startTime: -1, endTime: 30 })).toBe(
false
);
});
it("rejects end before start", { timeout: 5000 }, () => {
expect(validateTrimRange(60, { startTime: 30, endTime: 10 })).toBe(
false
);
});
it("rejects zero-length range", { timeout: 5000 }, () => {
expect(validateTrimRange(60, { startTime: 10, endTime: 10 })).toBe(
false
);
});
it("rejects end beyond duration", { timeout: 5000 }, () => {
expect(validateTrimRange(60, { startTime: 10, endTime: 70 })).toBe(
false
);
});
});

View File

@@ -0,0 +1,12 @@
import type { TrimRange } from "../types/video-types/video-types.js";
/**
* Return `true` iff `range` is a non-empty trim range fully contained
* within `[0, duration]`.
*/
export function validateTrimRange(duration: number, range: TrimRange): boolean {
if (range.startTime < 0) return false;
if (range.endTime <= range.startTime) return false;
if (range.endTime > duration) return false;
return true;
}

View File

@@ -0,0 +1,29 @@
export {
type AudioCodec,
AudioCodecSchema,
RESOLUTION_PRESETS,
type ResolutionPreset,
ResolutionPresetSchema,
type TrimRange,
TrimRangeSchema,
VIDEO_MIME_TYPES,
type VideoCodec,
VideoCodecSchema,
type VideoDimensions,
VideoDimensionsSchema,
type VideoEventMap,
type VideoFormat,
VideoFormatSchema,
type VideoMetadata,
VideoMetadataSchema,
type VideoProcessorConfig,
VideoProcessorConfigSchema,
type VideoResizeOptions,
VideoResizeOptionsSchema,
type VideoTransformOp,
VideoTransformOpSchema,
type VideoTransformPipeline,
VideoTransformPipelineSchema,
type VideoTransformResult,
VideoTransformResultSchema,
} from "./video-types.js";

View File

@@ -0,0 +1,111 @@
# `video-types` — schemas, constants, and inferred types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the type foundation for the entire `@suaveplan/video`
package. Every public surface — `VideoProcessor`, `VideoPipelineBuilder`,
helper functions, error contexts — references a type or schema declared
here. Schemas are Zod-first and the TypeScript types are inferred from them
where possible (or restated as explicit `readonly` interfaces when the
schema's inferred shape would otherwise be too loose).
The module also owns the two canonical lookup tables: `RESOLUTION_PRESETS`
(six common video resolutions with width/height) and `VIDEO_MIME_TYPES`
(MIME string per supported container). Both are frozen `Readonly<Record>`
shapes so consumers can rely on the keys at compile time.
## Features
- 8-variant `VideoFormatSchema` covering MP4, WebM, AVI, MKV, MOV, FLV,
WMV, OGG.
- 6-variant `VideoCodecSchema` plus 6-variant `AudioCodecSchema`.
- 6-variant `ResolutionPresetSchema` (360p / 480p / 720p / 1080p / 1440p /
4k).
- `VideoMetadataSchema`, `TrimRangeSchema`, `VideoResizeOptionsSchema`,
`VideoProcessorConfigSchema`, `VideoTransformPipelineSchema`,
`VideoTransformResultSchema`.
- `VideoTransformOpSchema` — `z.discriminatedUnion("type", [...])` over
the 9 op variants: resize / trim / rotate / flip / speed / mute /
format / codec / bitrate.
- `VideoEventMap` — single-payload `Record<topic, Payload>` shape for
`EventBus<TMap>`.
- `RESOLUTION_PRESETS` and `VIDEO_MIME_TYPES` constant tables.
## Usage: Basic
```ts
import {
VideoTransformPipelineSchema,
type VideoTransformPipeline,
} from "@suaveplan/video/types/video-types";
const pipeline: VideoTransformPipeline = {
operations: [{ type: "resize", options: { preset: "720p" } }],
outputFormat: "webm",
};
const parsed = VideoTransformPipelineSchema.parse(pipeline);
```
## Usage: Advanced
```ts
import {
RESOLUTION_PRESETS,
VIDEO_MIME_TYPES,
VideoTransformOpSchema,
type ResolutionPreset,
type VideoFormat,
} from "@suaveplan/video/types/video-types";
function mimeFor(format: VideoFormat): string {
return VIDEO_MIME_TYPES[format];
}
function dimsFor(preset: ResolutionPreset) {
return RESOLUTION_PRESETS[preset];
}
// Discriminated union — branch on `op.type`.
const op = VideoTransformOpSchema.parse({
type: "rotate",
degrees: 90,
});
if (op.type === "rotate") {
console.log("rotating by", op.degrees);
}
```
## API Reference
- Schemas: `VideoFormatSchema`, `VideoCodecSchema`, `AudioCodecSchema`,
`ResolutionPresetSchema`, `VideoDimensionsSchema`, `VideoMetadataSchema`,
`TrimRangeSchema`, `VideoResizeOptionsSchema`, `VideoTransformOpSchema`,
`VideoTransformPipelineSchema`, `VideoTransformResultSchema`,
`VideoProcessorConfigSchema`.
- Types: `VideoFormat`, `VideoCodec`, `AudioCodec`, `ResolutionPreset`,
`VideoDimensions`, `VideoMetadata`, `TrimRange`, `VideoResizeOptions`,
`VideoTransformOp`, `VideoTransformPipeline`, `VideoTransformResult`,
`VideoProcessorConfig`, `VideoEventMap`.
- Constants: `RESOLUTION_PRESETS` (6 entries), `VIDEO_MIME_TYPES` (8
entries).
## Implementation Notes
`VideoTransformOpSchema` uses `z.discriminatedUnion("type", …)` rather than
a plain `z.union(…)` so unknown discriminant values fail fast with a clear
error path (`type: "explode"` returns `"Invalid discriminator value …"`
rather than a generic "all union members failed"). Each variant carries its
own typed payload; consumers using TypeScript narrowing get exhaustiveness
checks automatically.
The interfaces for shapes like `VideoMetadata` are restated as
`readonly`-property interfaces rather than relying on
`z.infer<typeof VideoMetadataSchema>` directly because `z.infer` widens
optional fields to `T | undefined` and the explicit `readonly` keyword
helps document the immutability contract the processor relies on. Both
forms are kept in sync — any schema change must update both halves.

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
RESOLUTION_PRESETS,
VIDEO_MIME_TYPES,
VideoMetadataSchema,
VideoProcessorConfigSchema,
VideoTransformOpSchema,
VideoTransformPipelineSchema,
VideoTransformResultSchema,
} from "./video-types.js";
describe("VideoTransformOpSchema", () => {
it("VideoTransformOpSchema parses every variant", { timeout: 5000 }, () => {
const variants = [
{ type: "resize" as const, options: { preset: "720p" as const } },
{
type: "trim" as const,
range: { startTime: 0, endTime: 30 },
},
{ type: "rotate" as const, degrees: 90 },
{ type: "flip" as const, direction: "horizontal" as const },
{ type: "speed" as const, factor: 2 },
{ type: "mute" as const },
{ type: "format" as const, format: "mp4" as const },
{ type: "codec" as const, codec: "h265" as const },
{ type: "bitrate" as const, value: 5_000_000 },
];
for (const v of variants) {
expect(() => VideoTransformOpSchema.parse(v)).not.toThrow();
}
});
it("VideoTransformOpSchema rejects unknown discriminant", {
timeout: 5000,
}, () => {
const parsed = VideoTransformOpSchema.safeParse({ type: "explode" });
expect(parsed.success).toBe(false);
});
});
describe("VideoMetadataSchema", () => {
it("parses a full metadata object", { timeout: 5000 }, () => {
const meta = {
format: "mp4" as const,
width: 1920,
height: 1080,
duration: 60,
frameRate: 30,
hasAudio: true,
};
expect(VideoMetadataSchema.parse(meta)).toEqual(meta);
});
it("rejects negative width", { timeout: 5000 }, () => {
const result = VideoMetadataSchema.safeParse({
format: "mp4",
width: -1,
height: 1080,
duration: 60,
frameRate: 30,
hasAudio: true,
});
expect(result.success).toBe(false);
});
});
describe("VideoTransformPipelineSchema", () => {
it("parses an empty pipeline", { timeout: 5000 }, () => {
const parsed = VideoTransformPipelineSchema.parse({ operations: [] });
expect(parsed.operations).toHaveLength(0);
});
});
describe("VideoTransformResultSchema", () => {
it("parses a typical result", { timeout: 5000 }, () => {
const result = VideoTransformResultSchema.parse({
width: 1280,
height: 720,
format: "webm",
duration: 30,
operations: [],
hasAudio: true,
});
expect(result.width).toBe(1280);
});
});
describe("VideoProcessorConfigSchema", () => {
it("accepts an empty config", { timeout: 5000 }, () => {
expect(VideoProcessorConfigSchema.parse({})).toEqual({});
});
it("rejects negative maxDuration", { timeout: 5000 }, () => {
const r = VideoProcessorConfigSchema.safeParse({ maxDuration: -1 });
expect(r.success).toBe(false);
});
});
describe("RESOLUTION_PRESETS", () => {
it("has 6 entries", { timeout: 5000 }, () => {
expect(Object.keys(RESOLUTION_PRESETS)).toHaveLength(6);
});
it("4k is 3840x2160", { timeout: 5000 }, () => {
expect(RESOLUTION_PRESETS["4k"]).toEqual({ width: 3840, height: 2160 });
});
it("720p is 1280x720", { timeout: 5000 }, () => {
expect(RESOLUTION_PRESETS["720p"]).toEqual({
width: 1280,
height: 720,
});
});
});
describe("VIDEO_MIME_TYPES", () => {
it("has 8 entries", { timeout: 5000 }, () => {
expect(Object.keys(VIDEO_MIME_TYPES)).toHaveLength(8);
});
it("mp4 maps to video/mp4", { timeout: 5000 }, () => {
expect(VIDEO_MIME_TYPES.mp4).toBe("video/mp4");
});
});

View File

@@ -0,0 +1,231 @@
import z from "zod";
export const VideoFormatSchema = z.enum([
"mp4",
"webm",
"avi",
"mkv",
"mov",
"flv",
"wmv",
"ogg",
]);
export type VideoFormat = z.infer<typeof VideoFormatSchema>;
export const VideoCodecSchema = z.enum([
"h264",
"h265",
"vp8",
"vp9",
"av1",
"mpeg4",
]);
export type VideoCodec = z.infer<typeof VideoCodecSchema>;
export const AudioCodecSchema = z.enum([
"aac",
"mp3",
"opus",
"vorbis",
"flac",
"pcm",
]);
export type AudioCodec = z.infer<typeof AudioCodecSchema>;
export const ResolutionPresetSchema = z.enum([
"360p",
"480p",
"720p",
"1080p",
"1440p",
"4k",
]);
export type ResolutionPreset = z.infer<typeof ResolutionPresetSchema>;
export const VideoDimensionsSchema = z.object({
width: z.number().int().nonnegative(),
height: z.number().int().nonnegative(),
});
export interface VideoDimensions {
readonly width: number;
readonly height: number;
}
export const VideoMetadataSchema = z.object({
format: VideoFormatSchema,
width: z.number().int().nonnegative(),
height: z.number().int().nonnegative(),
duration: z.number().nonnegative(),
frameRate: z.number().nonnegative(),
bitrate: z.number().nonnegative().optional(),
videoCodec: VideoCodecSchema.optional(),
audioCodec: AudioCodecSchema.optional(),
hasAudio: z.boolean(),
fileSize: z.number().int().nonnegative().optional(),
});
export interface VideoMetadata {
readonly format: VideoFormat;
readonly width: number;
readonly height: number;
readonly duration: number;
readonly frameRate: number;
readonly bitrate?: number;
readonly videoCodec?: VideoCodec;
readonly audioCodec?: AudioCodec;
readonly hasAudio: boolean;
readonly fileSize?: number;
}
export const TrimRangeSchema = z.object({
startTime: z.number().nonnegative(),
endTime: z.number().nonnegative(),
});
export interface TrimRange {
readonly startTime: number;
readonly endTime: number;
}
export const VideoResizeOptionsSchema = z.object({
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
preset: ResolutionPresetSchema.optional(),
maintainAspectRatio: z.boolean().optional(),
});
export interface VideoResizeOptions {
readonly width?: number;
readonly height?: number;
readonly preset?: ResolutionPreset;
readonly maintainAspectRatio?: boolean;
}
const ResizeOpSchema = z.object({
type: z.literal("resize"),
options: VideoResizeOptionsSchema,
});
const TrimOpSchema = z.object({
type: z.literal("trim"),
range: TrimRangeSchema,
});
const RotateOpSchema = z.object({
type: z.literal("rotate"),
degrees: z.number().int(),
});
const FlipOpSchema = z.object({
type: z.literal("flip"),
direction: z.enum(["horizontal", "vertical"]),
});
const SpeedOpSchema = z.object({
type: z.literal("speed"),
factor: z.number(),
});
const MuteOpSchema = z.object({
type: z.literal("mute"),
});
const FormatOpSchema = z.object({
type: z.literal("format"),
format: VideoFormatSchema,
});
const CodecOpSchema = z.object({
type: z.literal("codec"),
codec: VideoCodecSchema,
});
const BitrateOpSchema = z.object({
type: z.literal("bitrate"),
value: z.number().positive(),
});
export const VideoTransformOpSchema = z.discriminatedUnion("type", [
ResizeOpSchema,
TrimOpSchema,
RotateOpSchema,
FlipOpSchema,
SpeedOpSchema,
MuteOpSchema,
FormatOpSchema,
CodecOpSchema,
BitrateOpSchema,
]);
export type VideoTransformOp = z.infer<typeof VideoTransformOpSchema>;
export const VideoTransformPipelineSchema = z.object({
operations: z.array(VideoTransformOpSchema),
outputFormat: VideoFormatSchema.optional(),
});
export interface VideoTransformPipeline {
readonly operations: readonly VideoTransformOp[];
readonly outputFormat?: VideoFormat;
}
export const VideoTransformResultSchema = z.object({
width: z.number().int().nonnegative(),
height: z.number().int().nonnegative(),
format: VideoFormatSchema,
duration: z.number().nonnegative(),
operations: z.array(VideoTransformOpSchema),
hasAudio: z.boolean(),
});
export interface VideoTransformResult {
readonly width: number;
readonly height: number;
readonly format: VideoFormat;
readonly duration: number;
readonly operations: readonly VideoTransformOp[];
readonly hasAudio: boolean;
}
export const VideoProcessorConfigSchema = z.object({
maxDuration: z.number().positive().optional(),
defaultFormat: VideoFormatSchema.optional(),
});
export interface VideoProcessorConfig {
readonly maxDuration?: number;
readonly defaultFormat?: VideoFormat;
}
export type VideoEventMap = {
"metadata.extracted": VideoMetadata;
"transform.planned": VideoTransformResult;
"pipeline.built": VideoTransformPipeline;
};
export const RESOLUTION_PRESETS: Readonly<
Record<ResolutionPreset, VideoDimensions>
> = {
"360p": { width: 640, height: 360 },
"480p": { width: 854, height: 480 },
"720p": { width: 1280, height: 720 },
"1080p": { width: 1920, height: 1080 },
"1440p": { width: 2560, height: 1440 },
"4k": { width: 3840, height: 2160 },
};
export const VIDEO_MIME_TYPES: Readonly<Record<VideoFormat, string>> = {
mp4: "video/mp4",
webm: "video/webm",
avi: "video/x-msvideo",
mkv: "video/x-matroska",
mov: "video/quicktime",
flv: "video/x-flv",
wmv: "video/x-ms-wmv",
ogg: "video/ogg",
};

View File

@@ -0,0 +1 @@
import "@suaveplan/testing/polyfills";

View File

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

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: {
index: "./src/index.ts",
"error-codes/error-codes": "./src/error-codes/error-codes.ts",
"errors/errors": "./src/errors/errors.ts",
"types/video-types": "./src/types/video-types/video-types.ts",
"detection/detection": "./src/detection/detection.ts",
"resize/resize": "./src/resize/resize.ts",
"trim/trim": "./src/trim/trim.ts",
"duration/duration": "./src/duration/duration.ts",
"format/format": "./src/format/format.ts",
"instruments/instruments":
"./src/instruments/instruments.ts",
"processor/processor": "./src/processor/processor.ts",
"pipeline-builder/pipeline-builder":
"./src/pipeline-builder/pipeline-builder.ts",
},
formats: ["es"],
},
sourcemap: true,
rollupOptions: {
external: [/^@suaveplan\//, /^@opentelemetry\//, "zod", "tslib"],
},
},
plugins: [dts({ rollupTypes: false, include: ["src/**/*.ts"], tsconfigPath: "./tsconfig.lib.json" })],
});

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: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});