first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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 browser-onl… |
| [`@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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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.