first commit
This commit is contained in:
14
docs/media/video/README.md
Normal file
14
docs/media/video/README.md
Normal 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 -->
|
||||
1
docs/media/video/_intro.md
Normal file
1
docs/media/video/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Video encoding, decoding, and thumbnail generation.
|
||||
85
docs/media/video/video-thumbnails/README.md
Normal file
85
docs/media/video/video-thumbnails/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# @suaveplan/video-thumbnails - Video thumbnail extraction and sprite-sheet generation backed by an injectable FfmpegRunner (fluent-ffmpeg + @ffmpeg-installer by default).
|
||||
|
||||
Video thumbnail extraction and sprite-sheet generation backed by an injectable
|
||||
`FfmpegRunner`. The default runner — `FluentFfmpegRunner` — wraps `fluent-ffmpeg` and pins
|
||||
ffmpeg/ffprobe to the binaries shipped by `@ffmpeg-installer/ffmpeg` and
|
||||
`@ffprobe-installer/ffprobe`, so the package works on any host without requiring those
|
||||
binaries on `PATH`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `VIDEO_THUMBNAILS_CODES` is the stable, frozen registry of every failure mode raised by `@suaveplan/video-thumbnails`.…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `VIDEO_THUMBNAILS_CODES` is the stable, frozen registry of every failure mode raised by `@suaveplan/video-thumbnails`.…
|
||||
- [errors](./src/errors/video-thumbnails-error.md) — `VideoThumbnailsError` is the single error class raised by every public surface of `@suaveplan/video-thumbnails`. It ex…
|
||||
- [video-thumbnails-error](./src/errors/video-thumbnails-error.md) — `VideoThumbnailsError` is the single error class raised by every public surface of `@suaveplan/video-thumbnails`. It ex…
|
||||
- [runner](./src/runner/runner.md) — The runner module defines the `FfmpegRunner` interface plus a handful of pure helpers used by every implementation: `bu…
|
||||
- [fluent-ffmpeg-runner](./src/runner/fluent-ffmpeg-runner.md) — Default `FfmpegRunner` implementation used by `ThumbnailGenerator` and `SpriteSheetGenerator` when no other runner is i…
|
||||
- [runner](./src/runner/runner.md) — The runner module defines the `FfmpegRunner` interface plus a handful of pure helpers used by every implementation: `bu…
|
||||
- [schemas](./src/schemas/schemas.md) — Defines every Zod schema and inferred TypeScript type that crosses the public boundary of `@suaveplan/video-thumbnails`…
|
||||
- [schemas](./src/schemas/schemas.md) — Defines every Zod schema and inferred TypeScript type that crosses the public boundary of `@suaveplan/video-thumbnails`…
|
||||
- [sprite-sheet](./src/sprite-sheet/sprite-sheet.md) — `SpriteSheetGenerator` renders an N×M grid of thumbnails into a single image using ffmpeg's `tile` filter. A sprite she…
|
||||
- [sprite-sheet](./src/sprite-sheet/sprite-sheet.md) — `SpriteSheetGenerator` renders an N×M grid of thumbnails into a single image using ffmpeg's `tile` filter. A sprite she…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Centralises the OpenTelemetry instruments owned by `@suaveplan/video-thumbnails`. A meter is acquired lazily — only whe…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Centralises the OpenTelemetry instruments owned by `@suaveplan/video-thumbnails`. A meter is acquired lazily — only whe…
|
||||
- [thumbnail-generator](./src/thumbnail-generator/thumbnail-generator.md) — `ThumbnailGenerator` is the primary class consumers reach for to extract still frames from a video file. It accepts a Z…
|
||||
- [thumbnail-generator](./src/thumbnail-generator/thumbnail-generator.md) — `ThumbnailGenerator` is the primary class consumers reach for to extract still frames from a video file. It accepts a Z…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/video-thumbnails
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
ThumbnailGenerator,
|
||||
SpriteSheetGenerator,
|
||||
} from "@suaveplan/video-thumbnails";
|
||||
|
||||
const thumb = new ThumbnailGenerator({ outputDir: "/tmp/thumbs" });
|
||||
const result = await thumb.generate("/data/clip.mp4", { timestamp: "00:00:02" });
|
||||
console.log(result.outputPath, result.sizeBytes);
|
||||
|
||||
const sprite = new SpriteSheetGenerator({ columns: 5, rows: 5, interval: 2 });
|
||||
const sheet = await sprite.generate("/data/clip.mp4", "/tmp/sprite.jpg");
|
||||
console.log(sheet.frameCount, sheet.width, sheet.height);
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Single-frame extract, multi-timestamp batch, sliding-window sampling.
|
||||
- Sprite-sheet generation via ffmpeg's `tile` filter with auto-duration detection.
|
||||
- jpeg / png / webp output for thumbnails; jpeg / png for sprite sheets.
|
||||
- Zod-validated boundaries — `INVALID_DIMENSIONS`, `INVALID_TIMESTAMP`, `FORMAT_UNSUPPORTED`,
|
||||
`INVALID_OPTIONS` are typed failures.
|
||||
- `AbortSignal` propagation kills the underlying ffmpeg child process and raises `ABORTED`.
|
||||
- OTel: one counter and three histograms keyed by `outcome`, `format`, `fit`.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes`](./src/error-codes/error-codes.md) — `VIDEO_THUMBNAILS_CODES` registry.
|
||||
- [`errors/video-thumbnails-error`](./src/errors/video-thumbnails-error.md) — typed error class.
|
||||
- [`schemas`](./src/schemas/schemas.md) — Zod schemas + format / fit constants.
|
||||
- [`telemetry`](./src/telemetry/telemetry.md) — OTel counter + histograms.
|
||||
- [`runner`](./src/runner/runner.md) — `FfmpegRunner` interface + filter helpers.
|
||||
- [`runner/fluent-ffmpeg-runner`](./src/runner/fluent-ffmpeg-runner.md) — default runner.
|
||||
- [`thumbnail-generator`](./src/thumbnail-generator/thumbnail-generator.md) — single-frame +
|
||||
batch + sliding-window extractor.
|
||||
- [`sprite-sheet`](./src/sprite-sheet/sprite-sheet.md) — N×M sprite-sheet generator.
|
||||
|
||||
## Error codes
|
||||
|
||||
`VIDEO_THUMBNAILS.EXTRACT_FAILED`, `VIDEO_THUMBNAILS.PROBE_FAILED`,
|
||||
`VIDEO_THUMBNAILS.VIDEO_INVALID`, `VIDEO_THUMBNAILS.INVALID_TIMESTAMP`,
|
||||
`VIDEO_THUMBNAILS.INVALID_DIMENSIONS`, `VIDEO_THUMBNAILS.INVALID_OPTIONS`,
|
||||
`VIDEO_THUMBNAILS.FORMAT_UNSUPPORTED`, `VIDEO_THUMBNAILS.RUNNER_UNAVAILABLE`,
|
||||
`VIDEO_THUMBNAILS.ABORTED`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`VIDEO_THUMBNAILS_CODES` is the stable, frozen registry of every failure mode raised by
|
||||
`@suaveplan/video-thumbnails`. It is built with `defineCodes` from `@suaveplan/error` so the
|
||||
namespace prefix (`VIDEO_THUMBNAILS.`) is encoded in the literal type — meaning consumers can
|
||||
`switch` on a `VideoThumbnailsCode` and TypeScript will enforce exhaustiveness. The registry is
|
||||
the single source of truth: every `throw new VideoThumbnailsError(...)` references a code from
|
||||
this object, and the conformance test walks the registry to prove no code is dead.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen, namespaced registry built via `defineCodes("VIDEO_THUMBNAILS", [...])`.
|
||||
- Literal union `VideoThumbnailsCode` derived from the registry values.
|
||||
- Every code reachable from a real call site in `src/`, validated by `error-codes.test.ts`.
|
||||
- Compatible with the project-wide `SuaveplanError` wire format, OTel span events, and
|
||||
redaction.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { VIDEO_THUMBNAILS_CODES } from "@suaveplan/video-thumbnails/error-codes";
|
||||
|
||||
console.log(VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED); // "VIDEO_THUMBNAILS.EXTRACT_FAILED"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
VIDEO_THUMBNAILS_CODES,
|
||||
type VideoThumbnailsCode,
|
||||
} from "@suaveplan/video-thumbnails/error-codes";
|
||||
|
||||
function describe(code: VideoThumbnailsCode): string {
|
||||
switch (code) {
|
||||
case VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED:
|
||||
return "ffmpeg failed mid-extract";
|
||||
case VIDEO_THUMBNAILS_CODES.PROBE_FAILED:
|
||||
return "ffprobe failed";
|
||||
case VIDEO_THUMBNAILS_CODES.VIDEO_INVALID:
|
||||
return "input is not a valid video";
|
||||
case VIDEO_THUMBNAILS_CODES.INVALID_TIMESTAMP:
|
||||
return "timestamp is not HH:MM:SS(.mmm)?";
|
||||
case VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS:
|
||||
return "width/height/count <= 0";
|
||||
case VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS:
|
||||
return "Zod boundary validation failed";
|
||||
case VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED:
|
||||
return "output format not in jpg|png|webp";
|
||||
case VIDEO_THUMBNAILS_CODES.RUNNER_UNAVAILABLE:
|
||||
return "ffmpeg binary not available";
|
||||
case VIDEO_THUMBNAILS_CODES.ABORTED:
|
||||
return "AbortSignal triggered";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `VIDEO_THUMBNAILS_CODES` — `Readonly<Record<Reason, "VIDEO_THUMBNAILS.<Reason>">>` for the
|
||||
nine codes `EXTRACT_FAILED`, `PROBE_FAILED`, `VIDEO_INVALID`, `INVALID_TIMESTAMP`,
|
||||
`INVALID_DIMENSIONS`, `INVALID_OPTIONS`, `FORMAT_UNSUPPORTED`, `RUNNER_UNAVAILABLE`,
|
||||
`ABORTED`. Frozen at construction.
|
||||
- `VideoThumbnailsCode` — literal union of every value in the registry; use as the parameter
|
||||
type for handlers that need to switch on failure mode.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is intentionally minimal — codes are added only when there is a corresponding
|
||||
`throw` site in `src/`. Removing a code requires a deprecation window: keep the entry in the
|
||||
registry, raise the new code from the call site, then drop the entry in a follow-up change so
|
||||
external callers have one release to migrate `case` clauses. Codes never carry payload — that
|
||||
lives on `SuaveplanError.context`. The frozen object means accidental mutation throws at
|
||||
runtime in strict mode, which preserves the registry-as-contract invariant.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `VideoThumbnailsError`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`VideoThumbnailsError` is the single error class raised by every public surface of
|
||||
`@suaveplan/video-thumbnails`. It extends `SuaveplanError<VideoThumbnailsCode>` so it inherits
|
||||
the project-wide wire format (`toJSON` / `fromJSON`), the OTel span-event adapter, the
|
||||
redaction pipeline, the cause-chain walker, and the i18n resolver hook. Every constructor call
|
||||
must reference a code from `VIDEO_THUMBNAILS_CODES`, which keeps the registry and the call
|
||||
sites in lock-step.
|
||||
|
||||
## Features
|
||||
|
||||
- Single class for the entire failure surface — no parallel hierarchies to keep in sync.
|
||||
- Code is parameterised, so TypeScript narrows on `error.code` at catch sites.
|
||||
- Carries `context` (Zod issues, ffmpeg stderr, abort reason) without bloating the message.
|
||||
- Compatible with `walkCauses`, `toSpanEvent`, and the JSON / binary serializers exported by
|
||||
`@suaveplan/error`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { VideoThumbnailsError } from "@suaveplan/video-thumbnails/errors";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "@suaveplan/video-thumbnails/error-codes";
|
||||
|
||||
throw new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
"videoThumbnails.errors.extractFailed",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { VideoThumbnailsError } from "@suaveplan/video-thumbnails/errors";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "@suaveplan/video-thumbnails/error-codes";
|
||||
|
||||
try {
|
||||
await generator.generate("/missing.mp4");
|
||||
} catch (error) {
|
||||
if (error instanceof VideoThumbnailsError) {
|
||||
if (error.code === VIDEO_THUMBNAILS_CODES.PROBE_FAILED) {
|
||||
await reportMetric("probe-failed", error.context);
|
||||
return;
|
||||
}
|
||||
if (error.code === VIDEO_THUMBNAILS_CODES.ABORTED) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class VideoThumbnailsError extends SuaveplanError<VideoThumbnailsCode>`
|
||||
- `constructor(code, messageKey, options?)`
|
||||
- `code` — one of `VIDEO_THUMBNAILS_CODES.*`
|
||||
- `messageKey` — i18n key (caller resolves the human-readable string)
|
||||
- `options` — `SuaveplanErrorOptions` (`{ cause?, context?, severity?, retryable?, httpStatus? }`)
|
||||
- Inherited members: `code`, `messageKey`, `context`, `cause`, `severity`, `retryable`,
|
||||
`httpStatus`, `toJSON()`, `toSpanEvent()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class sets `this.name = "VideoThumbnailsError"` so structured logs identify the class
|
||||
even when prototypes are stripped (some test runners walk `name` rather than the prototype
|
||||
chain). It does not override `toJSON` — the base `SuaveplanError` serializer already covers
|
||||
the wire format. When a Zod validation fails the schema layer attaches the `ZodError.issues`
|
||||
array under `context.issues`, which the redactor passes through unmodified because Zod issues
|
||||
do not contain sensitive material in this package's option space.
|
||||
@@ -0,0 +1,81 @@
|
||||
# `FluentFfmpegRunner`
|
||||
|
||||
## Purpose
|
||||
|
||||
Default `FfmpegRunner` implementation used by `ThumbnailGenerator` and
|
||||
`SpriteSheetGenerator` when no other runner is injected. It wraps `fluent-ffmpeg` and pins
|
||||
ffmpeg to the binary shipped by `@ffmpeg-installer/ffmpeg`, and ffprobe to
|
||||
`@ffprobe-installer/ffprobe`, so the package works on any host without requiring system
|
||||
ffmpeg/ffprobe to be on `PATH`. Every operation respects an optional `AbortSignal` by killing
|
||||
the underlying child process and rejecting with `VIDEO_THUMBNAILS.ABORTED`.
|
||||
|
||||
## Features
|
||||
|
||||
- Bundled ffmpeg + ffprobe binaries — zero system dependency.
|
||||
- Override-friendly: pass `ffmpegPath` and/or `ffprobePath` to point at a custom binary.
|
||||
- `extract` issues a single-frame ffmpeg pipeline with the canonical filter from
|
||||
`buildScaleFilter` and quality from `buildQualityArgs`.
|
||||
- `tile` issues the canonical `fps=…,scale=…,tile=COLSxROWS` sprite-sheet pipeline.
|
||||
- `probe` returns a strict `ProbeResult` with duration, dimensions, codec, fps, bitrate.
|
||||
- `isAvailable` spawns `ffmpeg -version` and resolves to `true` only on exit code 0.
|
||||
- Every call honours `AbortSignal`: `command.kill("SIGKILL")` on abort, then reject with
|
||||
`ABORTED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FluentFfmpegRunner } from "@suaveplan/video-thumbnails/runner";
|
||||
|
||||
const runner = new FluentFfmpegRunner();
|
||||
const ok = await runner.isAvailable();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FluentFfmpegRunner } from "@suaveplan/video-thumbnails/runner";
|
||||
|
||||
const runner = new FluentFfmpegRunner({
|
||||
ffmpegPath: "/usr/local/bin/ffmpeg-hwaccel",
|
||||
ffprobePath: "/usr/local/bin/ffprobe-hwaccel",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 250);
|
||||
try {
|
||||
await runner.extract(
|
||||
{
|
||||
inputPath: "/data/big.mkv",
|
||||
outputPath: "/tmp/thumb.jpg",
|
||||
timestamp: "00:00:10",
|
||||
width: 640,
|
||||
height: 360,
|
||||
fit: "cover",
|
||||
format: "jpg",
|
||||
quality: 80,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
// VideoThumbnailsError { code: "VIDEO_THUMBNAILS.ABORTED" } when aborted.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FluentFfmpegRunner implements FfmpegRunner`
|
||||
- `constructor(options?: { ffmpegPath?: string; ffprobePath?: string })`
|
||||
- `isAvailable(signal?): Promise<boolean>` — exit code 0 from `ffmpeg -version`.
|
||||
- `extract(input, signal?): Promise<void>` — single-frame extraction.
|
||||
- `tile(input, signal?): Promise<void>` — N×M sprite-sheet render.
|
||||
- `probe(inputPath, signal?): Promise<ProbeResult>` — ffprobe metadata.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`run` listens for `end` and `error` on the `FfmpegCommand`, tracks a `settled` flag so a late
|
||||
abort cannot double-resolve, and attaches the abort listener with `{ once: true }` to keep
|
||||
event-emitter memory bounded. `probe` re-pins the ffprobe path on the module-level
|
||||
`fluent-ffmpeg` singleton before each call because `ffmpeg.ffprobe` (the top-level helper)
|
||||
does not consult per-instance state — without the re-pin the singleton would happily fall
|
||||
back to system `ffprobe`. The probe failure paths split into two: a real ffprobe crash
|
||||
(`PROBE_FAILED`) and a structurally invalid response (`VIDEO_INVALID`, raised when the file
|
||||
has no video stream or a non-positive duration).
|
||||
91
docs/media/video/video-thumbnails/src/runner/runner.md
Normal file
91
docs/media/video/video-thumbnails/src/runner/runner.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# `runner`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The runner module defines the `FfmpegRunner` interface plus a handful of pure helpers used by
|
||||
every implementation: `buildScaleFilter`, `buildQualityArgs`, and `classifyRunnerError`.
|
||||
Centralising filter construction means the generator classes stay focused on orchestration
|
||||
while every implementation (the bundled `FluentFfmpegRunner` and any future native or browser
|
||||
variant) shares the exact same algorithmic shape. `classifyRunnerError` is the single point
|
||||
where an `AbortSignal` distinguishes an `ABORTED` failure from a real ffmpeg crash.
|
||||
|
||||
## Features
|
||||
|
||||
- `FfmpegRunner` interface — four methods (`isAvailable`, `extract`, `tile`, `probe`), each
|
||||
taking an optional `AbortSignal`.
|
||||
- Strong typed payloads: `ExtractInput`, `TileInput`, `ProbeResult`.
|
||||
- `buildScaleFilter` — encodes the `cover | contain | fill` fit semantics into ffmpeg
|
||||
filter-graph syntax once and only once.
|
||||
- `buildQualityArgs` — translates the 1–100 quality scale into ffmpeg's inverted 1–31 `-q:v`
|
||||
scale (PNG returns an empty arg list).
|
||||
- `classifyRunnerError` — single guarded helper that turns any raw ffmpeg failure into a
|
||||
typed `VideoThumbnailsError`, distinguishing `ABORTED` from `EXTRACT_FAILED` /
|
||||
`PROBE_FAILED` based on the supplied `AbortSignal`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildScaleFilter, buildQualityArgs } from "@suaveplan/video-thumbnails/runner";
|
||||
|
||||
buildScaleFilter(320, 180, "cover");
|
||||
// "scale=320:180:force_original_aspect_ratio=increase,crop=320:180"
|
||||
|
||||
buildQualityArgs("jpg", 80);
|
||||
// ["-q:v", "7"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
classifyRunnerError,
|
||||
type FfmpegRunner,
|
||||
} from "@suaveplan/video-thumbnails/runner";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "@suaveplan/video-thumbnails/error-codes";
|
||||
|
||||
class CustomRunner implements FfmpegRunner {
|
||||
async extract(input, signal) {
|
||||
try {
|
||||
await runViaCustomTransport(input, signal);
|
||||
} catch (error) {
|
||||
throw classifyRunnerError(
|
||||
error,
|
||||
signal,
|
||||
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
"videoThumbnails.errors.extractFailed",
|
||||
);
|
||||
}
|
||||
}
|
||||
// ... isAvailable, tile, probe
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FfmpegRunner` — `{ isAvailable(signal?), extract(input, signal?), tile(input, signal?),
|
||||
probe(inputPath, signal?) }`.
|
||||
- `ExtractInput` — `{ inputPath, outputPath, timestamp, width, height, fit, format,
|
||||
quality }`.
|
||||
- `TileInput` — `{ inputPath, outputPath, columns, rows, thumbnailWidth, thumbnailHeight,
|
||||
interval, format }`.
|
||||
- `ProbeResult` — `{ duration, width, height, codec, fps, bitrate }`.
|
||||
- `buildScaleFilter(width, height, fit): string`.
|
||||
- `buildQualityArgs(format, quality): readonly string[]`.
|
||||
- `classifyRunnerError(cause, signal, failureCode, failureKey): VideoThumbnailsError` — when
|
||||
`signal.aborted`, returns `ABORTED`; otherwise the supplied `failureCode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Quality mapping uses ffmpeg's inverted scale where `1` is best and `31` is worst — the
|
||||
inverse arithmetic (`31 - quality/100 × 30`) keeps the round-trip stable for the canonical
|
||||
80% quality (returning `-q:v 7`, the most commonly cited "good MJPEG" value). The fit
|
||||
strategies are deliberately limited to ffmpeg-native filter chains so no second-pass image
|
||||
processing is needed; consumers wanting more exotic compositing (gravity, focal point) can
|
||||
implement a custom `FfmpegRunner` and reuse `buildQualityArgs` while ignoring
|
||||
`buildScaleFilter`. `classifyRunnerError` reads `signal.aborted` lazily and only after the
|
||||
underlying call has thrown — this avoids a race where the signal aborts after the work
|
||||
already finished successfully.
|
||||
76
docs/media/video/video-thumbnails/src/schemas/schemas.md
Normal file
76
docs/media/video/video-thumbnails/src/schemas/schemas.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines every Zod schema and inferred TypeScript type that crosses the public boundary of
|
||||
`@suaveplan/video-thumbnails`. Schemas validate caller-supplied configuration at the
|
||||
constructor and method boundary, narrow defaults are applied at the same point, and the
|
||||
matching TS types are inferred via `z.infer` so callers cannot drift the literal and runtime
|
||||
shapes apart. Format and fit enums are co-exported as `const` tuples for runtime iteration
|
||||
and for the result schemas to reference.
|
||||
|
||||
## Features
|
||||
|
||||
- One schema per public type: config, options, results, and probe metadata.
|
||||
- Defaults applied at parse time (e.g. `width = 320`, `format = "jpg"`, `timestamp =
|
||||
"00:00:01"`).
|
||||
- Bounded numeric ranges (`width ≤ 8192`, `interval ≤ 3600 s`) catch fat-finger errors before
|
||||
they reach ffmpeg.
|
||||
- Tight `^HH:MM:SS(.mmm)?$` pattern for every timestamp string.
|
||||
- `THUMBNAIL_FORMATS`, `SPRITE_SHEET_FORMATS`, and `THUMBNAIL_FITS` exported as `as const`
|
||||
tuples — usable both as Zod source-of-truth and as runtime iteration keys.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ThumbnailConfigSchema } from "@suaveplan/video-thumbnails/schemas";
|
||||
|
||||
const config = ThumbnailConfigSchema.parse({ outputDir: "/tmp/thumbs" });
|
||||
// config.width === 320, config.format === "jpg"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ThumbnailConfigSchema,
|
||||
ThumbnailOptionsSchema,
|
||||
type ThumbnailConfig,
|
||||
type ThumbnailOptions,
|
||||
} from "@suaveplan/video-thumbnails/schemas";
|
||||
|
||||
function buildSettings(
|
||||
raw: unknown,
|
||||
opts: unknown,
|
||||
): { config: ThumbnailConfig; options: ThumbnailOptions } {
|
||||
const config = ThumbnailConfigSchema.parse(raw);
|
||||
const options = ThumbnailOptionsSchema.parse(opts);
|
||||
return { config, options };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ThumbnailConfigSchema` — `{ outputDir, width?, height?, quality?, format? }`.
|
||||
- `ThumbnailOptionsSchema` — `{ timestamp?, fit? }`.
|
||||
- `SpriteSheetConfigSchema` — `{ columns?, rows?, thumbnailWidth?, thumbnailHeight?, interval?, format? }`.
|
||||
- `ThumbnailResultSchema`, `SpriteSheetResultSchema`, `VideoMetadataSchema` — used both at
|
||||
the boundary (to assert internal invariants) and as the canonical type source.
|
||||
- `THUMBNAIL_FORMATS` (`["jpg", "png", "webp"]`), `SPRITE_SHEET_FORMATS` (`["jpg", "png"]`),
|
||||
`THUMBNAIL_FITS` (`["cover", "contain", "fill"]`), `TIMESTAMP_PATTERN`.
|
||||
- Types: `ThumbnailConfig`, `ThumbnailOptions`, `SpriteSheetConfig`, `ThumbnailResult`,
|
||||
`SpriteSheetResult`, `VideoMetadata`, `ThumbnailFormat`, `SpriteSheetFormat`,
|
||||
`ThumbnailFit`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`parse` (not `safeParse`) is used at the public boundary because failure must throw — the
|
||||
caller wrapping the boundary translates the `ZodError` into a `VideoThumbnailsError` with the
|
||||
issues attached under `context.issues`. Default values live on the schemas, so the
|
||||
`ThumbnailConfig` type carries the defaults as required fields after parsing — this is the
|
||||
intended ergonomic. The numeric upper bounds (8192, 32, 3600) reflect realistic ffmpeg
|
||||
limits; values beyond them indicate a bug rather than a corner case worth supporting.
|
||||
@@ -0,0 +1,81 @@
|
||||
# `SpriteSheetGenerator`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SpriteSheetGenerator` renders an N×M grid of thumbnails into a single image using ffmpeg's
|
||||
`tile` filter. A sprite sheet is the canonical artefact for video scrubbers and timeline
|
||||
previews — one HTTP fetch, one decode, then arbitrary in-page hovers slide a `background-
|
||||
position`. The generator delegates the actual ffmpeg invocation to an injectable
|
||||
`FfmpegRunner` (default: `FluentFfmpegRunner` with bundled ffmpeg/ffprobe binaries) and
|
||||
auto-detects video duration via the runner's `probe` when the caller does not pass one
|
||||
explicitly.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable grid: `columns`, `rows`, `thumbnailWidth`, `thumbnailHeight`, `interval`,
|
||||
`format`.
|
||||
- Auto-detection of video duration via `runner.probe` when caller omits `duration`.
|
||||
- Frame count is capped at `columns × rows` and at `floor(duration / interval)` — whichever
|
||||
is smaller — so short videos do not produce padded grids.
|
||||
- Zod boundary validation with code-specific failures (`INVALID_DIMENSIONS`,
|
||||
`FORMAT_UNSUPPORTED`, `INVALID_OPTIONS`).
|
||||
- OTel: `video_thumbnails.sprite_sheet.duration_ms{outcome,format}` plus
|
||||
`video_thumbnails.sprite_sheet.frames{format}`.
|
||||
- `AbortSignal` propagated through every call; aborts surface as `VIDEO_THUMBNAILS.ABORTED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SpriteSheetGenerator } from "@suaveplan/video-thumbnails/sprite-sheet";
|
||||
|
||||
const generator = new SpriteSheetGenerator();
|
||||
const result = await generator.generate("/data/clip.mp4", "/tmp/sprite.jpg");
|
||||
console.log(result.frameCount, result.columns, result.rows);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SpriteSheetGenerator } from "@suaveplan/video-thumbnails/sprite-sheet";
|
||||
|
||||
const generator = new SpriteSheetGenerator({
|
||||
columns: 8,
|
||||
rows: 6,
|
||||
thumbnailWidth: 192,
|
||||
thumbnailHeight: 108,
|
||||
interval: 2,
|
||||
format: "jpg",
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const result = await generator.generate(
|
||||
"/data/long-movie.mp4",
|
||||
"/tmp/movie-sprite.jpg",
|
||||
7200,
|
||||
controller.signal,
|
||||
);
|
||||
// result.width === 8 * 192, result.height === 6 * 108, result.frameCount === 48
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SpriteSheetGenerator`
|
||||
- `constructor(config?: SpriteSheetConfigInput, runner?: FfmpegRunner)`
|
||||
- `generate(inputPath, outputPath, duration?, signal?): Promise<SpriteSheetResult>`
|
||||
|
||||
Throws `VideoThumbnailsError` on `INVALID_DIMENSIONS`, `FORMAT_UNSUPPORTED`,
|
||||
`INVALID_OPTIONS`, `VIDEO_INVALID`, `EXTRACT_FAILED`, `PROBE_FAILED`, or `ABORTED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`rows` is recomputed against the actual `frameCount` (rather than blindly using the configured
|
||||
upper bound) so a 5-second clip with a 10-frame grid does not produce four trailing black
|
||||
cells. The generator never directly invokes ffmpeg — every call routes through the supplied
|
||||
runner so consumers can swap in a hardware-accelerated or hosted variant without touching
|
||||
this class. OTel records before throwing on failure so dashboards distinguish counts from
|
||||
timings even on errors. `duration` is validated when passed explicitly: non-finite or
|
||||
non-positive values are rejected with `VIDEO_INVALID`, mirroring the probe path.
|
||||
83
docs/media/video/video-thumbnails/src/telemetry/telemetry.md
Normal file
83
docs/media/video/video-thumbnails/src/telemetry/telemetry.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# `telemetry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises the OpenTelemetry instruments owned by `@suaveplan/video-thumbnails`. A meter is
|
||||
acquired lazily — only when `getTelemetry()` is called — so the package can keep
|
||||
`sideEffects: false` and consumers that import a subpath such as `./schemas` do not pay for
|
||||
metric creation. The bundle exposes one counter and three histograms covering the entire
|
||||
externally-visible surface (single-frame extracts and sprite-sheet generation).
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy meter acquisition via `metrics.getMeter("@suaveplan/video-thumbnails")`.
|
||||
- Stable instrument names that mirror the spec delta (`video_thumbnails.extract.*`,
|
||||
`video_thumbnails.sprite_sheet.*`).
|
||||
- Each instrument carries a `description` so dashboards stay self-documenting.
|
||||
- Returns a frozen `VideoThumbnailsTelemetry` view callable from any generator.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getTelemetry } from "@suaveplan/video-thumbnails/telemetry";
|
||||
|
||||
const telemetry = getTelemetry();
|
||||
telemetry.extractTotal.add(1, { outcome: "success", format: "jpg" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { getTelemetry } from "@suaveplan/video-thumbnails/telemetry";
|
||||
|
||||
async function instrumentedExtract<T>(
|
||||
label: { format: string; fit: string },
|
||||
body: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const telemetry = getTelemetry();
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await body();
|
||||
telemetry.extractTotal.add(1, { ...label, outcome: "success" });
|
||||
telemetry.extractDuration.record(performance.now() - start, {
|
||||
...label,
|
||||
outcome: "success",
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
telemetry.extractTotal.add(1, { ...label, outcome: "failure" });
|
||||
telemetry.extractDuration.record(performance.now() - start, {
|
||||
...label,
|
||||
outcome: "failure",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getTelemetry(): VideoThumbnailsTelemetry` — returns a fresh bundle from the global meter.
|
||||
- `VideoThumbnailsTelemetry` — `{ extractDuration: Histogram, extractTotal: Counter,
|
||||
spriteSheetDuration: Histogram, spriteSheetFrames: Histogram }`.
|
||||
|
||||
Instrument names:
|
||||
|
||||
- `video_thumbnails.extract.duration_ms` (histogram, ms; attrs `{format, fit, outcome}`)
|
||||
- `video_thumbnails.extract.total` (counter; attrs `{outcome, format}`)
|
||||
- `video_thumbnails.sprite_sheet.duration_ms` (histogram, ms; attrs `{outcome, format}`)
|
||||
- `video_thumbnails.sprite_sheet.frames` (histogram; attrs `{format}`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `metrics.getMeter` call returns a no-op meter when no OTel SDK has been registered, so
|
||||
tests that do not install a meter provider still execute the recording paths without
|
||||
exploding — the values are simply discarded. Tests that need to observe values install an
|
||||
`InMemoryMetricExporter` via `MeterProvider` before instantiating the generator; the rest of
|
||||
the call site stays identical. Instrument creation is cheap, but if it ever becomes a hot
|
||||
path the bundle can be memoised behind a lazy module-scoped singleton without changing the
|
||||
public contract.
|
||||
@@ -0,0 +1,91 @@
|
||||
# `ThumbnailGenerator`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ThumbnailGenerator` is the primary class consumers reach for to extract still frames from a
|
||||
video file. It accepts a Zod-validated `ThumbnailConfig` (output directory, dimensions,
|
||||
format, quality) and delegates the actual ffmpeg invocation to an injectable `FfmpegRunner`.
|
||||
The default runner — `FluentFfmpegRunner` — bundles ffmpeg/ffprobe binaries so the package
|
||||
runs unmodified on any host. Every public method honours an `AbortSignal`, validates input at
|
||||
the boundary, and records OTel histograms + counters keyed by `format`, `fit`, and `outcome`.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-frame extract at any `HH:MM:SS(.mmm)?` timestamp (`generate`).
|
||||
- Multi-timestamp batch with abort-aware iteration (`generateMultiple`).
|
||||
- Sliding-window extraction — N evenly-spaced frames across a duration
|
||||
(`extractSlidingWindow`).
|
||||
- ffprobe-backed metadata (`getMetadata`).
|
||||
- Runner-availability probe (`isAvailable`).
|
||||
- Zod boundary validation with code-specific failures (`INVALID_DIMENSIONS`,
|
||||
`INVALID_TIMESTAMP`, `FORMAT_UNSUPPORTED`, `INVALID_OPTIONS`).
|
||||
- OTel: `video_thumbnails.extract.duration_ms{format,fit,outcome}` +
|
||||
`video_thumbnails.extract.total{outcome,format}`.
|
||||
- `AbortSignal` propagated through every call into the runner; aborts surface as
|
||||
`VIDEO_THUMBNAILS.ABORTED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ThumbnailGenerator } from "@suaveplan/video-thumbnails/thumbnail-generator";
|
||||
|
||||
const generator = new ThumbnailGenerator({ outputDir: "/tmp/thumbs" });
|
||||
const result = await generator.generate("/data/clip.mp4");
|
||||
console.log(result.outputPath, result.sizeBytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ThumbnailGenerator } from "@suaveplan/video-thumbnails/thumbnail-generator";
|
||||
import { FluentFfmpegRunner } from "@suaveplan/video-thumbnails/runner";
|
||||
|
||||
const generator = new ThumbnailGenerator(
|
||||
{
|
||||
outputDir: "/tmp/thumbs",
|
||||
width: 640,
|
||||
height: 360,
|
||||
format: "webp",
|
||||
quality: 90,
|
||||
},
|
||||
new FluentFfmpegRunner({ ffmpegPath: "/opt/ffmpeg/bin/ffmpeg" }),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const metadata = await generator.getMetadata("/data/clip.mp4");
|
||||
const frames = await generator.extractSlidingWindow(
|
||||
"/data/clip.mp4",
|
||||
8,
|
||||
metadata.duration,
|
||||
controller.signal,
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ThumbnailGenerator`
|
||||
- `constructor(config: ThumbnailConfigInput, runner?: FfmpegRunner)`
|
||||
- `generate(inputPath, options?, signal?): Promise<ThumbnailResult>`
|
||||
- `generateMultiple(inputPath, timestamps, signal?): Promise<ThumbnailResult[]>`
|
||||
- `extractSlidingWindow(inputPath, count, durationSeconds, signal?): Promise<ThumbnailResult[]>`
|
||||
- `getMetadata(inputPath, signal?): Promise<VideoMetadata>`
|
||||
- `isAvailable(signal?): Promise<boolean>`
|
||||
- `buildEvenlySpacedTimestamps(count, durationSeconds): string[]` — utility re-used by
|
||||
`extractSlidingWindow`.
|
||||
|
||||
Throws `VideoThumbnailsError` for every failure mode (`INVALID_*`, `FORMAT_UNSUPPORTED`,
|
||||
`EXTRACT_FAILED`, `PROBE_FAILED`, `VIDEO_INVALID`, `ABORTED`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`extractSlidingWindow` divides the duration into `count + 1` slots and picks the boundary at
|
||||
each interior slot — i.e. for `count = 4, duration = 5s` the timestamps are `1.0s`, `2.0s`,
|
||||
`3.0s`, `4.0s`. This avoids the degenerate case of putting a frame at `0.0` (often a black
|
||||
intro frame) or at the exact duration (frequently past the last decodable frame). The
|
||||
generator never writes files itself — `stat()` reads back the file ffmpeg produced so the
|
||||
returned `sizeBytes` reflects the actual on-disk size, not the requested dimensions
|
||||
multiplied by an assumed bit depth. OTel records before throwing on failure so dashboards
|
||||
distinguish counts from timings.
|
||||
109
docs/media/video/video/README.md
Normal file
109
docs/media/video/video/README.md
Normal 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
|
||||
|
||||
78
docs/media/video/video/src/detection/detection.md
Normal file
78
docs/media/video/video/src/detection/detection.md
Normal 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).
|
||||
73
docs/media/video/video/src/duration/duration.md
Normal file
73
docs/media/video/video/src/duration/duration.md
Normal 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.
|
||||
96
docs/media/video/video/src/error-codes/error-codes.md
Normal file
96
docs/media/video/video/src/error-codes/error-codes.md
Normal 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.
|
||||
109
docs/media/video/video/src/errors/errors.md
Normal file
109
docs/media/video/video/src/errors/errors.md
Normal 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.
|
||||
64
docs/media/video/video/src/format/format.md
Normal file
64
docs/media/video/video/src/format/format.md
Normal 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.
|
||||
83
docs/media/video/video/src/instruments/instruments.md
Normal file
83
docs/media/video/video/src/instruments/instruments.md
Normal 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.
|
||||
105
docs/media/video/video/src/pipeline-builder/pipeline-builder.md
Normal file
105
docs/media/video/video/src/pipeline-builder/pipeline-builder.md
Normal 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.
|
||||
117
docs/media/video/video/src/processor/processor.md
Normal file
117
docs/media/video/video/src/processor/processor.md
Normal 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.
|
||||
83
docs/media/video/video/src/resize/resize.md
Normal file
83
docs/media/video/video/src/resize/resize.md
Normal 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.
|
||||
73
docs/media/video/video/src/trim/trim.md
Normal file
73
docs/media/video/video/src/trim/trim.md
Normal 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.
|
||||
111
docs/media/video/video/src/types/video-types/video-types.md
Normal file
111
docs/media/video/video/src/types/video-types/video-types.md
Normal 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.
|
||||
Reference in New Issue
Block a user