Initial Commit
This commit is contained in:
14
packages/media/video/README.md
Normal file
14
packages/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 browse… |
|
||||
| [`@suaveplan/video-thumbnails`](./video-thumbnails/README.md) | Video thumbnail extraction and sprite-sheet generation backed by an injectable `FfmpegRunner`. The default runner — `FluentFfmpegRunner` — wraps `fluent-ffmpeg… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/media/video/_intro.md
Normal file
1
packages/media/video/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Video encoding, decoding, and thumbnail generation.
|
||||
85
packages/media/video/video-thumbnails/README.md
Normal file
85
packages/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
|
||||
|
||||
104
packages/media/video/video-thumbnails/package.json
Normal file
104
packages/media/video/video-thumbnails/package.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"name": "@suaveplan/video-thumbnails",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Video thumbnail extraction and sprite-sheet generation backed by an injectable FfmpegRunner (fluent-ffmpeg + @ffmpeg-installer by default).",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/index.d.ts",
|
||||
"default": "./dist/errors/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/index.d.ts",
|
||||
"default": "./dist/schemas/index.js"
|
||||
}
|
||||
},
|
||||
"./runner": {
|
||||
"import": {
|
||||
"types": "./dist/runner/index.d.ts",
|
||||
"default": "./dist/runner/index.js"
|
||||
}
|
||||
},
|
||||
"./thumbnail-generator": {
|
||||
"import": {
|
||||
"types": "./dist/thumbnail-generator/index.d.ts",
|
||||
"default": "./dist/thumbnail-generator/index.js"
|
||||
}
|
||||
},
|
||||
"./sprite-sheet": {
|
||||
"import": {
|
||||
"types": "./dist/sprite-sheet/index.d.ts",
|
||||
"default": "./dist/sprite-sheet/index.js"
|
||||
}
|
||||
},
|
||||
"./telemetry": {
|
||||
"import": {
|
||||
"types": "./dist/telemetry/index.d.ts",
|
||||
"default": "./dist/telemetry/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@ffmpeg-installer/ffmpeg": "catalog:audio",
|
||||
"@ffprobe-installer/ffprobe": "catalog:audio",
|
||||
"fluent-ffmpeg": "catalog:audio",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "catalog:observability",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/fluent-ffmpeg": "catalog:audio",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -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,37 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "./error-codes.js";
|
||||
|
||||
describe("VIDEO_THUMBNAILS_CODES", () => {
|
||||
it("exposes all nine codes under the VIDEO_THUMBNAILS namespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const values = Object.values(VIDEO_THUMBNAILS_CODES);
|
||||
expect(values).toHaveLength(9);
|
||||
for (const value of values) {
|
||||
expect(value.startsWith("VIDEO_THUMBNAILS.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("every code is thrown by a real call site", { timeout: 5000 }, () => {
|
||||
const srcRoot = join(__dirname, "..");
|
||||
const files = [
|
||||
"runner/runner.ts",
|
||||
"runner/fluent-ffmpeg-runner.ts",
|
||||
"thumbnail-generator/thumbnail-generator.ts",
|
||||
"sprite-sheet/sprite-sheet.ts",
|
||||
];
|
||||
const corpus = files
|
||||
.map((relative) => readFileSync(join(srcRoot, relative), "utf8"))
|
||||
.join("\n");
|
||||
for (const [reason, code] of Object.entries(VIDEO_THUMBNAILS_CODES)) {
|
||||
const accessor = `VIDEO_THUMBNAILS_CODES.${reason}`;
|
||||
const hasUsage = corpus.includes(accessor);
|
||||
expect(
|
||||
hasUsage,
|
||||
`expected ${code} (${accessor}) referenced in a src/ call site`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineCodes } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/video-thumbnails`.
|
||||
*
|
||||
* All codes are namespaced under `VIDEO_THUMBNAILS.` so they survive grep-based
|
||||
* triage and OTel filtering across the monorepo without colliding with
|
||||
* unrelated registries.
|
||||
*/
|
||||
export const VIDEO_THUMBNAILS_CODES = defineCodes("VIDEO_THUMBNAILS", [
|
||||
"EXTRACT_FAILED",
|
||||
"PROBE_FAILED",
|
||||
"VIDEO_INVALID",
|
||||
"INVALID_TIMESTAMP",
|
||||
"INVALID_DIMENSIONS",
|
||||
"INVALID_OPTIONS",
|
||||
"FORMAT_UNSUPPORTED",
|
||||
"RUNNER_UNAVAILABLE",
|
||||
"ABORTED",
|
||||
] as const);
|
||||
|
||||
/** Union of every error code emitted by `@suaveplan/video-thumbnails`. */
|
||||
export type VideoThumbnailsCode =
|
||||
(typeof VIDEO_THUMBNAILS_CODES)[keyof typeof VIDEO_THUMBNAILS_CODES];
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
VIDEO_THUMBNAILS_CODES,
|
||||
type VideoThumbnailsCode,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { VideoThumbnailsError } from "./video-thumbnails-error.js";
|
||||
@@ -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,25 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
|
||||
import { VideoThumbnailsError } from "./video-thumbnails-error.js";
|
||||
|
||||
describe("VideoThumbnailsError", () => {
|
||||
it("extends SuaveplanError", { timeout: 5000 }, () => {
|
||||
const error = new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
"videoThumbnails.errors.extractFailed"
|
||||
);
|
||||
expect(error).toBeInstanceOf(SuaveplanError);
|
||||
expect(error.code).toBe(VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED);
|
||||
expect(error.name).toBe("VideoThumbnailsError");
|
||||
});
|
||||
|
||||
it("carries context when supplied", { timeout: 5000 }, () => {
|
||||
const error = new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
|
||||
"videoThumbnails.errors.probeFailed",
|
||||
{ context: { stderr: "boom" } }
|
||||
);
|
||||
expect(error.context).toEqual({ stderr: "boom" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import type { VideoThumbnailsCode } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Typed error for all `@suaveplan/video-thumbnails` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` with a stable code drawn from `VIDEO_THUMBNAILS_CODES`.
|
||||
* Callers can discriminate on `error.code` to handle specific failure modes and
|
||||
* the error survives every OTel adapter, JSON wire-format, and redaction
|
||||
* pipeline shipped by `@suaveplan/error`.
|
||||
*/
|
||||
export class VideoThumbnailsError extends SuaveplanError<VideoThumbnailsCode> {
|
||||
public constructor(
|
||||
code: VideoThumbnailsCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "VideoThumbnailsError";
|
||||
}
|
||||
}
|
||||
20
packages/media/video/video-thumbnails/src/index.test.ts
Normal file
20
packages/media/video/video-thumbnails/src/index.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FluentFfmpegRunner,
|
||||
SpriteSheetGenerator,
|
||||
ThumbnailGenerator,
|
||||
VIDEO_THUMBNAILS_CODES,
|
||||
VideoThumbnailsError,
|
||||
} from "./index.js";
|
||||
|
||||
describe("package barrel", () => {
|
||||
it("feature subpaths resolve", { timeout: 5000 }, () => {
|
||||
expect(ThumbnailGenerator).toBeTypeOf("function");
|
||||
expect(SpriteSheetGenerator).toBeTypeOf("function");
|
||||
expect(FluentFfmpegRunner).toBeTypeOf("function");
|
||||
expect(VideoThumbnailsError).toBeTypeOf("function");
|
||||
expect(VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED).toBe(
|
||||
"VIDEO_THUMBNAILS.EXTRACT_FAILED"
|
||||
);
|
||||
});
|
||||
});
|
||||
64
packages/media/video/video-thumbnails/src/index.ts
Normal file
64
packages/media/video/video-thumbnails/src/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Error codes
|
||||
export {
|
||||
VIDEO_THUMBNAILS_CODES,
|
||||
type VideoThumbnailsCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
|
||||
// Errors
|
||||
export { VideoThumbnailsError } from "./errors/video-thumbnails-error.js";
|
||||
// Runner
|
||||
export {
|
||||
FluentFfmpegRunner,
|
||||
type FluentFfmpegRunnerOptions,
|
||||
} from "./runner/fluent-ffmpeg-runner.js";
|
||||
export {
|
||||
buildQualityArgs,
|
||||
buildScaleFilter,
|
||||
classifyRunnerError,
|
||||
} from "./runner/runner.js";
|
||||
export type {
|
||||
ExtractInput,
|
||||
FfmpegRunner,
|
||||
ProbeResult,
|
||||
TileInput,
|
||||
} from "./runner/runner.types.js";
|
||||
// Schemas + types
|
||||
export {
|
||||
SPRITE_SHEET_FORMATS,
|
||||
SpriteSheetConfigSchema,
|
||||
SpriteSheetResultSchema,
|
||||
THUMBNAIL_FITS,
|
||||
THUMBNAIL_FORMATS,
|
||||
ThumbnailConfigSchema,
|
||||
ThumbnailOptionsSchema,
|
||||
ThumbnailResultSchema,
|
||||
TIMESTAMP_PATTERN,
|
||||
VideoMetadataSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
export type {
|
||||
SpriteSheetConfig,
|
||||
SpriteSheetFormat,
|
||||
SpriteSheetResult,
|
||||
ThumbnailConfig,
|
||||
ThumbnailFit,
|
||||
ThumbnailFormat,
|
||||
ThumbnailOptions,
|
||||
ThumbnailResult,
|
||||
VideoMetadata,
|
||||
} from "./schemas/schemas.types.js";
|
||||
export { SpriteSheetGenerator } from "./sprite-sheet/sprite-sheet.js";
|
||||
export type { SpriteSheetConfigInput } from "./sprite-sheet/sprite-sheet.types.js";
|
||||
// Telemetry
|
||||
export {
|
||||
getTelemetry,
|
||||
type VideoThumbnailsTelemetry,
|
||||
} from "./telemetry/telemetry.js";
|
||||
// Generators
|
||||
export {
|
||||
buildEvenlySpacedTimestamps,
|
||||
ThumbnailGenerator,
|
||||
} from "./thumbnail-generator/thumbnail-generator.js";
|
||||
export type {
|
||||
ThumbnailConfigInput,
|
||||
ThumbnailOptionsInput,
|
||||
} from "./thumbnail-generator/thumbnail-generator.types.js";
|
||||
@@ -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).
|
||||
@@ -0,0 +1,512 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getFixtureVideo,
|
||||
makeOutputDir,
|
||||
safeRm,
|
||||
} from "../../tests/setup/fixtures.js";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
|
||||
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
|
||||
import {
|
||||
FluentFfmpegRunner,
|
||||
makeSettler,
|
||||
toProbeResult,
|
||||
} from "./fluent-ffmpeg-runner.js";
|
||||
|
||||
let runner: FluentFfmpegRunner;
|
||||
let outDir: string;
|
||||
let fixture: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
runner = new FluentFfmpegRunner();
|
||||
outDir = await makeOutputDir("runner");
|
||||
fixture = await getFixtureVideo();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await safeRm(outDir);
|
||||
});
|
||||
|
||||
describe("FluentFfmpegRunner", () => {
|
||||
it("reports ffmpeg available", { timeout: 30000 }, async () => {
|
||||
expect(await runner.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it("isAvailable returns false when signal is pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(await runner.isAvailable(controller.signal)).toBe(false);
|
||||
});
|
||||
|
||||
it("isAvailable returns false when signal aborts mid-spawn", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
const pending = runner.isAvailable(controller.signal);
|
||||
// Fire abort on the next microtask, after spawn has hooked the listener.
|
||||
queueMicrotask(() => controller.abort());
|
||||
expect(await pending).toBe(false);
|
||||
});
|
||||
|
||||
it("isAvailable returns false when the binary path is invalid", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const broken = new FluentFfmpegRunner({
|
||||
ffmpegPath: "/nonexistent/ffmpeg",
|
||||
});
|
||||
expect(await broken.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts a JPEG frame from the real fixture", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const output = join(outDir, "extract.jpg");
|
||||
await runner.extract({
|
||||
inputPath: fixture,
|
||||
outputPath: output,
|
||||
timestamp: "00:00:01",
|
||||
width: 160,
|
||||
height: 120,
|
||||
fit: "cover",
|
||||
format: "jpg",
|
||||
quality: 80,
|
||||
});
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const fileStat = await stat(output);
|
||||
expect(fileStat.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects extract with ABORTED when signal aborts pre-call", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
runner.extract(
|
||||
{
|
||||
inputPath: fixture,
|
||||
outputPath: join(outDir, "aborted.jpg"),
|
||||
timestamp: "00:00:01",
|
||||
width: 160,
|
||||
height: 120,
|
||||
fit: "cover",
|
||||
format: "jpg",
|
||||
quality: 80,
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects extract with EXTRACT_FAILED when the source is bogus", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
runner.extract({
|
||||
inputPath: "/this/file/does/not/exist.mp4",
|
||||
outputPath: join(outDir, "missing.jpg"),
|
||||
timestamp: "00:00:01",
|
||||
width: 160,
|
||||
height: 120,
|
||||
fit: "cover",
|
||||
format: "jpg",
|
||||
quality: 80,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a tile sprite sheet", { timeout: 30000 }, async () => {
|
||||
const output = join(outDir, "tile.jpg");
|
||||
await runner.tile({
|
||||
inputPath: fixture,
|
||||
outputPath: output,
|
||||
columns: 3,
|
||||
rows: 2,
|
||||
thumbnailWidth: 80,
|
||||
thumbnailHeight: 60,
|
||||
interval: 1,
|
||||
format: "jpg",
|
||||
});
|
||||
const { stat } = await import("node:fs/promises");
|
||||
expect((await stat(output)).size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects tile with ABORTED when signal aborts pre-call", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
runner.tile(
|
||||
{
|
||||
inputPath: fixture,
|
||||
outputPath: join(outDir, "tile-abort.jpg"),
|
||||
columns: 3,
|
||||
rows: 2,
|
||||
thumbnailWidth: 80,
|
||||
thumbnailHeight: 60,
|
||||
interval: 1,
|
||||
format: "jpg",
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("probe returns sensible metadata for the fixture", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const probed = await runner.probe(fixture);
|
||||
expect(probed.duration).toBeGreaterThan(0);
|
||||
expect(probed.width).toBeGreaterThan(0);
|
||||
expect(probed.height).toBeGreaterThan(0);
|
||||
expect(probed.codec).toBeTypeOf("string");
|
||||
expect(probed.fps).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("probe rejects with ABORTED when signal is pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
runner.probe(fixture, controller.signal)
|
||||
).rejects.toMatchObject({ code: VIDEO_THUMBNAILS_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("probe rejects with PROBE_FAILED on a missing file", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await expect(runner.probe("/no/such/file.mp4")).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("probe rejects with VIDEO_INVALID on a non-video container", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const audioOnly = join(outDir, "audio-only.mp4");
|
||||
const ffmpegInstaller = (await import("@ffmpeg-installer/ffmpeg"))
|
||||
.default;
|
||||
const { spawn } = await import("node:child_process");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(ffmpegInstaller.path, [
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=1",
|
||||
"-c:a",
|
||||
"aac",
|
||||
audioOnly,
|
||||
]);
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) =>
|
||||
code === 0
|
||||
? resolve()
|
||||
: reject(new Error("audio fixture failed"))
|
||||
);
|
||||
});
|
||||
await expect(runner.probe(audioOnly)).rejects.toBeInstanceOf(
|
||||
VideoThumbnailsError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toProbeResult", () => {
|
||||
it("throws VIDEO_INVALID when no video stream is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
toProbeResult({
|
||||
streams: [],
|
||||
format: {},
|
||||
} as never)
|
||||
).toThrow(
|
||||
expect.objectContaining({ code: "VIDEO_THUMBNAILS.VIDEO_INVALID" })
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when streams is missing entirely", { timeout: 5000 }, () => {
|
||||
expect(() => toProbeResult({ format: {} } as never)).toThrow();
|
||||
});
|
||||
|
||||
it("falls back to 30/1 when r_frame_rate is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
],
|
||||
format: { duration: "5.0", bit_rate: "12345" },
|
||||
} as never);
|
||||
expect(result.fps).toBe(30);
|
||||
});
|
||||
|
||||
it("yields fps = num when denominator is zero", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "vp9",
|
||||
width: 100,
|
||||
height: 100,
|
||||
r_frame_rate: "24/0",
|
||||
},
|
||||
],
|
||||
format: { duration: "1" },
|
||||
} as never);
|
||||
expect(result.fps).toBe(24);
|
||||
});
|
||||
|
||||
it("handles non-finite duration as 0", { timeout: 5000 }, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 1,
|
||||
height: 1,
|
||||
r_frame_rate: "30/1",
|
||||
},
|
||||
],
|
||||
format: { duration: "N/A" },
|
||||
} as never);
|
||||
expect(result.duration).toBe(0);
|
||||
});
|
||||
|
||||
it("substitutes 0 for missing width/height", { timeout: 5000 }, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
r_frame_rate: "30/1",
|
||||
},
|
||||
],
|
||||
format: { duration: "1" },
|
||||
} as never);
|
||||
expect(result.width).toBe(0);
|
||||
expect(result.height).toBe(0);
|
||||
expect(result.codec).toBe("unknown");
|
||||
});
|
||||
|
||||
it("treats missing format object as duration/bitrate 0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 1,
|
||||
height: 1,
|
||||
r_frame_rate: "30/1",
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
expect(result.duration).toBe(0);
|
||||
expect(result.bitrate).toBe(0);
|
||||
});
|
||||
|
||||
it("returns bitrate 0 when not present", { timeout: 5000 }, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 16,
|
||||
height: 9,
|
||||
r_frame_rate: "30/1",
|
||||
},
|
||||
],
|
||||
format: { duration: "1" },
|
||||
} as never);
|
||||
expect(result.bitrate).toBe(0);
|
||||
});
|
||||
|
||||
it("handles non-numeric fps numerator", { timeout: 5000 }, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 16,
|
||||
height: 9,
|
||||
r_frame_rate: "abc/1",
|
||||
},
|
||||
],
|
||||
format: { duration: "1" },
|
||||
} as never);
|
||||
expect(result.fps).toBe(0);
|
||||
});
|
||||
|
||||
it("handles non-numeric fps denominator", { timeout: 5000 }, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 16,
|
||||
height: 9,
|
||||
r_frame_rate: "24/xyz",
|
||||
},
|
||||
],
|
||||
format: { duration: "1" },
|
||||
} as never);
|
||||
expect(result.fps).toBe(24);
|
||||
});
|
||||
|
||||
it("handles missing fps numerator (empty string)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 16,
|
||||
height: 9,
|
||||
r_frame_rate: "/1",
|
||||
},
|
||||
],
|
||||
format: { duration: "1" },
|
||||
} as never);
|
||||
expect(result.fps).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeSettler", () => {
|
||||
it("completes the promise on first complete", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let resolved = false;
|
||||
const settler = makeSettler(
|
||||
() => {
|
||||
resolved = true;
|
||||
},
|
||||
() => {
|
||||
throw new Error("should not reject");
|
||||
},
|
||||
undefined
|
||||
);
|
||||
settler.complete();
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores complete after settled", { timeout: 5000 }, () => {
|
||||
let count = 0;
|
||||
const settler = makeSettler(
|
||||
() => {
|
||||
count += 1;
|
||||
},
|
||||
() => {
|
||||
/* unused */
|
||||
},
|
||||
undefined
|
||||
);
|
||||
settler.complete();
|
||||
settler.complete();
|
||||
settler.fail(new Error("late"));
|
||||
settler.abort(() => {
|
||||
/* unused */
|
||||
});
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("fail rejects with the supplied error", { timeout: 5000 }, () => {
|
||||
let rejection: unknown;
|
||||
const settler = makeSettler(
|
||||
() => {
|
||||
/* unused */
|
||||
},
|
||||
(reason) => {
|
||||
rejection = reason;
|
||||
},
|
||||
undefined
|
||||
);
|
||||
const cause = new Error("boom");
|
||||
settler.fail(cause);
|
||||
expect(rejection).toBe(cause);
|
||||
});
|
||||
|
||||
it("ignores fail after settled", { timeout: 5000 }, () => {
|
||||
let calls = 0;
|
||||
const settler = makeSettler(
|
||||
() => {
|
||||
/* unused */
|
||||
},
|
||||
() => {
|
||||
calls += 1;
|
||||
},
|
||||
undefined
|
||||
);
|
||||
settler.fail(new Error("first"));
|
||||
settler.fail(new Error("ignored"));
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("abort invokes the cancel callback then rejects with ABORTED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort("user-action");
|
||||
let rejection: unknown;
|
||||
let cancelCalled = 0;
|
||||
const settler = makeSettler(
|
||||
() => {
|
||||
/* unused */
|
||||
},
|
||||
(reason) => {
|
||||
rejection = reason;
|
||||
},
|
||||
controller.signal
|
||||
);
|
||||
settler.abort(() => {
|
||||
cancelCalled += 1;
|
||||
});
|
||||
expect(cancelCalled).toBe(1);
|
||||
expect(rejection).toBeInstanceOf(VideoThumbnailsError);
|
||||
expect((rejection as VideoThumbnailsError).code).toBe(
|
||||
"VIDEO_THUMBNAILS.ABORTED"
|
||||
);
|
||||
});
|
||||
|
||||
it("abort no-ops when already settled", { timeout: 5000 }, () => {
|
||||
let cancelCalled = 0;
|
||||
const settler = makeSettler(
|
||||
() => {
|
||||
/* unused */
|
||||
},
|
||||
() => {
|
||||
/* unused */
|
||||
},
|
||||
undefined
|
||||
);
|
||||
settler.complete();
|
||||
settler.abort(() => {
|
||||
cancelCalled += 1;
|
||||
});
|
||||
expect(cancelCalled).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import ffmpegInstaller from "@ffmpeg-installer/ffmpeg";
|
||||
import ffprobeInstaller from "@ffprobe-installer/ffprobe";
|
||||
import ffmpeg, { type FfmpegCommand } from "fluent-ffmpeg";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
|
||||
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
|
||||
import {
|
||||
buildQualityArgs,
|
||||
buildScaleFilter,
|
||||
classifyRunnerError,
|
||||
} from "./runner.js";
|
||||
import type {
|
||||
ExtractInput,
|
||||
FfmpegRunner,
|
||||
ProbeResult,
|
||||
TileInput,
|
||||
} from "./runner.types.js";
|
||||
|
||||
/** Construction options for {@link FluentFfmpegRunner}. */
|
||||
export interface FluentFfmpegRunnerOptions {
|
||||
/** Override the ffmpeg binary path. Defaults to `@ffmpeg-installer/ffmpeg`. */
|
||||
readonly ffmpegPath?: string;
|
||||
/** Override the ffprobe binary path. Defaults to `@ffprobe-installer/ffprobe`. */
|
||||
readonly ffprobePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default `FfmpegRunner` implementation backed by `fluent-ffmpeg` and the
|
||||
* binary shipped via `@ffmpeg-installer/ffmpeg`. Consumers can supply a
|
||||
* different binary by passing `ffmpegPath`.
|
||||
*/
|
||||
export class FluentFfmpegRunner implements FfmpegRunner {
|
||||
private readonly ffmpegPath: string;
|
||||
private readonly ffprobePath: string;
|
||||
|
||||
public constructor(options: FluentFfmpegRunnerOptions = {}) {
|
||||
this.ffmpegPath = options.ffmpegPath ?? ffmpegInstaller.path;
|
||||
this.ffprobePath = options.ffprobePath ?? ffprobeInstaller.path;
|
||||
}
|
||||
|
||||
public async isAvailable(signal?: AbortSignal): Promise<boolean> {
|
||||
if (signal?.aborted) {
|
||||
return false;
|
||||
}
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const child = spawn(this.ffmpegPath, ["-version"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
const onAbort = (): void => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
resolve(false);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
child.once("error", () => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(false);
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(code === 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async extract(
|
||||
input: ExtractInput,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const filter = buildScaleFilter(input.width, input.height, input.fit);
|
||||
const qualityArgs = buildQualityArgs(input.format, input.quality);
|
||||
const outputOptions = [
|
||||
"-ss",
|
||||
input.timestamp,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
filter,
|
||||
...qualityArgs,
|
||||
"-y",
|
||||
];
|
||||
const command = this.command()
|
||||
.input(input.inputPath)
|
||||
.outputOptions(outputOptions)
|
||||
.output(input.outputPath);
|
||||
await this.run(command, signal, VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED);
|
||||
}
|
||||
|
||||
public async tile(input: TileInput, signal?: AbortSignal): Promise<void> {
|
||||
const vfFilter = [
|
||||
`fps=1/${input.interval}`,
|
||||
`scale=${input.thumbnailWidth}:${input.thumbnailHeight}`,
|
||||
`tile=${input.columns}x${input.rows}`,
|
||||
].join(",");
|
||||
const command = this.command()
|
||||
.input(input.inputPath)
|
||||
.outputOptions(["-vf", vfFilter, "-frames:v", "1", "-y"])
|
||||
.output(input.outputPath);
|
||||
await this.run(command, signal, VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED);
|
||||
}
|
||||
|
||||
public async probe(
|
||||
inputPath: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ProbeResult> {
|
||||
if (signal?.aborted) {
|
||||
throw new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.ABORTED,
|
||||
"videoThumbnails.errors.aborted",
|
||||
{ context: { reason: String(signal.reason) } }
|
||||
);
|
||||
}
|
||||
ffmpeg.setFfprobePath(this.ffprobePath);
|
||||
return new Promise<ProbeResult>((resolve, reject) => {
|
||||
ffmpeg.ffprobe(inputPath, (error, data) => {
|
||||
if (error != null) {
|
||||
reject(
|
||||
classifyRunnerError(
|
||||
error,
|
||||
signal,
|
||||
VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
|
||||
"videoThumbnails.errors.probeFailed"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(toProbeResult(data));
|
||||
} catch (parseError) {
|
||||
reject(parseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private command(): FfmpegCommand {
|
||||
const command = ffmpeg();
|
||||
command.setFfmpegPath(this.ffmpegPath);
|
||||
command.setFfprobePath(this.ffprobePath);
|
||||
return command;
|
||||
}
|
||||
|
||||
private run(
|
||||
command: FfmpegCommand,
|
||||
signal: AbortSignal | undefined,
|
||||
failureCode: typeof VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const settler = makeSettler(resolve, reject, signal);
|
||||
const onAbort = (): void =>
|
||||
settler.abort(() => {
|
||||
try {
|
||||
command.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignored — the command may already have exited
|
||||
}
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
command.on("end", () => settler.complete());
|
||||
command.on("error", (error: unknown) => {
|
||||
settler.fail(
|
||||
classifyRunnerError(
|
||||
error,
|
||||
signal,
|
||||
failureCode,
|
||||
"videoThumbnails.errors.extractFailed"
|
||||
)
|
||||
);
|
||||
});
|
||||
command.run();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal — single-shot settle helper used by the run pipeline. Exported for testability. */
|
||||
export interface Settler {
|
||||
complete(): void;
|
||||
fail(error: unknown): void;
|
||||
abort(onFirstAbort: () => void): void;
|
||||
}
|
||||
|
||||
/** Internal — exported for testability. */
|
||||
export function makeSettler(
|
||||
resolve: () => void,
|
||||
reject: (reason: unknown) => void,
|
||||
signal: AbortSignal | undefined
|
||||
): Settler {
|
||||
let settled = false;
|
||||
return {
|
||||
complete(): void {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve();
|
||||
},
|
||||
fail(error: unknown): void {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
reject(error);
|
||||
},
|
||||
abort(onFirstAbort: () => void): void {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
onFirstAbort();
|
||||
reject(
|
||||
new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.ABORTED,
|
||||
"videoThumbnails.errors.aborted",
|
||||
{
|
||||
context: {
|
||||
reason: String(signal?.reason),
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Internal — exported for testability. */
|
||||
export function toProbeResult(data: ffmpeg.FfprobeData): ProbeResult {
|
||||
const videoStream = (data.streams ?? []).find(
|
||||
(stream) => stream.codec_type === "video"
|
||||
);
|
||||
if (videoStream === undefined) {
|
||||
throw new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.VIDEO_INVALID,
|
||||
"videoThumbnails.errors.videoInvalid",
|
||||
{ context: { reason: "no-video-stream" } }
|
||||
);
|
||||
}
|
||||
const fpsRatio = videoStream.r_frame_rate ?? "30/1";
|
||||
const parts = fpsRatio.split("/");
|
||||
const rawNum = Number(parts[0]);
|
||||
const rawDen = Number(parts[1]);
|
||||
const num = Number.isFinite(rawNum) ? rawNum : 0;
|
||||
const den = Number.isFinite(rawDen) ? rawDen : 0;
|
||||
const fps = den === 0 ? num : num / den;
|
||||
const rawDuration = Number(data.format?.duration ?? 0);
|
||||
const duration =
|
||||
Number.isFinite(rawDuration) && rawDuration > 0 ? rawDuration : 0;
|
||||
return {
|
||||
duration,
|
||||
width: typeof videoStream.width === "number" ? videoStream.width : 0,
|
||||
height: typeof videoStream.height === "number" ? videoStream.height : 0,
|
||||
codec: videoStream.codec_name ?? "unknown",
|
||||
fps,
|
||||
bitrate: Number(data.format?.bit_rate ?? 0),
|
||||
};
|
||||
}
|
||||
15
packages/media/video/video-thumbnails/src/runner/index.ts
Normal file
15
packages/media/video/video-thumbnails/src/runner/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
FluentFfmpegRunner,
|
||||
type FluentFfmpegRunnerOptions,
|
||||
} from "./fluent-ffmpeg-runner.js";
|
||||
export {
|
||||
buildQualityArgs,
|
||||
buildScaleFilter,
|
||||
classifyRunnerError,
|
||||
} from "./runner.js";
|
||||
export type {
|
||||
ExtractInput,
|
||||
FfmpegRunner,
|
||||
ProbeResult,
|
||||
TileInput,
|
||||
} from "./runner.types.js";
|
||||
91
packages/media/video/video-thumbnails/src/runner/runner.md
Normal file
91
packages/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.
|
||||
104
packages/media/video/video-thumbnails/src/runner/runner.test.ts
Normal file
104
packages/media/video/video-thumbnails/src/runner/runner.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
|
||||
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
|
||||
import {
|
||||
buildQualityArgs,
|
||||
buildScaleFilter,
|
||||
classifyRunnerError,
|
||||
} from "./runner.js";
|
||||
|
||||
describe("buildScaleFilter", () => {
|
||||
it("emits the cover filter chain by default", { timeout: 5000 }, () => {
|
||||
expect(buildScaleFilter(320, 180, "cover")).toBe(
|
||||
"scale=320:180:force_original_aspect_ratio=increase,crop=320:180"
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a contain (decrease) filter", { timeout: 5000 }, () => {
|
||||
expect(buildScaleFilter(320, 180, "contain")).toBe(
|
||||
"scale=320:180:force_original_aspect_ratio=decrease"
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a fill (stretch) filter", { timeout: 5000 }, () => {
|
||||
expect(buildScaleFilter(320, 180, "fill")).toBe("scale=320:180");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildQualityArgs", () => {
|
||||
it("returns empty args for png", { timeout: 5000 }, () => {
|
||||
expect(buildQualityArgs("png", 80)).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps quality 80 to -q:v 7 for jpg", { timeout: 5000 }, () => {
|
||||
expect(buildQualityArgs("jpg", 80)).toEqual(["-q:v", "7"]);
|
||||
});
|
||||
|
||||
it("maps quality 1 to -q:v 31 (worst) for webp", { timeout: 5000 }, () => {
|
||||
expect(buildQualityArgs("webp", 1)).toEqual(["-q:v", "31"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyRunnerError", () => {
|
||||
it("returns ABORTED when signal is aborted", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort("manual-cancel");
|
||||
const error = classifyRunnerError(
|
||||
new Error("boom"),
|
||||
controller.signal,
|
||||
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
"videoThumbnails.errors.extractFailed"
|
||||
);
|
||||
expect(error).toBeInstanceOf(VideoThumbnailsError);
|
||||
expect(error.code).toBe(VIDEO_THUMBNAILS_CODES.ABORTED);
|
||||
});
|
||||
|
||||
it("returns the requested failure code otherwise", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = classifyRunnerError(
|
||||
new Error("ffmpeg crashed"),
|
||||
undefined,
|
||||
VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
|
||||
"videoThumbnails.errors.probeFailed"
|
||||
);
|
||||
expect(error.code).toBe(VIDEO_THUMBNAILS_CODES.PROBE_FAILED);
|
||||
expect(error.context?.stderr).toBe("ffmpeg crashed");
|
||||
});
|
||||
|
||||
it("stringifies non-Error causes for the stderr context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = classifyRunnerError(
|
||||
"raw string failure",
|
||||
undefined,
|
||||
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
"videoThumbnails.errors.extractFailed"
|
||||
);
|
||||
expect(error.context?.stderr).toBe("raw string failure");
|
||||
});
|
||||
|
||||
it("stringifies object causes whose message is non-string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = classifyRunnerError(
|
||||
{ message: 42 },
|
||||
undefined,
|
||||
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
"videoThumbnails.errors.extractFailed"
|
||||
);
|
||||
expect(typeof error.context?.stderr).toBe("string");
|
||||
});
|
||||
|
||||
it("preserves the abort reason in context", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort("user-clicked-cancel");
|
||||
const error = classifyRunnerError(
|
||||
new Error("anything"),
|
||||
controller.signal,
|
||||
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
"videoThumbnails.errors.extractFailed"
|
||||
);
|
||||
expect(error.context?.reason).toBe("user-clicked-cancel");
|
||||
});
|
||||
});
|
||||
74
packages/media/video/video-thumbnails/src/runner/runner.ts
Normal file
74
packages/media/video/video-thumbnails/src/runner/runner.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
|
||||
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
|
||||
|
||||
/**
|
||||
* Build the ffmpeg `scale=...` filter expression for the requested fit strategy.
|
||||
*
|
||||
* - `cover` upscales to cover the target then crops to the exact dimensions.
|
||||
* - `contain` scales down preserving aspect ratio, leaving letterbox padding.
|
||||
* - `fill` stretches the frame to the exact target dimensions.
|
||||
*/
|
||||
export function buildScaleFilter(
|
||||
width: number,
|
||||
height: number,
|
||||
fit: "cover" | "contain" | "fill"
|
||||
): string {
|
||||
if (fit === "contain") {
|
||||
return `scale=${width}:${height}:force_original_aspect_ratio=decrease`;
|
||||
}
|
||||
if (fit === "fill") {
|
||||
return `scale=${width}:${height}`;
|
||||
}
|
||||
return `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a `quality` (1–100) into ffmpeg's inverted `-q:v` 1–31 scale for codecs
|
||||
* that use it (MJPEG/JPEG/WebP). PNG ignores `-q:v`.
|
||||
*/
|
||||
export function buildQualityArgs(
|
||||
format: "jpg" | "png" | "webp",
|
||||
quality: number
|
||||
): readonly string[] {
|
||||
if (format === "png") {
|
||||
return [];
|
||||
}
|
||||
const q = Math.round(31 - (quality / 100) * 30);
|
||||
return ["-q:v", String(q)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard helper: convert a raw `unknown` thrown from ffmpeg into the appropriate
|
||||
* `VideoThumbnailsError`. Used by every runner implementation to keep the
|
||||
* abort-vs-failure decision in one place.
|
||||
*/
|
||||
export function classifyRunnerError(
|
||||
cause: unknown,
|
||||
signal: AbortSignal | undefined,
|
||||
failureCode:
|
||||
| typeof VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED
|
||||
| typeof VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
|
||||
failureKey: string
|
||||
): VideoThumbnailsError {
|
||||
if (signal?.aborted) {
|
||||
return new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.ABORTED,
|
||||
"videoThumbnails.errors.aborted",
|
||||
{ cause, context: { reason: String(signal.reason) } }
|
||||
);
|
||||
}
|
||||
return new VideoThumbnailsError(failureCode, failureKey, {
|
||||
cause,
|
||||
context: { stderr: extractStderr(cause) },
|
||||
});
|
||||
}
|
||||
|
||||
function extractStderr(cause: unknown): string {
|
||||
if (typeof cause === "object" && cause !== null) {
|
||||
const message = (cause as { message?: unknown }).message;
|
||||
if (typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(cause);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Arguments accepted by `FfmpegRunner.extract`. */
|
||||
export interface ExtractInput {
|
||||
readonly inputPath: string;
|
||||
readonly outputPath: string;
|
||||
readonly timestamp: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly fit: "cover" | "contain" | "fill";
|
||||
readonly format: "jpg" | "png" | "webp";
|
||||
readonly quality: number;
|
||||
}
|
||||
|
||||
/** Arguments accepted by `FfmpegRunner.tile`. */
|
||||
export interface TileInput {
|
||||
readonly inputPath: string;
|
||||
readonly outputPath: string;
|
||||
readonly columns: number;
|
||||
readonly rows: number;
|
||||
readonly thumbnailWidth: number;
|
||||
readonly thumbnailHeight: number;
|
||||
readonly interval: number;
|
||||
readonly format: "jpg" | "png";
|
||||
}
|
||||
|
||||
/** Raw probe payload returned by `FfmpegRunner.probe`. */
|
||||
export interface ProbeResult {
|
||||
readonly duration: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly codec: string;
|
||||
readonly fps: number;
|
||||
readonly bitrate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstraction over ffmpeg / ffprobe used by every generator.
|
||||
*
|
||||
* Implementations MUST honour the supplied `AbortSignal` by terminating the
|
||||
* underlying ffmpeg process and rejecting with `VIDEO_THUMBNAILS.ABORTED`.
|
||||
*/
|
||||
export interface FfmpegRunner {
|
||||
isAvailable(signal?: AbortSignal): Promise<boolean>;
|
||||
extract(input: ExtractInput, signal?: AbortSignal): Promise<void>;
|
||||
tile(input: TileInput, signal?: AbortSignal): Promise<void>;
|
||||
probe(inputPath: string, signal?: AbortSignal): Promise<ProbeResult>;
|
||||
}
|
||||
23
packages/media/video/video-thumbnails/src/schemas/index.ts
Normal file
23
packages/media/video/video-thumbnails/src/schemas/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
SPRITE_SHEET_FORMATS,
|
||||
SpriteSheetConfigSchema,
|
||||
SpriteSheetResultSchema,
|
||||
THUMBNAIL_FITS,
|
||||
THUMBNAIL_FORMATS,
|
||||
ThumbnailConfigSchema,
|
||||
ThumbnailOptionsSchema,
|
||||
ThumbnailResultSchema,
|
||||
TIMESTAMP_PATTERN,
|
||||
VideoMetadataSchema,
|
||||
} from "./schemas.js";
|
||||
export type {
|
||||
SpriteSheetConfig,
|
||||
SpriteSheetFormat,
|
||||
SpriteSheetResult,
|
||||
ThumbnailConfig,
|
||||
ThumbnailFit,
|
||||
ThumbnailFormat,
|
||||
ThumbnailOptions,
|
||||
ThumbnailResult,
|
||||
VideoMetadata,
|
||||
} from "./schemas.types.js";
|
||||
76
packages/media/video/video-thumbnails/src/schemas/schemas.md
Normal file
76
packages/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,118 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SpriteSheetConfigSchema,
|
||||
SpriteSheetResultSchema,
|
||||
ThumbnailConfigSchema,
|
||||
ThumbnailOptionsSchema,
|
||||
ThumbnailResultSchema,
|
||||
VideoMetadataSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("ThumbnailConfigSchema", () => {
|
||||
it("applies defaults when only outputDir is supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = ThumbnailConfigSchema.parse({ outputDir: "/tmp" });
|
||||
expect(parsed).toEqual({
|
||||
outputDir: "/tmp",
|
||||
width: 320,
|
||||
height: 180,
|
||||
quality: 80,
|
||||
format: "jpg",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects width = 0", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
ThumbnailConfigSchema.parse({ outputDir: "/tmp", width: 0 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects format=tiff", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
ThumbnailConfigSchema.parse({ outputDir: "/tmp", format: "tiff" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ThumbnailOptionsSchema", () => {
|
||||
it("defaults timestamp and fit", { timeout: 5000 }, () => {
|
||||
expect(ThumbnailOptionsSchema.parse({})).toEqual({
|
||||
timestamp: "00:00:01",
|
||||
fit: "cover",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts milliseconds in timestamp", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
ThumbnailOptionsSchema.parse({ timestamp: "00:00:01.500" })
|
||||
.timestamp
|
||||
).toBe("00:00:01.500");
|
||||
});
|
||||
|
||||
it("rejects malformed timestamps", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
ThumbnailOptionsSchema.parse({ timestamp: "0:0:0" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpriteSheetConfigSchema", () => {
|
||||
it("applies defaults", { timeout: 5000 }, () => {
|
||||
expect(SpriteSheetConfigSchema.parse({})).toEqual({
|
||||
columns: 5,
|
||||
rows: 5,
|
||||
thumbnailWidth: 160,
|
||||
thumbnailHeight: 90,
|
||||
interval: 10,
|
||||
format: "jpg",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("result schemas", () => {
|
||||
it("ThumbnailResultSchema validates a happy-path result", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = ThumbnailResultSchema.parse({
|
||||
outputPath: "/tmp/a.jpg",
|
||||
width: 320,
|
||||
height: 180,
|
||||
format: "jpg",
|
||||
sizeBytes: 1024,
|
||||
timestamp: "00:00:01",
|
||||
});
|
||||
expect(result.sizeBytes).toBe(1024);
|
||||
});
|
||||
|
||||
it("SpriteSheetResultSchema validates a happy-path result", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = SpriteSheetResultSchema.parse({
|
||||
outputPath: "/tmp/a.jpg",
|
||||
width: 800,
|
||||
height: 450,
|
||||
columns: 5,
|
||||
rows: 5,
|
||||
frameCount: 25,
|
||||
interval: 5,
|
||||
sizeBytes: 2048,
|
||||
});
|
||||
expect(result.frameCount).toBe(25);
|
||||
});
|
||||
|
||||
it("VideoMetadataSchema accepts zero-duration entries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
VideoMetadataSchema.parse({
|
||||
duration: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
codec: "unknown",
|
||||
fps: 0,
|
||||
bitrate: 0,
|
||||
}).codec
|
||||
).toBe("unknown");
|
||||
});
|
||||
});
|
||||
73
packages/media/video/video-thumbnails/src/schemas/schemas.ts
Normal file
73
packages/media/video/video-thumbnails/src/schemas/schemas.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Matches `HH:MM:SS` or `HH:MM:SS.mmm`. */
|
||||
export const TIMESTAMP_PATTERN = /^\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
|
||||
|
||||
/** Output formats supported for single-frame extraction. */
|
||||
export const THUMBNAIL_FORMATS = ["jpg", "png", "webp"] as const;
|
||||
|
||||
/** Output formats supported for sprite-sheet generation. */
|
||||
export const SPRITE_SHEET_FORMATS = ["jpg", "png"] as const;
|
||||
|
||||
/** Fit strategies supported when scaling the extracted frame. */
|
||||
export const THUMBNAIL_FITS = ["cover", "contain", "fill"] as const;
|
||||
|
||||
/** Zod schema validating `ThumbnailGenerator` construction config. */
|
||||
export const ThumbnailConfigSchema = z.object({
|
||||
outputDir: z.string().min(1, "outputDir must be non-empty"),
|
||||
width: z.number().int().positive().max(8192).default(320),
|
||||
height: z.number().int().positive().max(8192).default(180),
|
||||
quality: z.number().int().min(1).max(100).default(80),
|
||||
format: z.enum(THUMBNAIL_FORMATS).default("jpg"),
|
||||
});
|
||||
|
||||
/** Zod schema validating per-call extraction options. */
|
||||
export const ThumbnailOptionsSchema = z.object({
|
||||
timestamp: z
|
||||
.string()
|
||||
.regex(TIMESTAMP_PATTERN, "timestamp must be HH:MM:SS(.mmm)?")
|
||||
.default("00:00:01"),
|
||||
fit: z.enum(THUMBNAIL_FITS).default("cover"),
|
||||
});
|
||||
|
||||
/** Zod schema validating `SpriteSheetGenerator` construction config. */
|
||||
export const SpriteSheetConfigSchema = z.object({
|
||||
columns: z.number().int().positive().max(32).default(5),
|
||||
rows: z.number().int().positive().max(32).default(5),
|
||||
thumbnailWidth: z.number().int().positive().max(2048).default(160),
|
||||
thumbnailHeight: z.number().int().positive().max(2048).default(90),
|
||||
interval: z.number().positive().max(3600).default(10),
|
||||
format: z.enum(SPRITE_SHEET_FORMATS).default("jpg"),
|
||||
});
|
||||
|
||||
/** Zod schema for a successful single-frame extraction result. */
|
||||
export const ThumbnailResultSchema = z.object({
|
||||
outputPath: z.string(),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
format: z.enum(THUMBNAIL_FORMATS),
|
||||
sizeBytes: z.number().int().nonnegative(),
|
||||
timestamp: z.string(),
|
||||
});
|
||||
|
||||
/** Zod schema for a successful sprite-sheet generation result. */
|
||||
export const SpriteSheetResultSchema = z.object({
|
||||
outputPath: z.string(),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
columns: z.number().int().positive(),
|
||||
rows: z.number().int().positive(),
|
||||
frameCount: z.number().int().nonnegative(),
|
||||
interval: z.number().positive(),
|
||||
sizeBytes: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
/** Zod schema for `VideoMetadata`. */
|
||||
export const VideoMetadataSchema = z.object({
|
||||
duration: z.number().nonnegative(),
|
||||
width: z.number().int().nonnegative(),
|
||||
height: z.number().int().nonnegative(),
|
||||
codec: z.string(),
|
||||
fps: z.number().nonnegative(),
|
||||
bitrate: z.number().nonnegative(),
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { z } from "zod";
|
||||
import type {
|
||||
SpriteSheetConfigSchema,
|
||||
SpriteSheetResultSchema,
|
||||
ThumbnailConfigSchema,
|
||||
ThumbnailOptionsSchema,
|
||||
ThumbnailResultSchema,
|
||||
VideoMetadataSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
/** Output formats supported by single-frame extraction. */
|
||||
export type ThumbnailFormat = "jpg" | "png" | "webp";
|
||||
|
||||
/** Output formats supported by sprite-sheet generation. */
|
||||
export type SpriteSheetFormat = "jpg" | "png";
|
||||
|
||||
/** How the extracted thumbnail should fit the configured dimensions. */
|
||||
export type ThumbnailFit = "cover" | "contain" | "fill";
|
||||
|
||||
/** Validated configuration for a `ThumbnailGenerator`. */
|
||||
export type ThumbnailConfig = z.infer<typeof ThumbnailConfigSchema>;
|
||||
|
||||
/** Validated options for a single `generate` / `extractSlidingWindow` call. */
|
||||
export type ThumbnailOptions = z.infer<typeof ThumbnailOptionsSchema>;
|
||||
|
||||
/** Validated configuration for a `SpriteSheetGenerator`. */
|
||||
export type SpriteSheetConfig = z.infer<typeof SpriteSheetConfigSchema>;
|
||||
|
||||
/** Result of a successful single-frame extraction. */
|
||||
export type ThumbnailResult = z.infer<typeof ThumbnailResultSchema>;
|
||||
|
||||
/** Result of a successful sprite-sheet generation. */
|
||||
export type SpriteSheetResult = z.infer<typeof SpriteSheetResultSchema>;
|
||||
|
||||
/** Metadata returned by `ThumbnailGenerator.getMetadata`. */
|
||||
export type VideoMetadata = z.infer<typeof VideoMetadataSchema>;
|
||||
@@ -0,0 +1,6 @@
|
||||
export { SpriteSheetGenerator } from "./sprite-sheet.js";
|
||||
export type {
|
||||
SpriteSheetConfig,
|
||||
SpriteSheetConfigInput,
|
||||
SpriteSheetResult,
|
||||
} from "./sprite-sheet.types.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,187 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getFixtureVideo,
|
||||
makeOutputDir,
|
||||
safeRm,
|
||||
} from "../../tests/setup/fixtures.js";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
|
||||
import { SpriteSheetGenerator } from "./sprite-sheet.js";
|
||||
|
||||
let fixture: string;
|
||||
let outDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = await getFixtureVideo();
|
||||
outDir = await makeOutputDir("sprite");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await safeRm(outDir);
|
||||
});
|
||||
|
||||
describe("SpriteSheetGenerator", () => {
|
||||
it("produces a 3x3 sprite sheet for the fixture", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const generator = new SpriteSheetGenerator({
|
||||
columns: 3,
|
||||
rows: 3,
|
||||
thumbnailWidth: 80,
|
||||
thumbnailHeight: 60,
|
||||
interval: 1,
|
||||
format: "jpg",
|
||||
});
|
||||
const output = join(outDir, "sprite.jpg");
|
||||
const result = await generator.generate(fixture, output, 5);
|
||||
expect(result.width).toBe(240);
|
||||
expect(result.columns).toBe(3);
|
||||
expect(result.frameCount).toBeGreaterThanOrEqual(4);
|
||||
expect(result.frameCount).toBeLessThanOrEqual(9);
|
||||
expect((await stat(output)).size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("auto-detects duration via runner.probe", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const generator = new SpriteSheetGenerator({
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
thumbnailWidth: 80,
|
||||
thumbnailHeight: 60,
|
||||
interval: 1,
|
||||
format: "jpg",
|
||||
});
|
||||
const output = join(outDir, "auto.jpg");
|
||||
const result = await generator.generate(fixture, output);
|
||||
expect(result.frameCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects columns <= 0", { timeout: 5000 }, () => {
|
||||
expect(() => new SpriteSheetGenerator({ columns: 0 })).toThrow(
|
||||
expect.objectContaining({
|
||||
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects rows <= 0", { timeout: 5000 }, () => {
|
||||
expect(() => new SpriteSheetGenerator({ rows: 0 })).toThrow(
|
||||
expect.objectContaining({
|
||||
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects thumbnailHeight <= 0", { timeout: 5000 }, () => {
|
||||
expect(() => new SpriteSheetGenerator({ thumbnailHeight: 0 })).toThrow(
|
||||
expect.objectContaining({
|
||||
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects width <= 0", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new SpriteSheetGenerator({
|
||||
thumbnailWidth: 0,
|
||||
})
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsupported format", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new SpriteSheetGenerator({
|
||||
// @ts-expect-error — runtime boundary
|
||||
format: "webp",
|
||||
})
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid interval as INVALID_OPTIONS", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new SpriteSheetGenerator({
|
||||
interval: 0,
|
||||
})
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects explicit duration <= 0", { timeout: 30000 }, async () => {
|
||||
const generator = new SpriteSheetGenerator({ interval: 1 });
|
||||
await expect(
|
||||
generator.generate(fixture, join(outDir, "no.jpg"), 0)
|
||||
).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.VIDEO_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("records failure metrics and rethrows when runner.tile throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const generator = new SpriteSheetGenerator(
|
||||
{ interval: 1 },
|
||||
{
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
async extract() {
|
||||
/* unused */
|
||||
},
|
||||
async tile() {
|
||||
throw new Error("tile failed");
|
||||
},
|
||||
async probe() {
|
||||
return {
|
||||
duration: 5,
|
||||
width: 320,
|
||||
height: 240,
|
||||
codec: "h264",
|
||||
fps: 10,
|
||||
bitrate: 0,
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
await expect(
|
||||
generator.generate(fixture, join(outDir, "fail.jpg"))
|
||||
).rejects.toThrow(/tile failed/);
|
||||
});
|
||||
|
||||
it("rejects pre-aborted signal with ABORTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const generator = new SpriteSheetGenerator();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
generator.generate(
|
||||
fixture,
|
||||
join(outDir, "ab.jpg"),
|
||||
5,
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({ code: VIDEO_THUMBNAILS_CODES.ABORTED });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import type { ZodError } from "zod";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
|
||||
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
|
||||
import { FluentFfmpegRunner } from "../runner/fluent-ffmpeg-runner.js";
|
||||
import type { FfmpegRunner } from "../runner/runner.types.js";
|
||||
import {
|
||||
type SpriteSheetConfig,
|
||||
SpriteSheetConfigSchema,
|
||||
type SpriteSheetResult,
|
||||
} from "../schemas/index.js";
|
||||
import {
|
||||
getTelemetry,
|
||||
type VideoThumbnailsTelemetry,
|
||||
} from "../telemetry/telemetry.js";
|
||||
import type { SpriteSheetConfigInput } from "./sprite-sheet.types.js";
|
||||
|
||||
/**
|
||||
* Generates N×M sprite sheets via ffmpeg's `tile` filter.
|
||||
*
|
||||
* Auto-detects video duration via the runner's `probe` when the caller does
|
||||
* not pass one explicitly, then computes the actual frame count as the
|
||||
* smaller of `columns × rows` and `floor(duration / interval)`.
|
||||
*/
|
||||
export class SpriteSheetGenerator {
|
||||
private readonly config: SpriteSheetConfig;
|
||||
private readonly runner: FfmpegRunner;
|
||||
private readonly telemetry: VideoThumbnailsTelemetry;
|
||||
|
||||
public constructor(
|
||||
config: SpriteSheetConfigInput = {},
|
||||
runner: FfmpegRunner = new FluentFfmpegRunner()
|
||||
) {
|
||||
this.config = parseConfig(config);
|
||||
this.runner = runner;
|
||||
this.telemetry = getTelemetry();
|
||||
}
|
||||
|
||||
public async generate(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
duration?: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<SpriteSheetResult> {
|
||||
throwIfAborted(signal);
|
||||
const start = performance.now();
|
||||
const attrs = { format: this.config.format };
|
||||
try {
|
||||
const videoDuration =
|
||||
duration !== undefined
|
||||
? validateDuration(duration)
|
||||
: (await this.runner.probe(inputPath, signal)).duration;
|
||||
const {
|
||||
columns,
|
||||
thumbnailWidth,
|
||||
thumbnailHeight,
|
||||
format,
|
||||
interval,
|
||||
} = this.config;
|
||||
const maxFrames = columns * this.config.rows;
|
||||
const frameCount = Math.min(
|
||||
maxFrames,
|
||||
Math.max(0, Math.floor(videoDuration / interval))
|
||||
);
|
||||
const rows = Math.max(1, Math.ceil(frameCount / columns));
|
||||
await this.runner.tile(
|
||||
{
|
||||
inputPath,
|
||||
outputPath,
|
||||
columns,
|
||||
rows,
|
||||
thumbnailWidth,
|
||||
thumbnailHeight,
|
||||
interval,
|
||||
format,
|
||||
},
|
||||
signal
|
||||
);
|
||||
const fileStat = await stat(outputPath);
|
||||
const result: SpriteSheetResult = {
|
||||
outputPath,
|
||||
width: columns * thumbnailWidth,
|
||||
height: rows * thumbnailHeight,
|
||||
columns,
|
||||
rows,
|
||||
frameCount,
|
||||
interval,
|
||||
sizeBytes: fileStat.size,
|
||||
};
|
||||
this.telemetry.spriteSheetDuration.record(
|
||||
performance.now() - start,
|
||||
{ ...attrs, outcome: "success" }
|
||||
);
|
||||
this.telemetry.spriteSheetFrames.record(frameCount, attrs);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.telemetry.spriteSheetDuration.record(
|
||||
performance.now() - start,
|
||||
{ ...attrs, outcome: "failure" }
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfig(input: SpriteSheetConfigInput): SpriteSheetConfig {
|
||||
const parsed = SpriteSheetConfigSchema.safeParse(input);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
throw new VideoThumbnailsError(
|
||||
pickCode(parsed.error),
|
||||
"videoThumbnails.errors.invalidOptions",
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { issues: parsed.error.issues },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function pickCode(
|
||||
error: ZodError
|
||||
):
|
||||
| typeof VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS
|
||||
| typeof VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED
|
||||
| typeof VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS {
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path[0];
|
||||
if (
|
||||
key === "columns" ||
|
||||
key === "rows" ||
|
||||
key === "thumbnailWidth" ||
|
||||
key === "thumbnailHeight"
|
||||
) {
|
||||
return VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS;
|
||||
}
|
||||
if (key === "format") {
|
||||
return VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED;
|
||||
}
|
||||
}
|
||||
return VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS;
|
||||
}
|
||||
|
||||
function validateDuration(duration: number): number {
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
throw new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.VIDEO_INVALID,
|
||||
"videoThumbnails.errors.videoInvalid",
|
||||
{ context: { duration } }
|
||||
);
|
||||
}
|
||||
return duration;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.ABORTED,
|
||||
"videoThumbnails.errors.aborted",
|
||||
{ context: { reason: String(signal.reason) } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { SpriteSheetConfig, SpriteSheetResult } from "../schemas/index.js";
|
||||
|
||||
export type { SpriteSheetConfig, SpriteSheetResult };
|
||||
|
||||
/** Partial `SpriteSheetConfig` accepted by callers; defaults applied at parse. */
|
||||
export type SpriteSheetConfigInput = Partial<SpriteSheetConfig>;
|
||||
@@ -0,0 +1 @@
|
||||
export { getTelemetry, type VideoThumbnailsTelemetry } from "./telemetry.js";
|
||||
@@ -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,76 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getFixtureVideo,
|
||||
makeOutputDir,
|
||||
safeRm,
|
||||
} from "../../tests/setup/fixtures.js";
|
||||
import { ThumbnailGenerator } from "../thumbnail-generator/thumbnail-generator.js";
|
||||
import { getTelemetry } from "./telemetry.js";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 50,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
|
||||
let outDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
outDir = await makeOutputDir("telemetry");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await reader.shutdown();
|
||||
await provider.shutdown();
|
||||
metrics.disable();
|
||||
await safeRm(outDir);
|
||||
});
|
||||
|
||||
describe("telemetry", () => {
|
||||
it("constructs a telemetry bundle with four instruments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const telemetry = getTelemetry();
|
||||
expect(telemetry.extractDuration).toBeDefined();
|
||||
expect(telemetry.extractTotal).toBeDefined();
|
||||
expect(telemetry.spriteSheetDuration).toBeDefined();
|
||||
expect(telemetry.spriteSheetFrames).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits success counter after a real extract", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const fixture = await getFixtureVideo();
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
await generator.generate(fixture, { timestamp: "00:00:01" });
|
||||
await reader.forceFlush();
|
||||
const metricsData = exporter.getMetrics();
|
||||
const flat = metricsData
|
||||
.flatMap((entry) => entry.scopeMetrics)
|
||||
.flatMap((scope) => scope.metrics);
|
||||
const extractTotal = flat.find(
|
||||
(metric) =>
|
||||
metric.descriptor.name === "video_thumbnails.extract.total"
|
||||
);
|
||||
expect(extractTotal).toBeDefined();
|
||||
const success = extractTotal?.dataPoints.find(
|
||||
(point) => point.attributes.outcome === "success"
|
||||
);
|
||||
expect(success).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/** Bundle of every OTel instrument owned by `@suaveplan/video-thumbnails`. */
|
||||
export interface VideoThumbnailsTelemetry {
|
||||
readonly extractDuration: Histogram;
|
||||
readonly extractTotal: Counter;
|
||||
readonly spriteSheetDuration: Histogram;
|
||||
readonly spriteSheetFrames: Histogram;
|
||||
}
|
||||
|
||||
const METER_NAME = "@suaveplan/video-thumbnails";
|
||||
|
||||
/**
|
||||
* Build a fresh telemetry bundle backed by the global `metrics` provider.
|
||||
*
|
||||
* Called lazily by each generator so the package retains `sideEffects: false`
|
||||
* and consumers that only import a subpath (e.g. `./schemas`) do not pay the
|
||||
* cost of acquiring a meter.
|
||||
*/
|
||||
export function getTelemetry(): VideoThumbnailsTelemetry {
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
return {
|
||||
extractDuration: meter.createHistogram(
|
||||
"video_thumbnails.extract.duration_ms",
|
||||
{
|
||||
description:
|
||||
"Single-frame thumbnail extraction duration in milliseconds.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
extractTotal: meter.createCounter("video_thumbnails.extract.total", {
|
||||
description:
|
||||
"Total thumbnail extractions, partitioned by outcome and format.",
|
||||
}),
|
||||
spriteSheetDuration: meter.createHistogram(
|
||||
"video_thumbnails.sprite_sheet.duration_ms",
|
||||
{
|
||||
description:
|
||||
"Sprite-sheet generation duration in milliseconds.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
spriteSheetFrames: meter.createHistogram(
|
||||
"video_thumbnails.sprite_sheet.frames",
|
||||
{
|
||||
description: "Number of frames captured per sprite-sheet run.",
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
buildEvenlySpacedTimestamps,
|
||||
ThumbnailGenerator,
|
||||
} from "./thumbnail-generator.js";
|
||||
export type {
|
||||
ThumbnailConfig,
|
||||
ThumbnailConfigInput,
|
||||
ThumbnailOptions,
|
||||
ThumbnailOptionsInput,
|
||||
ThumbnailResult,
|
||||
VideoMetadata,
|
||||
} from "./thumbnail-generator.types.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,338 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getFixtureVideo,
|
||||
makeOutputDir,
|
||||
safeRm,
|
||||
} from "../../tests/setup/fixtures.js";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
buildEvenlySpacedTimestamps,
|
||||
ThumbnailGenerator,
|
||||
} from "./thumbnail-generator.js";
|
||||
|
||||
let fixture: string;
|
||||
let outDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = await getFixtureVideo();
|
||||
outDir = await makeOutputDir("thumb-gen");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await safeRm(outDir);
|
||||
});
|
||||
|
||||
describe("ThumbnailGenerator", () => {
|
||||
it("extracts a JPEG at the default timestamp", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const generator = new ThumbnailGenerator({
|
||||
outputDir: outDir,
|
||||
format: "jpg",
|
||||
width: 160,
|
||||
height: 120,
|
||||
});
|
||||
const result = await generator.generate(fixture);
|
||||
expect(result.format).toBe("jpg");
|
||||
expect(result.timestamp).toBe("00:00:01");
|
||||
expect((await stat(result.outputPath)).size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("supports png output", { timeout: 30000 }, async () => {
|
||||
const generator = new ThumbnailGenerator({
|
||||
outputDir: outDir,
|
||||
format: "png",
|
||||
});
|
||||
const result = await generator.generate(fixture, {
|
||||
timestamp: "00:00:02",
|
||||
});
|
||||
expect(result.outputPath.endsWith(".png")).toBe(true);
|
||||
});
|
||||
|
||||
it("supports webp output", { timeout: 30000 }, async () => {
|
||||
const generator = new ThumbnailGenerator({
|
||||
outputDir: outDir,
|
||||
format: "webp",
|
||||
});
|
||||
const result = await generator.generate(fixture, {
|
||||
timestamp: "00:00:03",
|
||||
});
|
||||
expect(result.outputPath.endsWith(".webp")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns one result per timestamp", { timeout: 30000 }, async () => {
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
const results = await generator.generateMultiple(fixture, [
|
||||
"00:00:01",
|
||||
"00:00:02",
|
||||
"00:00:03",
|
||||
]);
|
||||
expect(results).toHaveLength(3);
|
||||
for (const result of results) {
|
||||
expect((await stat(result.outputPath)).size).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("generateMultiple aborts mid-iteration", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
generator.generateMultiple(
|
||||
fixture,
|
||||
["00:00:01", "00:00:02"],
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({ code: VIDEO_THUMBNAILS_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("produces exactly N evenly-spaced thumbnails", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
const results = await generator.extractSlidingWindow(fixture, 4, 5);
|
||||
expect(results).toHaveLength(4);
|
||||
const timestamps = results.map((entry) => entry.timestamp);
|
||||
expect(timestamps).toEqual([
|
||||
"00:00:01.000",
|
||||
"00:00:02.000",
|
||||
"00:00:03.000",
|
||||
"00:00:04.000",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects sliding-window with INVALID_DIMENSIONS on count <= 0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
await expect(
|
||||
generator.extractSlidingWindow(fixture, 0, 5)
|
||||
).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns metadata for a real fixture", { timeout: 30000 }, async () => {
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
const metadata = await generator.getMetadata(fixture);
|
||||
expect(metadata.duration).toBeGreaterThan(4);
|
||||
expect(metadata.duration).toBeLessThan(6);
|
||||
expect(metadata.width).toBe(320);
|
||||
expect(metadata.height).toBe(240);
|
||||
expect(metadata.codec).toBe("h264");
|
||||
});
|
||||
|
||||
it("getMetadata rejects when pre-aborted", { timeout: 5000 }, async () => {
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
generator.getMetadata(fixture, controller.signal)
|
||||
).rejects.toMatchObject({ code: VIDEO_THUMBNAILS_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("reports ffmpeg available when binary is installed", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
expect(await generator.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts a long-running extract", { timeout: 30000 }, async () => {
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
generator.generate(
|
||||
fixture,
|
||||
{ timestamp: "00:00:01" },
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({ code: VIDEO_THUMBNAILS_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("rejects width <= 0", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() => new ThumbnailGenerator({ outputDir: outDir, width: 0 })
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed timestamp", { timeout: 30000 }, async () => {
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
await expect(
|
||||
generator.generate(fixture, { timestamp: "not-a-time" })
|
||||
).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.INVALID_TIMESTAMP,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported output format", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new ThumbnailGenerator({
|
||||
outputDir: outDir,
|
||||
// @ts-expect-error — testing runtime boundary
|
||||
format: "tiff",
|
||||
})
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown option keys (INVALID_OPTIONS)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// ZodSchema is strict only on keys it knows about — for fit, an invalid
|
||||
// enum lands under INVALID_OPTIONS through the parseOptions catch-all.
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
return expect(
|
||||
// @ts-expect-error — testing runtime boundary
|
||||
generator.generate(fixture, { fit: "nonsense" })
|
||||
).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty outputDir at construction", { timeout: 5000 }, () => {
|
||||
expect(() => new ThumbnailGenerator({ outputDir: "" })).toThrow(
|
||||
expect.objectContaining({
|
||||
code: VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate failure path", () => {
|
||||
it("records failure metrics and rethrows when runner.extract throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cause = new Error("runner exploded");
|
||||
const generator = new ThumbnailGenerator(
|
||||
{ outputDir: outDir },
|
||||
{
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
async extract() {
|
||||
throw cause;
|
||||
},
|
||||
async tile() {
|
||||
/* unused */
|
||||
},
|
||||
async probe() {
|
||||
return {
|
||||
duration: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
codec: "",
|
||||
fps: 0,
|
||||
bitrate: 0,
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
await expect(generator.generate(fixture)).rejects.toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureAvailable", () => {
|
||||
it("resolves when runner reports available", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
await expect(generator.ensureAvailable()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects with RUNNER_UNAVAILABLE when runner reports false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const generator = new ThumbnailGenerator(
|
||||
{ outputDir: outDir },
|
||||
{
|
||||
async isAvailable() {
|
||||
return false;
|
||||
},
|
||||
async extract() {
|
||||
/* unused */
|
||||
},
|
||||
async tile() {
|
||||
/* unused */
|
||||
},
|
||||
async probe() {
|
||||
return {
|
||||
duration: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
codec: "",
|
||||
fps: 0,
|
||||
bitrate: 0,
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
await expect(generator.ensureAvailable()).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.RUNNER_UNAVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects with ABORTED when signal pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
generator.ensureAvailable(controller.signal)
|
||||
).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildEvenlySpacedTimestamps", () => {
|
||||
it("evenly distributes across the supplied duration", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(buildEvenlySpacedTimestamps(3, 4)).toEqual([
|
||||
"00:00:01.000",
|
||||
"00:00:02.000",
|
||||
"00:00:03.000",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rounds milliseconds to three digits", { timeout: 5000 }, () => {
|
||||
expect(buildEvenlySpacedTimestamps(1, 1)).toEqual(["00:00:00.500"]);
|
||||
});
|
||||
|
||||
it("handles hour boundaries", { timeout: 5000 }, () => {
|
||||
expect(buildEvenlySpacedTimestamps(1, 7200)).toEqual(["01:00:00.000"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ts-expect-error guard", () => {
|
||||
it("re-asserts INVALID_DIMENSIONS on extractSlidingWindow with non-finite duration", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const generator = new ThumbnailGenerator({ outputDir: outDir });
|
||||
await expect(
|
||||
generator.extractSlidingWindow(fixture, 3, Number.POSITIVE_INFINITY)
|
||||
).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { ZodError } from "zod";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
|
||||
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
|
||||
import { FluentFfmpegRunner } from "../runner/fluent-ffmpeg-runner.js";
|
||||
import type { FfmpegRunner } from "../runner/runner.types.js";
|
||||
import {
|
||||
type ThumbnailConfig,
|
||||
ThumbnailConfigSchema,
|
||||
type ThumbnailOptions,
|
||||
ThumbnailOptionsSchema,
|
||||
type ThumbnailResult,
|
||||
type VideoMetadata,
|
||||
} from "../schemas/index.js";
|
||||
import {
|
||||
getTelemetry,
|
||||
type VideoThumbnailsTelemetry,
|
||||
} from "../telemetry/telemetry.js";
|
||||
import type {
|
||||
ThumbnailConfigInput,
|
||||
ThumbnailOptionsInput,
|
||||
} from "./thumbnail-generator.types.js";
|
||||
|
||||
/** Extension map per output format. */
|
||||
const FORMAT_EXTENSIONS: Readonly<Record<ThumbnailConfig["format"], string>> = {
|
||||
jpg: "jpg",
|
||||
png: "png",
|
||||
webp: "webp",
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates single-frame video thumbnails through an injectable {@link FfmpegRunner}.
|
||||
*
|
||||
* Defaults to {@link FluentFfmpegRunner}, which bundles ffmpeg/ffprobe binaries
|
||||
* via `@ffmpeg-installer/ffmpeg` and `@ffprobe-installer/ffprobe` so the
|
||||
* package works on any host out of the box.
|
||||
*/
|
||||
export class ThumbnailGenerator {
|
||||
private readonly config: ThumbnailConfig;
|
||||
private readonly runner: FfmpegRunner;
|
||||
private readonly telemetry: VideoThumbnailsTelemetry;
|
||||
|
||||
public constructor(
|
||||
config: ThumbnailConfigInput,
|
||||
runner: FfmpegRunner = new FluentFfmpegRunner()
|
||||
) {
|
||||
this.config = parseConfig(config);
|
||||
this.runner = runner;
|
||||
this.telemetry = getTelemetry();
|
||||
}
|
||||
|
||||
/** Extract a single thumbnail at the requested timestamp. */
|
||||
public async generate(
|
||||
inputPath: string,
|
||||
options?: ThumbnailOptionsInput,
|
||||
signal?: AbortSignal
|
||||
): Promise<ThumbnailResult> {
|
||||
throwIfAborted(signal);
|
||||
const parsedOptions = parseOptions(options);
|
||||
const start = performance.now();
|
||||
const attrs = {
|
||||
format: this.config.format,
|
||||
fit: parsedOptions.fit,
|
||||
};
|
||||
try {
|
||||
const result = await this.runOne(inputPath, parsedOptions, signal);
|
||||
this.telemetry.extractDuration.record(performance.now() - start, {
|
||||
...attrs,
|
||||
outcome: "success",
|
||||
});
|
||||
this.telemetry.extractTotal.add(1, {
|
||||
outcome: "success",
|
||||
format: attrs.format,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.telemetry.extractDuration.record(performance.now() - start, {
|
||||
...attrs,
|
||||
outcome: "failure",
|
||||
});
|
||||
this.telemetry.extractTotal.add(1, {
|
||||
outcome: "failure",
|
||||
format: attrs.format,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract one thumbnail per supplied timestamp. */
|
||||
public async generateMultiple(
|
||||
inputPath: string,
|
||||
timestamps: readonly string[],
|
||||
signal?: AbortSignal
|
||||
): Promise<ThumbnailResult[]> {
|
||||
const results: ThumbnailResult[] = [];
|
||||
for (const timestamp of timestamps) {
|
||||
throwIfAborted(signal);
|
||||
results.push(await this.generate(inputPath, { timestamp }, signal));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Extract `count` thumbnails evenly distributed across the given duration. */
|
||||
public async extractSlidingWindow(
|
||||
inputPath: string,
|
||||
count: number,
|
||||
durationSeconds: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<ThumbnailResult[]> {
|
||||
if (
|
||||
!Number.isFinite(count) ||
|
||||
!Number.isInteger(count) ||
|
||||
count <= 0 ||
|
||||
!Number.isFinite(durationSeconds) ||
|
||||
durationSeconds <= 0
|
||||
) {
|
||||
throw new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS,
|
||||
"videoThumbnails.errors.invalidDimensions",
|
||||
{
|
||||
context: { count, durationSeconds },
|
||||
}
|
||||
);
|
||||
}
|
||||
const timestamps = buildEvenlySpacedTimestamps(count, durationSeconds);
|
||||
return this.generateMultiple(inputPath, timestamps, signal);
|
||||
}
|
||||
|
||||
/** Probe the video for metadata via ffprobe. */
|
||||
public async getMetadata(
|
||||
inputPath: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<VideoMetadata> {
|
||||
throwIfAborted(signal);
|
||||
return this.runner.probe(inputPath, signal);
|
||||
}
|
||||
|
||||
/** Whether the configured runner reports ffmpeg available. */
|
||||
public async isAvailable(signal?: AbortSignal): Promise<boolean> {
|
||||
return this.runner.isAvailable(signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert ffmpeg is available via the configured runner; throw
|
||||
* `VIDEO_THUMBNAILS.RUNNER_UNAVAILABLE` otherwise. Useful as an early-fail
|
||||
* guard before kicking off an expensive batch.
|
||||
*/
|
||||
public async ensureAvailable(signal?: AbortSignal): Promise<void> {
|
||||
throwIfAborted(signal);
|
||||
if (!(await this.runner.isAvailable(signal))) {
|
||||
throw new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.RUNNER_UNAVAILABLE,
|
||||
"videoThumbnails.errors.runnerUnavailable",
|
||||
{ context: { reason: "isAvailable-returned-false" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async runOne(
|
||||
inputPath: string,
|
||||
options: ThumbnailOptions,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<ThumbnailResult> {
|
||||
const ext = FORMAT_EXTENSIONS[this.config.format];
|
||||
const filename = `thumb_${options.timestamp.replace(/[:.]/g, "-")}.${ext}`;
|
||||
const outputPath = join(this.config.outputDir, filename);
|
||||
await this.runner.extract(
|
||||
{
|
||||
inputPath,
|
||||
outputPath,
|
||||
timestamp: options.timestamp,
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
fit: options.fit,
|
||||
format: this.config.format,
|
||||
quality: this.config.quality,
|
||||
},
|
||||
signal
|
||||
);
|
||||
const fileStat = await stat(outputPath);
|
||||
return {
|
||||
outputPath,
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
format: this.config.format,
|
||||
sizeBytes: fileStat.size,
|
||||
timestamp: options.timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfig(input: ThumbnailConfigInput): ThumbnailConfig {
|
||||
const parsed = ThumbnailConfigSchema.safeParse(input);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
throw translateZod(parsed.error, classifyConfigError(parsed.error));
|
||||
}
|
||||
|
||||
function parseOptions(
|
||||
input: ThumbnailOptionsInput | undefined
|
||||
): ThumbnailOptions {
|
||||
const parsed = ThumbnailOptionsSchema.safeParse(input ?? {});
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
const issue = parsed.error.issues[0];
|
||||
if (issue?.path[0] === "timestamp") {
|
||||
throw new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.INVALID_TIMESTAMP,
|
||||
"videoThumbnails.errors.invalidTimestamp",
|
||||
{ cause: parsed.error, context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
throw translateZod(parsed.error, VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
|
||||
function classifyConfigError(
|
||||
error: ZodError
|
||||
):
|
||||
| typeof VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS
|
||||
| typeof VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED
|
||||
| typeof VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS {
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path[0];
|
||||
if (key === "width" || key === "height") {
|
||||
return VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS;
|
||||
}
|
||||
if (key === "format") {
|
||||
return VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED;
|
||||
}
|
||||
}
|
||||
return VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS;
|
||||
}
|
||||
|
||||
function translateZod(
|
||||
error: ZodError,
|
||||
code:
|
||||
| typeof VIDEO_THUMBNAILS_CODES.INVALID_OPTIONS
|
||||
| typeof VIDEO_THUMBNAILS_CODES.INVALID_DIMENSIONS
|
||||
| typeof VIDEO_THUMBNAILS_CODES.FORMAT_UNSUPPORTED
|
||||
): VideoThumbnailsError {
|
||||
return new VideoThumbnailsError(
|
||||
code,
|
||||
"videoThumbnails.errors.invalidOptions",
|
||||
{
|
||||
cause: error,
|
||||
context: { issues: error.issues },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.ABORTED,
|
||||
"videoThumbnails.errors.aborted",
|
||||
{ context: { reason: String(signal.reason) } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build `count` evenly-spaced `HH:MM:SS.mmm` timestamps across `durationSeconds`. */
|
||||
export function buildEvenlySpacedTimestamps(
|
||||
count: number,
|
||||
durationSeconds: number
|
||||
): string[] {
|
||||
const slot = durationSeconds / (count + 1);
|
||||
const out: string[] = [];
|
||||
for (let index = 1; index <= count; index += 1) {
|
||||
out.push(formatTimestamp(slot * index));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
const ms = Math.max(0, Math.round(seconds * 1000));
|
||||
const hours = Math.floor(ms / 3_600_000);
|
||||
const minutes = Math.floor((ms % 3_600_000) / 60_000);
|
||||
const secs = Math.floor((ms % 60_000) / 1000);
|
||||
const millis = ms % 1000;
|
||||
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(secs, 2)}.${pad(millis, 3)}`;
|
||||
}
|
||||
|
||||
function pad(value: number, width: number): string {
|
||||
return value.toString().padStart(width, "0");
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
ThumbnailConfig,
|
||||
ThumbnailOptions,
|
||||
ThumbnailResult,
|
||||
VideoMetadata,
|
||||
} from "../schemas/index.js";
|
||||
|
||||
export type {
|
||||
ThumbnailConfig,
|
||||
ThumbnailOptions,
|
||||
ThumbnailResult,
|
||||
VideoMetadata,
|
||||
};
|
||||
|
||||
/** Optional partial `ThumbnailConfig` accepted by callers (defaults applied at parse). */
|
||||
export type ThumbnailConfigInput = Partial<ThumbnailConfig> & {
|
||||
outputDir: string;
|
||||
};
|
||||
|
||||
/** Optional partial `ThumbnailOptions` accepted by callers. */
|
||||
export type ThumbnailOptionsInput = Partial<ThumbnailOptions>;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import ffmpegInstaller from "@ffmpeg-installer/ffmpeg";
|
||||
|
||||
/** Path of the on-the-fly fixture video. Cached per Vitest worker. */
|
||||
let fixturePath: string | undefined;
|
||||
let fixtureDir: string | undefined;
|
||||
|
||||
/**
|
||||
* Synthesise a 5-second 320×240 colour-gradient MP4 once per worker and return
|
||||
* its absolute path. Subsequent calls return the cached path. Uses ffmpeg's
|
||||
* `lavfi` virtual source so no checked-in binary is required.
|
||||
*/
|
||||
export async function getFixtureVideo(): Promise<string> {
|
||||
if (fixturePath !== undefined) {
|
||||
return fixturePath;
|
||||
}
|
||||
const dir = await mkdtemp(join(tmpdir(), "vt-fixture-"));
|
||||
const out = join(dir, "fixture.mp4");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
ffmpegInstaller.path,
|
||||
[
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc=duration=5:size=320x240:rate=10",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-tune",
|
||||
"zerolatency",
|
||||
out,
|
||||
],
|
||||
{ stdio: "pipe" }
|
||||
);
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`ffmpeg fixture build failed: ${stderr}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
const fileStat = await stat(out);
|
||||
if (fileStat.size === 0) {
|
||||
throw new Error("fixture video has zero bytes");
|
||||
}
|
||||
fixtureDir = dir;
|
||||
fixturePath = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Create a unique tmp output directory for one test. */
|
||||
export async function makeOutputDir(label: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), `vt-${label}-`));
|
||||
await mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Best-effort cleanup; safe to ignore failures (Windows / locked files). */
|
||||
export async function safeRm(path: string): Promise<void> {
|
||||
try {
|
||||
await rm(path, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the cached fixture (used by setup teardown). */
|
||||
export async function clearFixtureCache(): Promise<void> {
|
||||
if (fixtureDir !== undefined) {
|
||||
await safeRm(fixtureDir);
|
||||
}
|
||||
fixturePath = undefined;
|
||||
fixtureDir = undefined;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
12
packages/media/video/video-thumbnails/tsconfig.lib.json
Normal file
12
packages/media/video/video-thumbnails/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
38
packages/media/video/video-thumbnails/vite.config.ts
Normal file
38
packages/media/video/video-thumbnails/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/video-thumbnails",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@ffmpeg-installer/ffmpeg",
|
||||
"fluent-ffmpeg",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/media/video/video-thumbnails/vitest.config.ts
Normal file
31
packages/media/video/video-thumbnails/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 60000,
|
||||
},
|
||||
});
|
||||
109
packages/media/video/video/README.md
Normal file
109
packages/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
|
||||
|
||||
172
packages/media/video/video/package.json
Normal file
172
packages/media/video/video/package.json
Normal file
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"name": "@suaveplan/video",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal video metadata extraction, format detection, resolution math, transform pipeline planning, and a fluent pipeline builder",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./errors/errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/errors.d.ts",
|
||||
"default": "./dist/errors/errors.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/errors.d.ts",
|
||||
"default": "./dist/errors/errors.js"
|
||||
}
|
||||
},
|
||||
"./types/video-types": {
|
||||
"import": {
|
||||
"types": "./dist/types/video-types.d.ts",
|
||||
"default": "./dist/types/video-types.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/video-types.d.ts",
|
||||
"default": "./dist/types/video-types.js"
|
||||
}
|
||||
},
|
||||
"./detection/detection": {
|
||||
"import": {
|
||||
"types": "./dist/detection/detection.d.ts",
|
||||
"default": "./dist/detection/detection.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/detection/detection.d.ts",
|
||||
"default": "./dist/detection/detection.js"
|
||||
}
|
||||
},
|
||||
"./resize/resize": {
|
||||
"import": {
|
||||
"types": "./dist/resize/resize.d.ts",
|
||||
"default": "./dist/resize/resize.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/resize/resize.d.ts",
|
||||
"default": "./dist/resize/resize.js"
|
||||
}
|
||||
},
|
||||
"./trim/trim": {
|
||||
"import": {
|
||||
"types": "./dist/trim/trim.d.ts",
|
||||
"default": "./dist/trim/trim.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/trim/trim.d.ts",
|
||||
"default": "./dist/trim/trim.js"
|
||||
}
|
||||
},
|
||||
"./duration/duration": {
|
||||
"import": {
|
||||
"types": "./dist/duration/duration.d.ts",
|
||||
"default": "./dist/duration/duration.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/duration/duration.d.ts",
|
||||
"default": "./dist/duration/duration.js"
|
||||
}
|
||||
},
|
||||
"./format/format": {
|
||||
"import": {
|
||||
"types": "./dist/format/format.d.ts",
|
||||
"default": "./dist/format/format.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/format/format.d.ts",
|
||||
"default": "./dist/format/format.js"
|
||||
}
|
||||
},
|
||||
"./instruments/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/instruments/instruments.d.ts",
|
||||
"default": "./dist/instruments/instruments.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/instruments/instruments.d.ts",
|
||||
"default": "./dist/instruments/instruments.js"
|
||||
}
|
||||
},
|
||||
"./processor/processor": {
|
||||
"import": {
|
||||
"types": "./dist/processor/processor.d.ts",
|
||||
"default": "./dist/processor/processor.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/processor/processor.d.ts",
|
||||
"default": "./dist/processor/processor.js"
|
||||
}
|
||||
},
|
||||
"./pipeline-builder/pipeline-builder": {
|
||||
"import": {
|
||||
"types": "./dist/pipeline-builder/pipeline-builder.d.ts",
|
||||
"default": "./dist/pipeline-builder/pipeline-builder.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/pipeline-builder/pipeline-builder.d.ts",
|
||||
"default": "./dist/pipeline-builder/pipeline-builder.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
78
packages/media/video/video/src/detection/detection.md
Normal file
78
packages/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).
|
||||
71
packages/media/video/video/src/detection/detection.test.ts
Normal file
71
packages/media/video/video/src/detection/detection.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detectVideoFormat } from "./detection.js";
|
||||
|
||||
function header(...prefix: number[]): Uint8Array {
|
||||
const buf = new Uint8Array(16);
|
||||
buf.set(prefix);
|
||||
return buf;
|
||||
}
|
||||
|
||||
describe("detectVideoFormat", () => {
|
||||
it("detects MP4 (ftyp)", { timeout: 5000 }, () => {
|
||||
const h = header(0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70);
|
||||
h.set([0x69, 0x73, 0x6f, 0x6d], 8);
|
||||
expect(detectVideoFormat(h)).toBe("mp4");
|
||||
});
|
||||
|
||||
it("detects MOV (qt brand)", { timeout: 5000 }, () => {
|
||||
const h = header(0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70);
|
||||
h.set([0x71, 0x74, 0x20, 0x20], 8);
|
||||
expect(detectVideoFormat(h)).toBe("mov");
|
||||
});
|
||||
|
||||
it("detects MKV", { timeout: 5000 }, () => {
|
||||
expect(detectVideoFormat(header(0x1a, 0x45, 0xdf, 0xa3))).toBe("mkv");
|
||||
});
|
||||
|
||||
it("detects AVI", { timeout: 5000 }, () => {
|
||||
const h = header(0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00);
|
||||
h.set([0x41, 0x56, 0x49, 0x20], 8);
|
||||
expect(detectVideoFormat(h)).toBe("avi");
|
||||
});
|
||||
|
||||
it("detects FLV", { timeout: 5000 }, () => {
|
||||
expect(detectVideoFormat(header(0x46, 0x4c, 0x56, 0x01))).toBe("flv");
|
||||
});
|
||||
|
||||
it("detects OGG", { timeout: 5000 }, () => {
|
||||
expect(detectVideoFormat(header(0x4f, 0x67, 0x67, 0x53))).toBe("ogg");
|
||||
});
|
||||
|
||||
it("returns null for unknown content", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
detectVideoFormat(
|
||||
new Uint8Array([
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09,
|
||||
0x0a, 0x0b,
|
||||
])
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for short header", { timeout: 5000 }, () => {
|
||||
expect(detectVideoFormat(new Uint8Array([0x00, 0x01]))).toBeNull();
|
||||
});
|
||||
|
||||
it("RIFF without AVI tag is unknown", { timeout: 5000 }, () => {
|
||||
const h = header(0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00);
|
||||
// WAVE — RIFF but not AVI.
|
||||
h.set([0x57, 0x41, 0x56, 0x45], 8);
|
||||
expect(detectVideoFormat(h)).toBeNull();
|
||||
});
|
||||
|
||||
it("ftyp with bare 'q' (no second t) is mp4 not mov", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const h = header(0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70);
|
||||
h.set([0x71, 0x00, 0x20, 0x20], 8);
|
||||
// brand starts with "q\0" — startsWith("qt") fails so we get mp4.
|
||||
expect(detectVideoFormat(h)).toBe("mp4");
|
||||
});
|
||||
});
|
||||
69
packages/media/video/video/src/detection/detection.ts
Normal file
69
packages/media/video/video/src/detection/detection.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { VideoFormat } from "../types/video-types/video-types.js";
|
||||
|
||||
/**
|
||||
* Detect the video container format from the magic bytes at the start of a
|
||||
* buffer. Returns `null` when no known signature matches or the buffer is
|
||||
* shorter than 12 bytes.
|
||||
*/
|
||||
export function detectVideoFormat(header: Uint8Array): VideoFormat | null {
|
||||
if (header.length < 12) return null;
|
||||
|
||||
// ISO Base Media (MP4 / MOV) — bytes 4..7 must be "ftyp".
|
||||
if (
|
||||
header[4] === 0x66 &&
|
||||
header[5] === 0x74 &&
|
||||
header[6] === 0x79 &&
|
||||
header[7] === 0x70
|
||||
) {
|
||||
// Length check above guarantees header[8..11] are defined.
|
||||
const brand = String.fromCharCode(
|
||||
header[8] as number,
|
||||
header[9] as number,
|
||||
header[10] as number,
|
||||
header[11] as number
|
||||
);
|
||||
if (brand.startsWith("qt")) return "mov";
|
||||
return "mp4";
|
||||
}
|
||||
|
||||
// Matroska / WebM EBML header.
|
||||
if (
|
||||
header[0] === 0x1a &&
|
||||
header[1] === 0x45 &&
|
||||
header[2] === 0xdf &&
|
||||
header[3] === 0xa3
|
||||
) {
|
||||
return "mkv";
|
||||
}
|
||||
|
||||
// RIFF AVI — "RIFF" + four-byte size + "AVI ".
|
||||
if (
|
||||
header[0] === 0x52 &&
|
||||
header[1] === 0x49 &&
|
||||
header[2] === 0x46 &&
|
||||
header[3] === 0x46 &&
|
||||
header[8] === 0x41 &&
|
||||
header[9] === 0x56 &&
|
||||
header[10] === 0x49 &&
|
||||
header[11] === 0x20
|
||||
) {
|
||||
return "avi";
|
||||
}
|
||||
|
||||
// Adobe FLV — first three bytes are "FLV".
|
||||
if (header[0] === 0x46 && header[1] === 0x4c && header[2] === 0x56) {
|
||||
return "flv";
|
||||
}
|
||||
|
||||
// Ogg "OggS".
|
||||
if (
|
||||
header[0] === 0x4f &&
|
||||
header[1] === 0x67 &&
|
||||
header[2] === 0x67 &&
|
||||
header[3] === 0x53
|
||||
) {
|
||||
return "ogg";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
1
packages/media/video/video/src/detection/index.ts
Normal file
1
packages/media/video/video/src/detection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { detectVideoFormat } from "./detection.js";
|
||||
73
packages/media/video/video/src/duration/duration.md
Normal file
73
packages/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.
|
||||
46
packages/media/video/video/src/duration/duration.test.ts
Normal file
46
packages/media/video/video/src/duration/duration.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { formatDuration, parseDuration } from "./duration.js";
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("formats 0 seconds", { timeout: 5000 }, () => {
|
||||
expect(formatDuration(0)).toBe("00:00:00");
|
||||
});
|
||||
|
||||
it("formats seconds-only input", { timeout: 5000 }, () => {
|
||||
expect(formatDuration(45)).toBe("00:00:45");
|
||||
});
|
||||
|
||||
it("formats minutes and seconds", { timeout: 5000 }, () => {
|
||||
expect(formatDuration(125)).toBe("00:02:05");
|
||||
});
|
||||
|
||||
it("formats hours", { timeout: 5000 }, () => {
|
||||
expect(formatDuration(3661)).toBe("01:01:01");
|
||||
});
|
||||
|
||||
it("floors fractional seconds", { timeout: 5000 }, () => {
|
||||
expect(formatDuration(125.9)).toBe("00:02:05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDuration", () => {
|
||||
it("parses HH:MM:SS", { timeout: 5000 }, () => {
|
||||
expect(parseDuration("01:30:00")).toBe(5400);
|
||||
});
|
||||
|
||||
it("parses MM:SS", { timeout: 5000 }, () => {
|
||||
expect(parseDuration("02:30")).toBe(150);
|
||||
});
|
||||
|
||||
it("parses seconds only", { timeout: 5000 }, () => {
|
||||
expect(parseDuration("45")).toBe(45);
|
||||
});
|
||||
|
||||
it("empty string returns 0", { timeout: 5000 }, () => {
|
||||
expect(parseDuration("")).toBe(0);
|
||||
});
|
||||
|
||||
it("round-trips with formatDuration", { timeout: 5000 }, () => {
|
||||
expect(parseDuration(formatDuration(3661))).toBe(3661);
|
||||
});
|
||||
});
|
||||
38
packages/media/video/video/src/duration/duration.ts
Normal file
38
packages/media/video/video/src/duration/duration.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Format a non-negative seconds value as zero-padded `HH:MM:SS`.
|
||||
*
|
||||
* Fractional seconds are floored.
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return [
|
||||
h.toString().padStart(2, "0"),
|
||||
m.toString().padStart(2, "0"),
|
||||
s.toString().padStart(2, "0"),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a duration string into a seconds total.
|
||||
*
|
||||
* Accepts `SS`, `MM:SS`, or `HH:MM:SS` forms. An empty string returns `0`.
|
||||
* Components are parsed via `Number`, so non-numeric input yields `NaN`
|
||||
* propagation — callers SHOULD pre-validate before passing untrusted input.
|
||||
*/
|
||||
export function parseDuration(duration: string): number {
|
||||
const parts = duration.split(":").map(Number);
|
||||
// split returns >=1 element; index access widths are guaranteed by length checks.
|
||||
if (parts.length === 3) {
|
||||
return (
|
||||
(parts[0] as number) * 3600 +
|
||||
(parts[1] as number) * 60 +
|
||||
(parts[2] as number)
|
||||
);
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return (parts[0] as number) * 60 + (parts[1] as number);
|
||||
}
|
||||
return parts[0] as number;
|
||||
}
|
||||
1
packages/media/video/video/src/duration/index.ts
Normal file
1
packages/media/video/video/src/duration/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { formatDuration, parseDuration } from "./duration.js";
|
||||
96
packages/media/video/video/src/error-codes/error-codes.md
Normal file
96
packages/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.
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
registerVideoErrorCodes,
|
||||
VIDEO_CODES,
|
||||
VIDEO_ERROR_METADATA,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("VIDEO_CODES", () => {
|
||||
it("namespaces every code under VIDEO.*", { timeout: 5000 }, () => {
|
||||
for (const value of Object.values(VIDEO_CODES)) {
|
||||
expect(value.startsWith("VIDEO.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("VIDEO_CODES covers every upstream failure mode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const required = [
|
||||
"UNKNOWN_FORMAT",
|
||||
"INVALID_DIMENSIONS",
|
||||
"INVALID_TRIM_RANGE",
|
||||
"METADATA_PARSE_FAILED",
|
||||
"INVALID_TRANSFORM",
|
||||
"DURATION_EXCEEDED",
|
||||
"UNSUPPORTED_CODEC",
|
||||
"NOT_FOUND",
|
||||
"DECODE_FAILED",
|
||||
"ENCODE_FAILED",
|
||||
"FORMAT_UNSUPPORTED",
|
||||
"TOO_LARGE",
|
||||
"PROCESSING_FAILED",
|
||||
"CODEC_UNSUPPORTED",
|
||||
"TIMEOUT",
|
||||
"CONFIG_INVALID",
|
||||
];
|
||||
for (const key of required) {
|
||||
expect(VIDEO_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("VIDEO_ERROR_METADATA", () => {
|
||||
it("VIDEO_ERROR_METADATA mirrors VIDEO_CODES", { timeout: 5000 }, () => {
|
||||
const codes = Object.values(VIDEO_CODES);
|
||||
const entries = Object.keys(
|
||||
VIDEO_ERROR_METADATA
|
||||
) as (keyof typeof VIDEO_ERROR_METADATA)[];
|
||||
expect(entries.length).toBe(codes.length);
|
||||
for (const code of codes) {
|
||||
expect(VIDEO_ERROR_METADATA).toHaveProperty(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("every entry has the full metadata shape", { timeout: 5000 }, () => {
|
||||
for (const meta of Object.values(VIDEO_ERROR_METADATA)) {
|
||||
expect(typeof meta.httpStatus).toBe("number");
|
||||
expect(
|
||||
meta.severity === "warning" || meta.severity === "error"
|
||||
).toBe(true);
|
||||
expect(typeof meta.retryable).toBe("boolean");
|
||||
expect(typeof meta.userMessageKey).toBe("string");
|
||||
expect(typeof meta.category).toBe("string");
|
||||
expect(typeof meta.description).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("TIMEOUT and ENCODE_FAILED carry an exponential retry policy", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = VIDEO_ERROR_METADATA[VIDEO_CODES.TIMEOUT];
|
||||
const en = VIDEO_ERROR_METADATA[VIDEO_CODES.ENCODE_FAILED];
|
||||
expect(tm.retry?.strategy).toBe("exponential");
|
||||
expect(tm.retry?.maxAttempts).toBe(3);
|
||||
expect(en.retry?.strategy).toBe("exponential");
|
||||
expect(en.retry?.maxAttempts).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerVideoErrorCodes", () => {
|
||||
it("returns the metadata table without side effects", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = registerVideoErrorCodes();
|
||||
const b = registerVideoErrorCodes();
|
||||
expect(a).toBe(VIDEO_ERROR_METADATA);
|
||||
expect(b).toBe(VIDEO_ERROR_METADATA);
|
||||
});
|
||||
});
|
||||
211
packages/media/video/video/src/error-codes/error-codes.ts
Normal file
211
packages/media/video/video/src/error-codes/error-codes.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { defineCodes } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error code registry for `@suaveplan/video`.
|
||||
*
|
||||
* Codes are namespaced under `VIDEO` so structured-error consumers
|
||||
* (problem+json mappers, retry policies, OTel span events) can discriminate
|
||||
* by prefix.
|
||||
*/
|
||||
export const VIDEO_CODES = defineCodes("VIDEO", [
|
||||
"UNKNOWN_FORMAT",
|
||||
"INVALID_DIMENSIONS",
|
||||
"INVALID_TRIM_RANGE",
|
||||
"METADATA_PARSE_FAILED",
|
||||
"INVALID_TRANSFORM",
|
||||
"DURATION_EXCEEDED",
|
||||
"UNSUPPORTED_CODEC",
|
||||
"NOT_FOUND",
|
||||
"DECODE_FAILED",
|
||||
"ENCODE_FAILED",
|
||||
"FORMAT_UNSUPPORTED",
|
||||
"TOO_LARGE",
|
||||
"PROCESSING_FAILED",
|
||||
"CODEC_UNSUPPORTED",
|
||||
"TIMEOUT",
|
||||
"CONFIG_INVALID",
|
||||
] as const);
|
||||
|
||||
export type VideoCode = (typeof VIDEO_CODES)[keyof typeof VIDEO_CODES];
|
||||
|
||||
/** Severity level for a video error code. */
|
||||
export type VideoCodeSeverity = "warning" | "error";
|
||||
|
||||
/** Retry policy descriptor for a retryable code. */
|
||||
export interface VideoCodeRetryPolicy {
|
||||
readonly strategy: "exponential";
|
||||
readonly maxAttempts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata describing how an HTTP layer, retry wrapper, or i18n surface
|
||||
* should react to a particular video error code.
|
||||
*
|
||||
* Mirrors the upstream `VIDEO_ERROR_METADATA` table so downstream consumers
|
||||
* can keep their problem+json / retry policy mappings intact.
|
||||
*/
|
||||
export interface VideoCodeMetadata {
|
||||
readonly httpStatus: number;
|
||||
readonly severity: VideoCodeSeverity;
|
||||
readonly retryable: boolean;
|
||||
readonly retry?: VideoCodeRetryPolicy;
|
||||
readonly userMessageKey: string;
|
||||
readonly category: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
/** Table of every {@link VideoCode} mapped to its consumer-facing metadata. */
|
||||
export const VIDEO_ERROR_METADATA: Readonly<
|
||||
Record<VideoCode, VideoCodeMetadata>
|
||||
> = {
|
||||
[VIDEO_CODES.UNKNOWN_FORMAT]: {
|
||||
httpStatus: 415,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.unknownFormat",
|
||||
category: "video.validation",
|
||||
description:
|
||||
"The supplied buffer does not match any known video format.",
|
||||
},
|
||||
[VIDEO_CODES.INVALID_DIMENSIONS]: {
|
||||
httpStatus: 422,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.invalidDimensions",
|
||||
category: "video.validation",
|
||||
description:
|
||||
"The supplied width/height values are not valid for a video frame.",
|
||||
},
|
||||
[VIDEO_CODES.INVALID_TRIM_RANGE]: {
|
||||
httpStatus: 422,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.invalidTrimRange",
|
||||
category: "video.validation",
|
||||
description:
|
||||
"The supplied trim range is empty, inverted, or out of bounds.",
|
||||
},
|
||||
[VIDEO_CODES.METADATA_PARSE_FAILED]: {
|
||||
httpStatus: 422,
|
||||
severity: "error",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.metadataParseFailed",
|
||||
category: "video.processing",
|
||||
description: "Failed to parse video metadata from the supplied buffer.",
|
||||
},
|
||||
[VIDEO_CODES.INVALID_TRANSFORM]: {
|
||||
httpStatus: 422,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.invalidTransform",
|
||||
category: "video.validation",
|
||||
description:
|
||||
"The supplied transform operation is malformed or unsupported.",
|
||||
},
|
||||
[VIDEO_CODES.DURATION_EXCEEDED]: {
|
||||
httpStatus: 413,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.durationExceeded",
|
||||
category: "video.validation",
|
||||
description: "The video duration exceeds the configured maximum.",
|
||||
},
|
||||
[VIDEO_CODES.UNSUPPORTED_CODEC]: {
|
||||
httpStatus: 415,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.unsupportedCodec",
|
||||
category: "video.validation",
|
||||
description: "The codec named in the pipeline is not supported.",
|
||||
},
|
||||
[VIDEO_CODES.NOT_FOUND]: {
|
||||
httpStatus: 404,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.notFound",
|
||||
category: "video.storage",
|
||||
description: "The requested video could not be found.",
|
||||
},
|
||||
[VIDEO_CODES.DECODE_FAILED]: {
|
||||
httpStatus: 422,
|
||||
severity: "error",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.decodeFailed",
|
||||
category: "video.processing",
|
||||
description:
|
||||
"The video could not be decoded — the file may be corrupt or use an unreadable encoding.",
|
||||
},
|
||||
[VIDEO_CODES.ENCODE_FAILED]: {
|
||||
httpStatus: 500,
|
||||
severity: "error",
|
||||
retryable: true,
|
||||
retry: { strategy: "exponential", maxAttempts: 3 },
|
||||
userMessageKey: "video.errors.encodeFailed",
|
||||
category: "video.processing",
|
||||
description: "The video encode step failed due to an internal error.",
|
||||
},
|
||||
[VIDEO_CODES.FORMAT_UNSUPPORTED]: {
|
||||
httpStatus: 415,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.formatUnsupported",
|
||||
category: "video.validation",
|
||||
description: "The supplied video container format is not supported.",
|
||||
},
|
||||
[VIDEO_CODES.TOO_LARGE]: {
|
||||
httpStatus: 413,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.tooLarge",
|
||||
category: "video.validation",
|
||||
description:
|
||||
"The video file or stream exceeds the permitted size limit.",
|
||||
},
|
||||
[VIDEO_CODES.PROCESSING_FAILED]: {
|
||||
httpStatus: 422,
|
||||
severity: "error",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.processingFailed",
|
||||
category: "video.processing",
|
||||
description:
|
||||
"A general processing error occurred while handling the video.",
|
||||
},
|
||||
[VIDEO_CODES.CODEC_UNSUPPORTED]: {
|
||||
httpStatus: 415,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.codecUnsupported",
|
||||
category: "video.validation",
|
||||
description: "The video codec is not supported by this processor.",
|
||||
},
|
||||
[VIDEO_CODES.TIMEOUT]: {
|
||||
httpStatus: 504,
|
||||
severity: "error",
|
||||
retryable: true,
|
||||
retry: { strategy: "exponential", maxAttempts: 3 },
|
||||
userMessageKey: "video.errors.timeout",
|
||||
category: "video.processing",
|
||||
description:
|
||||
"The video processing operation timed out; the operation may succeed on retry.",
|
||||
},
|
||||
[VIDEO_CODES.CONFIG_INVALID]: {
|
||||
httpStatus: 500,
|
||||
severity: "error",
|
||||
retryable: false,
|
||||
userMessageKey: "video.errors.configInvalid",
|
||||
category: "video.configuration",
|
||||
description:
|
||||
"The VideoProcessor was constructed with an invalid configuration.",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the {@link VIDEO_ERROR_METADATA} table.
|
||||
*
|
||||
* Kept as a callable alias so upstream consumers that previously invoked
|
||||
* `registerVideoErrorCodes()` keep compiling. Genesis `defineCodes` is the
|
||||
* authoritative registry — no side-effect registration is needed.
|
||||
*/
|
||||
export function registerVideoErrorCodes(): typeof VIDEO_ERROR_METADATA {
|
||||
return VIDEO_ERROR_METADATA;
|
||||
}
|
||||
9
packages/media/video/video/src/error-codes/index.ts
Normal file
9
packages/media/video/video/src/error-codes/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
registerVideoErrorCodes,
|
||||
VIDEO_CODES,
|
||||
VIDEO_ERROR_METADATA,
|
||||
type VideoCode,
|
||||
type VideoCodeMetadata,
|
||||
type VideoCodeRetryPolicy,
|
||||
type VideoCodeSeverity,
|
||||
} from "./error-codes.js";
|
||||
109
packages/media/video/video/src/errors/errors.md
Normal file
109
packages/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.
|
||||
144
packages/media/video/video/src/errors/errors.test.ts
Normal file
144
packages/media/video/video/src/errors/errors.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { VIDEO_CODES } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
durationExceeded,
|
||||
invalidTrimRange,
|
||||
invalidVideoDimensions,
|
||||
invalidVideoTransform,
|
||||
unknownVideoFormat,
|
||||
unsupportedCodec,
|
||||
VideoError,
|
||||
videoCodecUnsupported,
|
||||
videoConfigInvalid,
|
||||
videoDecodeFailed,
|
||||
videoEncodeFailed,
|
||||
videoFormatUnsupported,
|
||||
videoMetadataParseFailed,
|
||||
videoNotFound,
|
||||
videoProcessingFailed,
|
||||
videoTimeout,
|
||||
videoTooLarge,
|
||||
} from "./errors.js";
|
||||
|
||||
describe("VideoError", () => {
|
||||
it("VideoError carries code, message, cause, and context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("root");
|
||||
const err = new VideoError(VIDEO_CODES.METADATA_PARSE_FAILED, "wrap", {
|
||||
context: { duration: 9 },
|
||||
cause,
|
||||
});
|
||||
expect(err.code).toBe(VIDEO_CODES.METADATA_PARSE_FAILED);
|
||||
expect(err.message).toBe("wrap");
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context?.duration).toBe(9);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("VideoError omits context when none supplied", { timeout: 5000 }, () => {
|
||||
const err = new VideoError(VIDEO_CODES.NOT_FOUND, "nope");
|
||||
expect(err.context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error factories", () => {
|
||||
it("every upstream factory is exported with matching shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cases: Array<[() => VideoError, string]> = [
|
||||
[() => unknownVideoFormat(), VIDEO_CODES.UNKNOWN_FORMAT],
|
||||
[
|
||||
() => invalidVideoDimensions(-1, 0),
|
||||
VIDEO_CODES.INVALID_DIMENSIONS,
|
||||
],
|
||||
[() => invalidTrimRange(10, 5), VIDEO_CODES.INVALID_TRIM_RANGE],
|
||||
[
|
||||
() => videoMetadataParseFailed(new Error("x")),
|
||||
VIDEO_CODES.METADATA_PARSE_FAILED,
|
||||
],
|
||||
[
|
||||
() => videoMetadataParseFailed(),
|
||||
VIDEO_CODES.METADATA_PARSE_FAILED,
|
||||
],
|
||||
[() => invalidVideoTransform("bad"), VIDEO_CODES.INVALID_TRANSFORM],
|
||||
[() => durationExceeded(100, 60), VIDEO_CODES.DURATION_EXCEEDED],
|
||||
[() => unsupportedCodec("theora"), VIDEO_CODES.UNSUPPORTED_CODEC],
|
||||
[() => videoNotFound(), VIDEO_CODES.NOT_FOUND],
|
||||
[() => videoNotFound("mp4"), VIDEO_CODES.NOT_FOUND],
|
||||
[
|
||||
() => videoDecodeFailed(new Error("x")),
|
||||
VIDEO_CODES.DECODE_FAILED,
|
||||
],
|
||||
[() => videoDecodeFailed(), VIDEO_CODES.DECODE_FAILED],
|
||||
[
|
||||
() => videoEncodeFailed(new Error("x")),
|
||||
VIDEO_CODES.ENCODE_FAILED,
|
||||
],
|
||||
[() => videoEncodeFailed(), VIDEO_CODES.ENCODE_FAILED],
|
||||
[
|
||||
() => videoFormatUnsupported("xyz"),
|
||||
VIDEO_CODES.FORMAT_UNSUPPORTED,
|
||||
],
|
||||
[() => videoTooLarge(2_000, 1_000), VIDEO_CODES.TOO_LARGE],
|
||||
[
|
||||
() => videoProcessingFailed("step", new Error("x")),
|
||||
VIDEO_CODES.PROCESSING_FAILED,
|
||||
],
|
||||
[
|
||||
() => videoProcessingFailed("step"),
|
||||
VIDEO_CODES.PROCESSING_FAILED,
|
||||
],
|
||||
[
|
||||
() => videoCodecUnsupported("theora"),
|
||||
VIDEO_CODES.CODEC_UNSUPPORTED,
|
||||
],
|
||||
[() => videoTimeout(5000), VIDEO_CODES.TIMEOUT],
|
||||
[
|
||||
() => videoConfigInvalid("bad config"),
|
||||
VIDEO_CODES.CONFIG_INVALID,
|
||||
],
|
||||
];
|
||||
|
||||
for (const [factory, code] of cases) {
|
||||
const err = factory();
|
||||
expect(err).toBeInstanceOf(VideoError);
|
||||
expect(err.code).toBe(code);
|
||||
expect(err.message.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("contextful factories populate the typed context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(invalidVideoDimensions(-1, 0).context).toEqual({
|
||||
width: -1,
|
||||
height: 0,
|
||||
});
|
||||
expect(durationExceeded(100, 60).context).toEqual({ duration: 100 });
|
||||
expect(unsupportedCodec("theora").context).toEqual({
|
||||
codec: "theora",
|
||||
});
|
||||
expect(videoFormatUnsupported("xyz").context).toEqual({
|
||||
format: "xyz",
|
||||
});
|
||||
expect(videoTooLarge(2_000, 1_000).context).toEqual({
|
||||
fileSize: 2_000,
|
||||
maxSize: 1_000,
|
||||
});
|
||||
expect(videoCodecUnsupported("theora").context).toEqual({
|
||||
codec: "theora",
|
||||
});
|
||||
expect(videoTimeout(5000).context).toEqual({ operationMs: 5000 });
|
||||
expect(videoNotFound("mp4").context).toEqual({ format: "mp4" });
|
||||
expect(videoNotFound().context).toEqual({});
|
||||
});
|
||||
|
||||
it("factories with cause attach it", { timeout: 5000 }, () => {
|
||||
const cause = new Error("inner");
|
||||
expect(videoMetadataParseFailed(cause).cause).toBe(cause);
|
||||
expect(videoDecodeFailed(cause).cause).toBe(cause);
|
||||
expect(videoEncodeFailed(cause).cause).toBe(cause);
|
||||
expect(videoProcessingFailed("x", cause).cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
175
packages/media/video/video/src/errors/errors.ts
Normal file
175
packages/media/video/video/src/errors/errors.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { VIDEO_CODES, type VideoCode } from "../error-codes/error-codes.js";
|
||||
|
||||
export interface VideoErrorContext extends Record<string, unknown> {
|
||||
format?: string;
|
||||
codec?: string;
|
||||
duration?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fileSize?: number;
|
||||
maxSize?: number;
|
||||
operationMs?: number;
|
||||
}
|
||||
|
||||
export interface VideoErrorOptions extends SuaveplanErrorOptions {
|
||||
context?: VideoErrorContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed error for every failure thrown by `@suaveplan/video`.
|
||||
*
|
||||
* Carries a stable `code` drawn from {@link VIDEO_CODES} plus an optional
|
||||
* `context` field (inherited from {@link SuaveplanError}) with typed hints
|
||||
* (format, codec, dimensions, etc.).
|
||||
*/
|
||||
export class VideoError extends SuaveplanError<VideoCode> {
|
||||
public constructor(
|
||||
code: VideoCode,
|
||||
message: string,
|
||||
options: VideoErrorOptions = {}
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "VideoError";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upstream factories — preserved 1:1.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function unknownVideoFormat(): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.UNKNOWN_FORMAT,
|
||||
"Unable to determine video format"
|
||||
);
|
||||
}
|
||||
|
||||
export function invalidVideoDimensions(
|
||||
width: number,
|
||||
height: number
|
||||
): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.INVALID_DIMENSIONS,
|
||||
`Invalid dimensions: ${width}x${height}`,
|
||||
{ context: { width, height } }
|
||||
);
|
||||
}
|
||||
|
||||
export function invalidTrimRange(
|
||||
startTime: number,
|
||||
endTime: number
|
||||
): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.INVALID_TRIM_RANGE,
|
||||
`Invalid trim range: ${startTime}s to ${endTime}s`
|
||||
);
|
||||
}
|
||||
|
||||
export function videoMetadataParseFailed(cause?: Error): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.METADATA_PARSE_FAILED,
|
||||
"Failed to parse video metadata",
|
||||
{ context: {}, cause }
|
||||
);
|
||||
}
|
||||
|
||||
export function invalidVideoTransform(message: string): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.INVALID_TRANSFORM,
|
||||
`Invalid transform: ${message}`
|
||||
);
|
||||
}
|
||||
|
||||
export function durationExceeded(
|
||||
duration: number,
|
||||
maxDuration: number
|
||||
): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.DURATION_EXCEEDED,
|
||||
`Duration ${duration}s exceeds maximum of ${maxDuration}s`,
|
||||
{ context: { duration } }
|
||||
);
|
||||
}
|
||||
|
||||
export function unsupportedCodec(codec: string): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.UNSUPPORTED_CODEC,
|
||||
`Unsupported codec: ${codec}`,
|
||||
{ context: { codec } }
|
||||
);
|
||||
}
|
||||
|
||||
export function videoNotFound(format?: string): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.NOT_FOUND,
|
||||
"The requested video could not be found",
|
||||
{ context: format ? { format } : {} }
|
||||
);
|
||||
}
|
||||
|
||||
export function videoDecodeFailed(cause?: Error): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.DECODE_FAILED,
|
||||
"Failed to decode video — file may be corrupt or unreadable",
|
||||
{ context: {}, cause }
|
||||
);
|
||||
}
|
||||
|
||||
export function videoEncodeFailed(cause?: Error): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.ENCODE_FAILED,
|
||||
"Video encode step failed due to an internal error",
|
||||
{ context: {}, cause }
|
||||
);
|
||||
}
|
||||
|
||||
export function videoFormatUnsupported(format: string): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.FORMAT_UNSUPPORTED,
|
||||
`Video format not supported: ${format}`,
|
||||
{ context: { format } }
|
||||
);
|
||||
}
|
||||
|
||||
export function videoTooLarge(fileSize: number, maxSize: number): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.TOO_LARGE,
|
||||
`Video size ${fileSize} bytes exceeds maximum of ${maxSize} bytes`,
|
||||
{ context: { fileSize, maxSize } }
|
||||
);
|
||||
}
|
||||
|
||||
export function videoProcessingFailed(
|
||||
message: string,
|
||||
cause?: Error
|
||||
): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.PROCESSING_FAILED,
|
||||
`Video processing failed: ${message}`,
|
||||
{ context: {}, cause }
|
||||
);
|
||||
}
|
||||
|
||||
export function videoCodecUnsupported(codec: string): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.CODEC_UNSUPPORTED,
|
||||
`Video codec not supported: ${codec}`,
|
||||
{ context: { codec } }
|
||||
);
|
||||
}
|
||||
|
||||
export function videoTimeout(operationMs: number): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.TIMEOUT,
|
||||
`Video processing timed out after ${operationMs}ms`,
|
||||
{ context: { operationMs } }
|
||||
);
|
||||
}
|
||||
|
||||
export function videoConfigInvalid(message: string): VideoError {
|
||||
return new VideoError(
|
||||
VIDEO_CODES.CONFIG_INVALID,
|
||||
`Invalid VideoProcessor config: ${message}`
|
||||
);
|
||||
}
|
||||
21
packages/media/video/video/src/errors/index.ts
Normal file
21
packages/media/video/video/src/errors/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
durationExceeded,
|
||||
invalidTrimRange,
|
||||
invalidVideoDimensions,
|
||||
invalidVideoTransform,
|
||||
unknownVideoFormat,
|
||||
unsupportedCodec,
|
||||
VideoError,
|
||||
type VideoErrorContext,
|
||||
type VideoErrorOptions,
|
||||
videoCodecUnsupported,
|
||||
videoConfigInvalid,
|
||||
videoDecodeFailed,
|
||||
videoEncodeFailed,
|
||||
videoFormatUnsupported,
|
||||
videoMetadataParseFailed,
|
||||
videoNotFound,
|
||||
videoProcessingFailed,
|
||||
videoTimeout,
|
||||
videoTooLarge,
|
||||
} from "./errors.js";
|
||||
64
packages/media/video/video/src/format/format.md
Normal file
64
packages/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.
|
||||
31
packages/media/video/video/src/format/format.test.ts
Normal file
31
packages/media/video/video/src/format/format.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { VideoFormat } from "../types/video-types/video-types.js";
|
||||
import { videoFormatToExtension } from "./format.js";
|
||||
|
||||
describe("videoFormatToExtension", () => {
|
||||
it("mp4 -> .mp4", { timeout: 5000 }, () => {
|
||||
expect(videoFormatToExtension("mp4")).toBe(".mp4");
|
||||
});
|
||||
|
||||
it("webm -> .webm", { timeout: 5000 }, () => {
|
||||
expect(videoFormatToExtension("webm")).toBe(".webm");
|
||||
});
|
||||
|
||||
it("covers every VideoFormat", { timeout: 5000 }, () => {
|
||||
const formats: VideoFormat[] = [
|
||||
"mp4",
|
||||
"webm",
|
||||
"avi",
|
||||
"mkv",
|
||||
"mov",
|
||||
"flv",
|
||||
"wmv",
|
||||
"ogg",
|
||||
];
|
||||
for (const f of formats) {
|
||||
const ext = videoFormatToExtension(f);
|
||||
expect(ext.startsWith(".")).toBe(true);
|
||||
expect(ext.length).toBeGreaterThan(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
20
packages/media/video/video/src/format/format.ts
Normal file
20
packages/media/video/video/src/format/format.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { VideoFormat } from "../types/video-types/video-types.js";
|
||||
|
||||
const EXTENSION_MAP: Readonly<Record<VideoFormat, string>> = {
|
||||
mp4: ".mp4",
|
||||
webm: ".webm",
|
||||
avi: ".avi",
|
||||
mkv: ".mkv",
|
||||
mov: ".mov",
|
||||
flv: ".flv",
|
||||
wmv: ".wmv",
|
||||
ogg: ".ogg",
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the canonical file-extension (including leading dot) for a
|
||||
* supported {@link VideoFormat}.
|
||||
*/
|
||||
export function videoFormatToExtension(format: VideoFormat): string {
|
||||
return EXTENSION_MAP[format];
|
||||
}
|
||||
1
packages/media/video/video/src/format/index.ts
Normal file
1
packages/media/video/video/src/format/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { videoFormatToExtension } from "./format.js";
|
||||
74
packages/media/video/video/src/index.ts
Normal file
74
packages/media/video/video/src/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export { detectVideoFormat } from "./detection/index.js";
|
||||
export { formatDuration, parseDuration } from "./duration/index.js";
|
||||
export {
|
||||
registerVideoErrorCodes,
|
||||
VIDEO_CODES,
|
||||
VIDEO_ERROR_METADATA,
|
||||
type VideoCode,
|
||||
type VideoCodeMetadata,
|
||||
type VideoCodeRetryPolicy,
|
||||
type VideoCodeSeverity,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
durationExceeded,
|
||||
invalidTrimRange,
|
||||
invalidVideoDimensions,
|
||||
invalidVideoTransform,
|
||||
unknownVideoFormat,
|
||||
unsupportedCodec,
|
||||
VideoError,
|
||||
type VideoErrorContext,
|
||||
type VideoErrorOptions,
|
||||
videoCodecUnsupported,
|
||||
videoConfigInvalid,
|
||||
videoDecodeFailed,
|
||||
videoEncodeFailed,
|
||||
videoFormatUnsupported,
|
||||
videoMetadataParseFailed,
|
||||
videoNotFound,
|
||||
videoProcessingFailed,
|
||||
videoTimeout,
|
||||
videoTooLarge,
|
||||
} from "./errors/index.js";
|
||||
export { videoFormatToExtension } from "./format/index.js";
|
||||
export {
|
||||
getExtractCounter,
|
||||
getExtractDuration,
|
||||
getPlanCounter,
|
||||
getPlanDuration,
|
||||
getTracer,
|
||||
resetInstruments,
|
||||
} from "./instruments/index.js";
|
||||
export { VideoPipelineBuilder } from "./pipeline-builder/index.js";
|
||||
export { VideoProcessor } from "./processor/index.js";
|
||||
export { calculateVideoResize } from "./resize/index.js";
|
||||
export { validateTrimRange } from "./trim/index.js";
|
||||
export {
|
||||
type AudioCodec,
|
||||
AudioCodecSchema,
|
||||
RESOLUTION_PRESETS,
|
||||
type ResolutionPreset,
|
||||
ResolutionPresetSchema,
|
||||
type TrimRange,
|
||||
TrimRangeSchema,
|
||||
VIDEO_MIME_TYPES,
|
||||
type VideoCodec,
|
||||
VideoCodecSchema,
|
||||
type VideoDimensions,
|
||||
VideoDimensionsSchema,
|
||||
type VideoEventMap,
|
||||
type VideoFormat,
|
||||
VideoFormatSchema,
|
||||
type VideoMetadata,
|
||||
VideoMetadataSchema,
|
||||
type VideoProcessorConfig,
|
||||
VideoProcessorConfigSchema,
|
||||
type VideoResizeOptions,
|
||||
VideoResizeOptionsSchema,
|
||||
type VideoTransformOp,
|
||||
VideoTransformOpSchema,
|
||||
type VideoTransformPipeline,
|
||||
VideoTransformPipelineSchema,
|
||||
type VideoTransformResult,
|
||||
VideoTransformResultSchema,
|
||||
} from "./types/video-types/index.js";
|
||||
8
packages/media/video/video/src/instruments/index.ts
Normal file
8
packages/media/video/video/src/instruments/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
getExtractCounter,
|
||||
getExtractDuration,
|
||||
getPlanCounter,
|
||||
getPlanDuration,
|
||||
getTracer,
|
||||
resetInstruments,
|
||||
} from "./instruments.js";
|
||||
83
packages/media/video/video/src/instruments/instruments.md
Normal file
83
packages/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.
|
||||
79
packages/media/video/video/src/instruments/instruments.ts
Normal file
79
packages/media/video/video/src/instruments/instruments.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Counter, Histogram, Tracer } from "@opentelemetry/api";
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/video";
|
||||
const TRACER_NAME = "@suaveplan/video";
|
||||
|
||||
const METRIC_EXTRACT_TOTAL = "video.metadata.extracted_total";
|
||||
const METRIC_EXTRACT_DURATION_MS = "video.metadata.extract_duration_ms";
|
||||
const METRIC_PLAN_TOTAL = "video.transform.planned_total";
|
||||
const METRIC_PLAN_DURATION_MS = "video.transform.plan_duration_ms";
|
||||
|
||||
let extractCounter: Counter | undefined;
|
||||
let extractDuration: Histogram | undefined;
|
||||
let planCounter: Counter | undefined;
|
||||
let planDuration: Histogram | undefined;
|
||||
|
||||
export function getExtractCounter(): Counter {
|
||||
if (!extractCounter) {
|
||||
extractCounter = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createCounter(METRIC_EXTRACT_TOTAL, {
|
||||
description: "Total VideoProcessor.extractMetadata invocations",
|
||||
});
|
||||
}
|
||||
return extractCounter;
|
||||
}
|
||||
|
||||
export function getExtractDuration(): Histogram {
|
||||
if (!extractDuration) {
|
||||
extractDuration = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createHistogram(METRIC_EXTRACT_DURATION_MS, {
|
||||
description: "Duration of VideoProcessor.extractMetadata in ms",
|
||||
unit: "ms",
|
||||
});
|
||||
}
|
||||
return extractDuration;
|
||||
}
|
||||
|
||||
export function getPlanCounter(): Counter {
|
||||
if (!planCounter) {
|
||||
planCounter = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createCounter(METRIC_PLAN_TOTAL, {
|
||||
description: "Total VideoProcessor.planTransform invocations",
|
||||
});
|
||||
}
|
||||
return planCounter;
|
||||
}
|
||||
|
||||
export function getPlanDuration(): Histogram {
|
||||
if (!planDuration) {
|
||||
planDuration = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createHistogram(METRIC_PLAN_DURATION_MS, {
|
||||
description: "Duration of VideoProcessor.planTransform in ms",
|
||||
unit: "ms",
|
||||
});
|
||||
}
|
||||
return planDuration;
|
||||
}
|
||||
|
||||
export function getTracer(): Tracer {
|
||||
return trace.getTracer(TRACER_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cached instrument handles.
|
||||
*
|
||||
* Tests that swap the global `MeterProvider` / `TracerProvider` MUST call
|
||||
* this between `installTestProviders()` and the unit under test so the
|
||||
* next `getExtractCounter()` etc. resolves against the new provider.
|
||||
*/
|
||||
export function resetInstruments(): void {
|
||||
extractCounter = undefined;
|
||||
extractDuration = undefined;
|
||||
planCounter = undefined;
|
||||
planDuration = undefined;
|
||||
}
|
||||
1
packages/media/video/video/src/pipeline-builder/index.ts
Normal file
1
packages/media/video/video/src/pipeline-builder/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { VideoPipelineBuilder } from "./pipeline-builder.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { VideoProcessor } from "../processor/processor.js";
|
||||
import type {
|
||||
VideoMetadata,
|
||||
VideoTransformPipeline,
|
||||
} from "../types/video-types/video-types.js";
|
||||
import { VideoPipelineBuilder } from "./pipeline-builder.js";
|
||||
|
||||
const baseMeta: VideoMetadata = {
|
||||
format: "mp4",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: 120,
|
||||
frameRate: 30,
|
||||
hasAudio: true,
|
||||
};
|
||||
|
||||
describe("VideoPipelineBuilder fluent methods", () => {
|
||||
it("chains every method", { timeout: 5000 }, () => {
|
||||
const b = new VideoPipelineBuilder();
|
||||
expect(b.resize({ preset: "720p" })).toBe(b);
|
||||
expect(b.trim({ startTime: 0, endTime: 10 })).toBe(b);
|
||||
expect(b.rotate(90)).toBe(b);
|
||||
expect(b.flip("horizontal")).toBe(b);
|
||||
expect(b.speed(2)).toBe(b);
|
||||
expect(b.mute()).toBe(b);
|
||||
expect(b.toFormat("webm")).toBe(b);
|
||||
expect(b.codec("h265")).toBe(b);
|
||||
expect(b.bitrate(5_000_000)).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoPipelineBuilder.build", () => {
|
||||
it("build returns pipeline", { timeout: 5000 }, () => {
|
||||
const p = new VideoPipelineBuilder()
|
||||
.resize({ preset: "720p" })
|
||||
.trim({ startTime: 0, endTime: 30 })
|
||||
.toFormat("webm")
|
||||
.build();
|
||||
expect(p.operations).toHaveLength(3);
|
||||
expect(p.outputFormat).toBe("webm");
|
||||
});
|
||||
|
||||
it("build with no ops returns empty pipeline", { timeout: 5000 }, () => {
|
||||
const p = new VideoPipelineBuilder().build();
|
||||
expect(p.operations).toHaveLength(0);
|
||||
expect(p.outputFormat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("successive builds detach from later mutations", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = new VideoPipelineBuilder().resize({ preset: "720p" });
|
||||
const first = b.build();
|
||||
b.trim({ startTime: 0, endTime: 5 });
|
||||
expect(first.operations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoPipelineBuilder bound to processor", () => {
|
||||
it("emits pipeline.built when bound to a processor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const proc = new VideoProcessor();
|
||||
let received: VideoTransformPipeline | undefined;
|
||||
proc.on("pipeline.built", (p) => {
|
||||
received = p;
|
||||
});
|
||||
proc.createPipeline().resize({ preset: "720p" }).build();
|
||||
expect(received?.operations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("apply runs the pipeline", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const r = proc
|
||||
.createPipeline()
|
||||
.resize({ preset: "720p" })
|
||||
.apply(baseMeta, proc);
|
||||
expect(r.width).toBe(1280);
|
||||
expect(r.height).toBe(720);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoPipelineBuilder unbound", () => {
|
||||
it("does not emit when no bus is attached", { timeout: 5000 }, () => {
|
||||
// The default constructor takes no bus — building must not throw.
|
||||
const p = new VideoPipelineBuilder().toFormat("ogg").build();
|
||||
expect(p.outputFormat).toBe("ogg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { EventBus } from "@suaveplan/events";
|
||||
import type { VideoProcessor } from "../processor/processor.js";
|
||||
import type {
|
||||
TrimRange,
|
||||
VideoCodec,
|
||||
VideoEventMap,
|
||||
VideoFormat,
|
||||
VideoMetadata,
|
||||
VideoResizeOptions,
|
||||
VideoTransformOp,
|
||||
VideoTransformPipeline,
|
||||
VideoTransformResult,
|
||||
} from "../types/video-types/video-types.js";
|
||||
|
||||
/**
|
||||
* Fluent builder for `VideoTransformPipeline`.
|
||||
*
|
||||
* The builder is normally created via `VideoProcessor.createPipeline()` so
|
||||
* `build()` can fire the `pipeline.built` event on the processor's bus.
|
||||
* It can also be instantiated directly when no event emission is needed.
|
||||
*/
|
||||
export class VideoPipelineBuilder {
|
||||
private readonly ops: VideoTransformOp[] = [];
|
||||
private outFormat?: VideoFormat;
|
||||
private readonly bus?: EventBus<VideoEventMap>;
|
||||
|
||||
public constructor(bus?: EventBus<VideoEventMap>) {
|
||||
this.bus = bus;
|
||||
}
|
||||
|
||||
public resize(options: VideoResizeOptions): this {
|
||||
this.ops.push({ type: "resize", options });
|
||||
return this;
|
||||
}
|
||||
|
||||
public trim(range: TrimRange): this {
|
||||
this.ops.push({ type: "trim", range });
|
||||
return this;
|
||||
}
|
||||
|
||||
public rotate(degrees: number): this {
|
||||
this.ops.push({ type: "rotate", degrees });
|
||||
return this;
|
||||
}
|
||||
|
||||
public flip(direction: "horizontal" | "vertical"): this {
|
||||
this.ops.push({ type: "flip", direction });
|
||||
return this;
|
||||
}
|
||||
|
||||
public speed(factor: number): this {
|
||||
this.ops.push({ type: "speed", factor });
|
||||
return this;
|
||||
}
|
||||
|
||||
public mute(): this {
|
||||
this.ops.push({ type: "mute" });
|
||||
return this;
|
||||
}
|
||||
|
||||
public toFormat(format: VideoFormat): this {
|
||||
this.outFormat = format;
|
||||
this.ops.push({ type: "format", format });
|
||||
return this;
|
||||
}
|
||||
|
||||
public codec(codec: VideoCodec): this {
|
||||
this.ops.push({ type: "codec", codec });
|
||||
return this;
|
||||
}
|
||||
|
||||
public bitrate(value: number): this {
|
||||
this.ops.push({ type: "bitrate", value });
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): VideoTransformPipeline {
|
||||
const pipeline: VideoTransformPipeline = {
|
||||
operations: [...this.ops],
|
||||
outputFormat: this.outFormat,
|
||||
};
|
||||
if (this.bus) {
|
||||
this.bus.emit("pipeline.built", pipeline);
|
||||
}
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
public apply(
|
||||
metadata: VideoMetadata,
|
||||
processor: VideoProcessor
|
||||
): VideoTransformResult {
|
||||
const pipeline = this.build();
|
||||
return processor.planTransform(metadata, pipeline);
|
||||
}
|
||||
}
|
||||
1
packages/media/video/video/src/processor/index.ts
Normal file
1
packages/media/video/video/src/processor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { VideoProcessor } from "./processor.js";
|
||||
117
packages/media/video/video/src/processor/processor.md
Normal file
117
packages/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.
|
||||
@@ -0,0 +1,263 @@
|
||||
import type {
|
||||
Counter,
|
||||
Histogram,
|
||||
MeterProvider,
|
||||
Span,
|
||||
Tracer,
|
||||
TracerProvider,
|
||||
} from "@opentelemetry/api";
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { resetInstruments } from "../instruments/instruments.js";
|
||||
import type { VideoMetadata } from "../types/video-types/video-types.js";
|
||||
import { VideoProcessor } from "./processor.js";
|
||||
|
||||
interface MetricObs {
|
||||
readonly name: string;
|
||||
readonly value: number;
|
||||
readonly attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SpanObs {
|
||||
readonly name: string;
|
||||
ended: boolean;
|
||||
}
|
||||
|
||||
const counters: MetricObs[] = [];
|
||||
const histograms: MetricObs[] = [];
|
||||
const spans: SpanObs[] = [];
|
||||
|
||||
function installTestProviders(): void {
|
||||
counters.length = 0;
|
||||
histograms.length = 0;
|
||||
spans.length = 0;
|
||||
|
||||
const meterProvider = {
|
||||
getMeter: () => ({
|
||||
createCounter: (name: string) =>
|
||||
({
|
||||
add: (value: number, attrs?: Record<string, unknown>) => {
|
||||
counters.push({
|
||||
name,
|
||||
value,
|
||||
attributes: attrs ?? {},
|
||||
});
|
||||
},
|
||||
}) as unknown as Counter,
|
||||
createHistogram: (name: string) =>
|
||||
({
|
||||
record: (
|
||||
value: number,
|
||||
attrs?: Record<string, unknown>
|
||||
) => {
|
||||
histograms.push({
|
||||
name,
|
||||
value,
|
||||
attributes: attrs ?? {},
|
||||
});
|
||||
},
|
||||
}) as unknown as Histogram,
|
||||
}),
|
||||
} as unknown as MeterProvider;
|
||||
|
||||
const tracerProvider = {
|
||||
getTracer: () =>
|
||||
({
|
||||
startSpan: (name: string) => {
|
||||
const span: SpanObs = { name, ended: false };
|
||||
const apiSpan = {
|
||||
end: () => {
|
||||
span.ended = true;
|
||||
},
|
||||
setAttribute: () => apiSpan,
|
||||
setAttributes: () => apiSpan,
|
||||
addEvent: () => apiSpan,
|
||||
setStatus: () => apiSpan,
|
||||
updateName: () => apiSpan,
|
||||
recordException: () => apiSpan,
|
||||
isRecording: () => !span.ended,
|
||||
spanContext: () => ({
|
||||
traceId: "00000000000000000000000000000000",
|
||||
spanId: "0000000000000000",
|
||||
traceFlags: 0,
|
||||
}),
|
||||
} as unknown as Span;
|
||||
spans.push(span);
|
||||
return apiSpan;
|
||||
},
|
||||
startActiveSpan: () => {
|
||||
throw new Error("unused in tests");
|
||||
},
|
||||
}) as unknown as Tracer,
|
||||
} as unknown as TracerProvider;
|
||||
|
||||
metrics.setGlobalMeterProvider(meterProvider);
|
||||
trace.setGlobalTracerProvider(tracerProvider);
|
||||
}
|
||||
|
||||
function makeMp4(): Uint8Array {
|
||||
const buf = new Uint8Array(16);
|
||||
buf.set([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70]);
|
||||
buf.set([0x69, 0x73, 0x6f, 0x6d], 8);
|
||||
return buf;
|
||||
}
|
||||
|
||||
const meta: VideoMetadata = {
|
||||
format: "mp4",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: 60,
|
||||
frameRate: 30,
|
||||
hasAudio: true,
|
||||
};
|
||||
|
||||
describe("VideoProcessor spec conformance", () => {
|
||||
beforeEach(() => {
|
||||
installTestProviders();
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
describe("OTel instrumentation", () => {
|
||||
it("increments extracted counter on success", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
proc.extractMetadata(makeMp4(), {
|
||||
width: 1,
|
||||
height: 1,
|
||||
duration: 1,
|
||||
frameRate: 1,
|
||||
});
|
||||
const success = counters.find(
|
||||
(c) =>
|
||||
c.name === "video.metadata.extracted_total" &&
|
||||
c.attributes.outcome === "success"
|
||||
);
|
||||
expect(success).toBeDefined();
|
||||
expect(success?.value).toBe(1);
|
||||
});
|
||||
|
||||
it("records failure outcome on unknown_format", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const proc = new VideoProcessor();
|
||||
try {
|
||||
proc.extractMetadata(new Uint8Array(16));
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
const failure = counters.find(
|
||||
(c) =>
|
||||
c.name === "video.metadata.extracted_total" &&
|
||||
c.attributes.outcome === "unknown_format"
|
||||
);
|
||||
expect(failure).toBeDefined();
|
||||
});
|
||||
|
||||
it("records duration_exceeded outcome", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor({ maxDuration: 1 });
|
||||
try {
|
||||
proc.extractMetadata(makeMp4(), {
|
||||
width: 1,
|
||||
height: 1,
|
||||
duration: 60,
|
||||
frameRate: 1,
|
||||
});
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
const obs = counters.find(
|
||||
(c) => c.attributes.outcome === "duration_exceeded"
|
||||
);
|
||||
expect(obs).toBeDefined();
|
||||
});
|
||||
|
||||
it("records extract histogram", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
proc.extractMetadata(makeMp4(), {
|
||||
width: 1,
|
||||
height: 1,
|
||||
duration: 1,
|
||||
frameRate: 1,
|
||||
});
|
||||
const obs = histograms.find(
|
||||
(h) => h.name === "video.metadata.extract_duration_ms"
|
||||
);
|
||||
expect(obs).toBeDefined();
|
||||
expect(obs?.value).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("increments planned counter on success", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
proc.planTransform(meta, { operations: [] });
|
||||
const obs = counters.find(
|
||||
(c) =>
|
||||
c.name === "video.transform.planned_total" &&
|
||||
c.attributes.outcome === "success"
|
||||
);
|
||||
expect(obs).toBeDefined();
|
||||
});
|
||||
|
||||
it("records invalid_pipeline outcome on bad pipeline", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const proc = new VideoProcessor();
|
||||
try {
|
||||
proc.planTransform(meta, {
|
||||
operations: [{ type: "explode" } as unknown as never],
|
||||
});
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
const obs = counters.find(
|
||||
(c) =>
|
||||
c.name === "video.transform.planned_total" &&
|
||||
c.attributes.outcome === "invalid_pipeline"
|
||||
);
|
||||
expect(obs).toBeDefined();
|
||||
});
|
||||
|
||||
it("records error outcome when an operator throws", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const proc = new VideoProcessor();
|
||||
try {
|
||||
proc.planTransform(meta, {
|
||||
operations: [{ type: "rotate", degrees: 45 }],
|
||||
});
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
const obs = counters.find(
|
||||
(c) =>
|
||||
c.name === "video.transform.planned_total" &&
|
||||
c.attributes.outcome === "error"
|
||||
);
|
||||
expect(obs).toBeDefined();
|
||||
});
|
||||
|
||||
it("ends every started span", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
proc.extractMetadata(makeMp4(), {
|
||||
width: 1,
|
||||
height: 1,
|
||||
duration: 1,
|
||||
frameRate: 1,
|
||||
});
|
||||
proc.planTransform(meta, { operations: [] });
|
||||
expect(spans).toHaveLength(2);
|
||||
for (const s of spans) expect(s.ended).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbortSignal", () => {
|
||||
it("aborts extractMetadata before work", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
expect(() =>
|
||||
proc.extractMetadata(makeMp4(), undefined, ac.signal)
|
||||
).toThrow();
|
||||
// No span should have been started: assertion runs before tracer call.
|
||||
expect(spans).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
501
packages/media/video/video/src/processor/processor.test.ts
Normal file
501
packages/media/video/video/src/processor/processor.test.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { VIDEO_CODES } from "../error-codes/error-codes.js";
|
||||
import { VideoError } from "../errors/errors.js";
|
||||
import { VideoPipelineBuilder } from "../pipeline-builder/pipeline-builder.js";
|
||||
import type { VideoMetadata } from "../types/video-types/video-types.js";
|
||||
import { VideoProcessor } from "./processor.js";
|
||||
|
||||
function makeMp4Header(): Uint8Array {
|
||||
const buf = new Uint8Array(16);
|
||||
buf.set([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70]);
|
||||
buf.set([0x69, 0x73, 0x6f, 0x6d], 8);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function makeMkvHeader(): Uint8Array {
|
||||
const buf = new Uint8Array(16);
|
||||
buf.set([0x1a, 0x45, 0xdf, 0xa3]);
|
||||
return buf;
|
||||
}
|
||||
|
||||
const baseMeta: VideoMetadata = {
|
||||
format: "mp4",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: 120,
|
||||
frameRate: 30,
|
||||
hasAudio: true,
|
||||
};
|
||||
|
||||
describe("VideoProcessor.constructor", () => {
|
||||
it("accepts an empty config", { timeout: 5000 }, () => {
|
||||
expect(() => new VideoProcessor()).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts a typed config", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new VideoProcessor({
|
||||
maxDuration: 600,
|
||||
defaultFormat: "webm",
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws CONFIG_INVALID for invalid config", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new VideoProcessor({
|
||||
maxDuration: -1 as unknown as number,
|
||||
});
|
||||
throw new Error("expected to throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(VideoError);
|
||||
expect((err as VideoError).code).toBe(VIDEO_CODES.CONFIG_INVALID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoProcessor.extractMetadata", () => {
|
||||
it("extracts from MP4 header", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const meta = proc.extractMetadata(makeMp4Header(), {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: 60,
|
||||
frameRate: 30,
|
||||
});
|
||||
expect(meta.format).toBe("mp4");
|
||||
expect(meta.width).toBe(1920);
|
||||
expect(meta.fileSize).toBe(16);
|
||||
});
|
||||
|
||||
it("detects MKV", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const meta = proc.extractMetadata(makeMkvHeader(), {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
duration: 30,
|
||||
frameRate: 24,
|
||||
});
|
||||
expect(meta.format).toBe("mkv");
|
||||
});
|
||||
|
||||
it("uses knownMetadata.format when header is opaque", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const meta = proc.extractMetadata(new Uint8Array(16), {
|
||||
format: "webm",
|
||||
width: 640,
|
||||
height: 480,
|
||||
duration: 10,
|
||||
frameRate: 25,
|
||||
});
|
||||
expect(meta.format).toBe("webm");
|
||||
});
|
||||
|
||||
it("throws UNKNOWN_FORMAT when no format can be determined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const proc = new VideoProcessor();
|
||||
try {
|
||||
proc.extractMetadata(new Uint8Array(16));
|
||||
throw new Error("expected to throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(VideoError);
|
||||
expect((err as VideoError).code).toBe(VIDEO_CODES.UNKNOWN_FORMAT);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws for duration exceeding max", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor({ maxDuration: 60 });
|
||||
try {
|
||||
proc.extractMetadata(makeMp4Header(), {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: 120,
|
||||
frameRate: 30,
|
||||
});
|
||||
throw new Error("expected to throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(VideoError);
|
||||
expect((err as VideoError).code).toBe(
|
||||
VIDEO_CODES.DURATION_EXCEEDED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults hasAudio to true", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const meta = proc.extractMetadata(makeMp4Header(), {
|
||||
width: 640,
|
||||
height: 480,
|
||||
duration: 10,
|
||||
frameRate: 30,
|
||||
});
|
||||
expect(meta.hasAudio).toBe(true);
|
||||
});
|
||||
|
||||
it("respects hasAudio: false override", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const meta = proc.extractMetadata(makeMp4Header(), {
|
||||
width: 640,
|
||||
height: 480,
|
||||
duration: 10,
|
||||
frameRate: 30,
|
||||
hasAudio: false,
|
||||
});
|
||||
expect(meta.hasAudio).toBe(false);
|
||||
});
|
||||
|
||||
it("emits metadata.extracted", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
let captured: VideoMetadata | undefined;
|
||||
proc.on("metadata.extracted", (m) => {
|
||||
captured = m;
|
||||
});
|
||||
proc.extractMetadata(makeMp4Header(), {
|
||||
width: 640,
|
||||
height: 480,
|
||||
duration: 10,
|
||||
frameRate: 30,
|
||||
});
|
||||
expect(captured?.format).toBe("mp4");
|
||||
});
|
||||
|
||||
it("extracts with no knownMetadata (uses defaults)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const meta = proc.extractMetadata(makeMp4Header());
|
||||
expect(meta.format).toBe("mp4");
|
||||
expect(meta.width).toBe(0);
|
||||
expect(meta.height).toBe(0);
|
||||
expect(meta.duration).toBe(0);
|
||||
expect(meta.frameRate).toBe(0);
|
||||
expect(meta.hasAudio).toBe(true);
|
||||
});
|
||||
|
||||
it("wraps non-Error throws as METADATA_PARSE_FAILED with undefined cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const proc = new VideoProcessor();
|
||||
// Deliberately throw a non-Error to exercise the
|
||||
// `err instanceof Error ? err : undefined` false branch.
|
||||
// Using a pattern biome cannot statically reject (the value's
|
||||
// type is `unknown`, so the lint rule cannot prove it isn't
|
||||
// Error-like).
|
||||
const data = new Proxy(makeMp4Header(), {
|
||||
get(target, prop) {
|
||||
if (prop === "byteLength") {
|
||||
const failure: unknown = "string-failure";
|
||||
throw failure;
|
||||
}
|
||||
return Reflect.get(target, prop);
|
||||
},
|
||||
}) as Uint8Array;
|
||||
try {
|
||||
proc.extractMetadata(data, {
|
||||
width: 1,
|
||||
height: 1,
|
||||
duration: 1,
|
||||
frameRate: 1,
|
||||
});
|
||||
throw new Error("expected to throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(VideoError);
|
||||
expect((err as VideoError).code).toBe(
|
||||
VIDEO_CODES.METADATA_PARSE_FAILED
|
||||
);
|
||||
expect((err as VideoError).cause).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts before any work", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
proc.extractMetadata(makeMp4Header(), undefined, ac.signal);
|
||||
throw new Error("expected to throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(VideoError);
|
||||
expect((err as VideoError).code).toBe(
|
||||
VIDEO_CODES.PROCESSING_FAILED
|
||||
);
|
||||
expect((err as VideoError).cause).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps unexpected throws as METADATA_PARSE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const proc = new VideoProcessor();
|
||||
// Pass a non-Uint8Array-shaped object — accessing .byteLength is fine
|
||||
// but accessing indexed bytes throws inside detectVideoFormat path
|
||||
// is hard to provoke here; instead, monkey-poison the bus.
|
||||
// We trigger the catch by passing in a Proxy that throws on byteLength.
|
||||
const data = new Proxy(makeMp4Header(), {
|
||||
get(target, prop) {
|
||||
if (prop === "byteLength") {
|
||||
throw new Error("byteLength boom");
|
||||
}
|
||||
return Reflect.get(target, prop);
|
||||
},
|
||||
}) as Uint8Array;
|
||||
try {
|
||||
proc.extractMetadata(data, {
|
||||
width: 1,
|
||||
height: 1,
|
||||
duration: 1,
|
||||
frameRate: 1,
|
||||
});
|
||||
throw new Error("expected to throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(VideoError);
|
||||
expect((err as VideoError).code).toBe(
|
||||
VIDEO_CODES.METADATA_PARSE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoProcessor.planTransform", () => {
|
||||
it("applies resize", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const r = proc.planTransform(baseMeta, {
|
||||
operations: [{ type: "resize", options: { preset: "720p" } }],
|
||||
});
|
||||
expect(r.width).toBe(1280);
|
||||
expect(r.height).toBe(720);
|
||||
});
|
||||
|
||||
it("applies trim", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const r = proc.planTransform(baseMeta, {
|
||||
operations: [
|
||||
{ type: "trim", range: { startTime: 10, endTime: 60 } },
|
||||
],
|
||||
});
|
||||
expect(r.duration).toBe(50);
|
||||
});
|
||||
|
||||
it("throws for invalid trim", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
try {
|
||||
proc.planTransform(baseMeta, {
|
||||
operations: [
|
||||
{ type: "trim", range: { startTime: 60, endTime: 10 } },
|
||||
],
|
||||
});
|
||||
throw new Error("expected to throw");
|
||||
} catch (err) {
|
||||
expect((err as VideoError).code).toBe(
|
||||
VIDEO_CODES.INVALID_TRIM_RANGE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("applies 90-degree rotation", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const r = proc.planTransform(baseMeta, {
|
||||
operations: [{ type: "rotate", degrees: 90 }],
|
||||
});
|
||||
expect(r.width).toBe(1080);
|
||||
expect(r.height).toBe(1920);
|
||||
});
|
||||
|
||||
it("applies 180-degree rotation", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const r = proc.planTransform(baseMeta, {
|
||||
operations: [{ type: "rotate", degrees: 180 }],
|
||||
});
|
||||
expect(r.width).toBe(1920);
|
||||
expect(r.height).toBe(1080);
|
||||
});
|
||||
|
||||
it("throws for non-90 rotation", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
try {
|
||||
proc.planTransform(baseMeta, {
|
||||
operations: [{ type: "rotate", degrees: 45 }],
|
||||
});
|
||||
throw new Error("expected to throw");
|
||||
} catch (err) {
|
||||
expect((err as VideoError).code).toBe(
|
||||
VIDEO_CODES.INVALID_TRANSFORM
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("flip preserves dimensions", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const r = proc.planTransform(baseMeta, {
|
||||
operations: [{ type: "flip", direction: "vertical" }],
|
||||
});
|
||||
expect(r.width).toBe(1920);
|
||||
expect(r.height).toBe(1080);
|
||||
});
|
||||
|
||||
it("applies speed factor", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const r = proc.planTransform(baseMeta, {
|
||||
operations: [{ type: "speed", factor: 2 }],
|
||||
});
|
||||
expect(r.duration).toBe(60);
|
||||
});
|
||||
|
||||
it("throws for zero speed", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
try {
|
||||
proc.planTransform(baseMeta, {
|
||||
operations: [{ type: "speed", factor: 0 }],
|
||||
});
|
||||
throw new Error("expected to throw");
|
||||
} catch (err) {
|
||||
expect((err as VideoError).code).toBe(
|
||||
VIDEO_CODES.INVALID_TRANSFORM
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("mute removes audio", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const r = proc.planTransform(baseMeta, {
|
||||
operations: [{ type: "mute" }],
|
||||
});
|
||||
expect(r.hasAudio).toBe(false);
|
||||
});
|
||||
|
||||
it("format op changes output format", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const r = proc.planTransform(baseMeta, {
|
||||
operations: [{ type: "format", format: "webm" }],
|
||||
});
|
||||
expect(r.format).toBe("webm");
|
||||
});
|
||||
|
||||
it("uses pipeline outputFormat", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const r = proc.planTransform(baseMeta, {
|
||||
operations: [],
|
||||
outputFormat: "mkv",
|
||||
});
|
||||
expect(r.format).toBe("mkv");
|
||||
});
|
||||
|
||||
it("codec op is a no-op", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const r = proc.planTransform(baseMeta, {
|
||||
operations: [{ type: "codec", codec: "h265" }],
|
||||
});
|
||||
expect(r.width).toBe(1920);
|
||||
});
|
||||
|
||||
it("bitrate op is a no-op", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const r = proc.planTransform(baseMeta, {
|
||||
operations: [{ type: "bitrate", value: 5_000_000 }],
|
||||
});
|
||||
expect(r.width).toBe(1920);
|
||||
});
|
||||
|
||||
it("emits transform.planned", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
let captured = false;
|
||||
proc.on("transform.planned", () => {
|
||||
captured = true;
|
||||
});
|
||||
proc.planTransform(baseMeta, { operations: [] });
|
||||
expect(captured).toBe(true);
|
||||
});
|
||||
|
||||
it("invalid pipeline throws INVALID_TRANSFORM", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
try {
|
||||
proc.planTransform(baseMeta, {
|
||||
operations: [
|
||||
// Unknown discriminant — Zod rejects.
|
||||
{ type: "explode" } as unknown as never,
|
||||
],
|
||||
});
|
||||
throw new Error("expected to throw");
|
||||
} catch (err) {
|
||||
expect((err as VideoError).code).toBe(
|
||||
VIDEO_CODES.INVALID_TRANSFORM
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("aborted signal short-circuits planTransform", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
proc.planTransform(baseMeta, { operations: [] }, ac.signal);
|
||||
throw new Error("expected to throw");
|
||||
} catch (err) {
|
||||
expect((err as VideoError).code).toBe(
|
||||
VIDEO_CODES.PROCESSING_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to processor defaultFormat", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor({ defaultFormat: "ogg" });
|
||||
const meta = {
|
||||
...baseMeta,
|
||||
format: undefined as unknown as VideoMetadata["format"],
|
||||
};
|
||||
const r = proc.planTransform(meta, { operations: [] });
|
||||
expect(r.format).toBe("ogg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoProcessor.createPipeline", () => {
|
||||
it("returns a VideoPipelineBuilder", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
expect(proc.createPipeline()).toBeInstanceOf(VideoPipelineBuilder);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoProcessor event surface", () => {
|
||||
it("once subscribes for one event only", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
let count = 0;
|
||||
proc.once("metadata.extracted", () => {
|
||||
count += 1;
|
||||
});
|
||||
const sample = makeMp4Header();
|
||||
proc.extractMetadata(sample, {
|
||||
width: 1,
|
||||
height: 1,
|
||||
duration: 1,
|
||||
frameRate: 1,
|
||||
});
|
||||
proc.extractMetadata(sample, {
|
||||
width: 1,
|
||||
height: 1,
|
||||
duration: 1,
|
||||
frameRate: 1,
|
||||
});
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("off unsubscribes", { timeout: 5000 }, () => {
|
||||
const proc = new VideoProcessor();
|
||||
let count = 0;
|
||||
const listener = () => {
|
||||
count += 1;
|
||||
};
|
||||
proc.on("metadata.extracted", listener);
|
||||
proc.off("metadata.extracted", listener);
|
||||
proc.extractMetadata(makeMp4Header(), {
|
||||
width: 1,
|
||||
height: 1,
|
||||
duration: 1,
|
||||
frameRate: 1,
|
||||
});
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
247
packages/media/video/video/src/processor/processor.ts
Normal file
247
packages/media/video/video/src/processor/processor.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { detectVideoFormat } from "../detection/detection.js";
|
||||
import {
|
||||
durationExceeded,
|
||||
invalidTrimRange,
|
||||
invalidVideoTransform,
|
||||
unknownVideoFormat,
|
||||
VideoError,
|
||||
videoConfigInvalid,
|
||||
videoMetadataParseFailed,
|
||||
videoProcessingFailed,
|
||||
} from "../errors/errors.js";
|
||||
import {
|
||||
getExtractCounter,
|
||||
getExtractDuration,
|
||||
getPlanCounter,
|
||||
getPlanDuration,
|
||||
getTracer,
|
||||
} from "../instruments/instruments.js";
|
||||
import { VideoPipelineBuilder } from "../pipeline-builder/pipeline-builder.js";
|
||||
import { calculateVideoResize } from "../resize/resize.js";
|
||||
import { validateTrimRange } from "../trim/trim.js";
|
||||
import {
|
||||
type VideoEventMap,
|
||||
type VideoFormat,
|
||||
type VideoMetadata,
|
||||
type VideoProcessorConfig,
|
||||
VideoProcessorConfigSchema,
|
||||
type VideoTransformPipeline,
|
||||
VideoTransformPipelineSchema,
|
||||
type VideoTransformResult,
|
||||
} from "../types/video-types/video-types.js";
|
||||
|
||||
function assertNotAborted(
|
||||
signal: AbortSignal | undefined,
|
||||
operation: string
|
||||
): void {
|
||||
if (signal?.aborted) {
|
||||
const abortError = new Error("AbortError");
|
||||
abortError.name = "AbortError";
|
||||
throw videoProcessingFailed(`${operation} aborted`, abortError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts video metadata and plans transform pipelines.
|
||||
*
|
||||
* Universal: no Node-only or browser-only imports. Codec work is
|
||||
* delegated to consumer-side ffmpeg / WebCodecs wrappers — this class
|
||||
* only sniffs headers and plans the shape of the output.
|
||||
*/
|
||||
export class VideoProcessor {
|
||||
private readonly maxDuration: number;
|
||||
private readonly defaultFormat: VideoFormat;
|
||||
private readonly bus: EventBus<VideoEventMap>;
|
||||
|
||||
public constructor(config?: VideoProcessorConfig) {
|
||||
const parsed = VideoProcessorConfigSchema.safeParse(config ?? {});
|
||||
if (!parsed.success) {
|
||||
throw videoConfigInvalid(parsed.error.message);
|
||||
}
|
||||
this.maxDuration = parsed.data.maxDuration ?? 7200;
|
||||
this.defaultFormat = parsed.data.defaultFormat ?? "mp4";
|
||||
this.bus = new EventBus<VideoEventMap>();
|
||||
}
|
||||
|
||||
public on<K extends keyof VideoEventMap>(
|
||||
type: K,
|
||||
listener: (payload: VideoEventMap[K]) => void
|
||||
): () => void {
|
||||
return this.bus.on(type, listener);
|
||||
}
|
||||
|
||||
public once<K extends keyof VideoEventMap>(
|
||||
type: K,
|
||||
listener: (payload: VideoEventMap[K]) => void
|
||||
): () => void {
|
||||
return this.bus.once(type, listener);
|
||||
}
|
||||
|
||||
public off<K extends keyof VideoEventMap>(
|
||||
type: K,
|
||||
listener: (payload: VideoEventMap[K]) => void
|
||||
): void {
|
||||
this.bus.off(type, listener);
|
||||
}
|
||||
|
||||
public extractMetadata(
|
||||
data: Uint8Array,
|
||||
knownMetadata?: Partial<VideoMetadata>,
|
||||
signal?: AbortSignal
|
||||
): VideoMetadata {
|
||||
assertNotAborted(signal, "extractMetadata");
|
||||
const start = Date.now();
|
||||
const span = getTracer().startSpan("video.metadata.extract");
|
||||
let outcome = "success";
|
||||
|
||||
try {
|
||||
const detected = detectVideoFormat(data);
|
||||
const format = knownMetadata?.format ?? detected;
|
||||
if (!format) {
|
||||
outcome = "unknown_format";
|
||||
throw unknownVideoFormat();
|
||||
}
|
||||
|
||||
const metadata: VideoMetadata = {
|
||||
format,
|
||||
width: knownMetadata?.width ?? 0,
|
||||
height: knownMetadata?.height ?? 0,
|
||||
duration: knownMetadata?.duration ?? 0,
|
||||
frameRate: knownMetadata?.frameRate ?? 0,
|
||||
bitrate: knownMetadata?.bitrate,
|
||||
videoCodec: knownMetadata?.videoCodec,
|
||||
audioCodec: knownMetadata?.audioCodec,
|
||||
hasAudio: knownMetadata?.hasAudio ?? true,
|
||||
fileSize: data.byteLength,
|
||||
};
|
||||
|
||||
if (metadata.duration > this.maxDuration) {
|
||||
outcome = "duration_exceeded";
|
||||
throw durationExceeded(metadata.duration, this.maxDuration);
|
||||
}
|
||||
|
||||
this.bus.emit("metadata.extracted", metadata);
|
||||
return metadata;
|
||||
} catch (err) {
|
||||
if (err instanceof VideoError) {
|
||||
throw err;
|
||||
}
|
||||
outcome = "error";
|
||||
throw videoMetadataParseFailed(
|
||||
err instanceof Error ? err : undefined
|
||||
);
|
||||
} finally {
|
||||
const durationMs = Date.now() - start;
|
||||
getExtractCounter().add(1, { outcome });
|
||||
getExtractDuration().record(durationMs, { outcome });
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
public planTransform(
|
||||
metadata: VideoMetadata,
|
||||
pipeline: VideoTransformPipeline,
|
||||
signal?: AbortSignal
|
||||
): VideoTransformResult {
|
||||
assertNotAborted(signal, "planTransform");
|
||||
const start = Date.now();
|
||||
const span = getTracer().startSpan("video.transform.plan");
|
||||
let outcome = "success";
|
||||
|
||||
try {
|
||||
const parsed = VideoTransformPipelineSchema.safeParse(pipeline);
|
||||
if (!parsed.success) {
|
||||
outcome = "invalid_pipeline";
|
||||
throw invalidVideoTransform(parsed.error.message);
|
||||
}
|
||||
|
||||
let width = metadata.width;
|
||||
let height = metadata.height;
|
||||
let duration = metadata.duration;
|
||||
let format =
|
||||
pipeline.outputFormat ?? metadata.format ?? this.defaultFormat;
|
||||
let hasAudio = metadata.hasAudio;
|
||||
|
||||
for (const op of pipeline.operations) {
|
||||
switch (op.type) {
|
||||
case "resize": {
|
||||
const dims = calculateVideoResize(
|
||||
{ width, height },
|
||||
op.options
|
||||
);
|
||||
width = dims.width;
|
||||
height = dims.height;
|
||||
break;
|
||||
}
|
||||
case "trim": {
|
||||
if (!validateTrimRange(duration, op.range)) {
|
||||
throw invalidTrimRange(
|
||||
op.range.startTime,
|
||||
op.range.endTime
|
||||
);
|
||||
}
|
||||
duration = op.range.endTime - op.range.startTime;
|
||||
break;
|
||||
}
|
||||
case "rotate": {
|
||||
if (op.degrees % 90 !== 0) {
|
||||
throw invalidVideoTransform(
|
||||
`Rotation must be multiple of 90, got ${op.degrees}`
|
||||
);
|
||||
}
|
||||
if (op.degrees % 180 !== 0) {
|
||||
const tmp = width;
|
||||
width = height;
|
||||
height = tmp;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "flip":
|
||||
break;
|
||||
case "speed": {
|
||||
if (op.factor <= 0) {
|
||||
throw invalidVideoTransform(
|
||||
`Speed factor must be positive, got ${op.factor}`
|
||||
);
|
||||
}
|
||||
duration = duration / op.factor;
|
||||
break;
|
||||
}
|
||||
case "mute":
|
||||
hasAudio = false;
|
||||
break;
|
||||
case "format":
|
||||
format = op.format;
|
||||
break;
|
||||
case "codec":
|
||||
case "bitrate":
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const result: VideoTransformResult = {
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
duration,
|
||||
operations: pipeline.operations,
|
||||
hasAudio,
|
||||
};
|
||||
this.bus.emit("transform.planned", result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (outcome === "success") outcome = "error";
|
||||
throw err;
|
||||
} finally {
|
||||
const durationMs = Date.now() - start;
|
||||
getPlanCounter().add(1, { outcome });
|
||||
getPlanDuration().record(durationMs, { outcome });
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
public createPipeline(): VideoPipelineBuilder {
|
||||
return new VideoPipelineBuilder(this.bus);
|
||||
}
|
||||
}
|
||||
1
packages/media/video/video/src/resize/index.ts
Normal file
1
packages/media/video/video/src/resize/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { calculateVideoResize } from "./resize.js";
|
||||
83
packages/media/video/video/src/resize/resize.md
Normal file
83
packages/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.
|
||||
79
packages/media/video/video/src/resize/resize.test.ts
Normal file
79
packages/media/video/video/src/resize/resize.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RESOLUTION_PRESETS } from "../types/video-types/video-types.js";
|
||||
import { calculateVideoResize } from "./resize.js";
|
||||
|
||||
describe("calculateVideoResize", () => {
|
||||
it("uses preset dimensions", { timeout: 5000 }, () => {
|
||||
const r = calculateVideoResize(
|
||||
{ width: 1920, height: 1080 },
|
||||
{ preset: "720p" }
|
||||
);
|
||||
expect(r).toEqual({ width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
it("maintains aspect ratio by default for single-axis width", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = calculateVideoResize(
|
||||
{ width: 1920, height: 1080 },
|
||||
{ width: 960 }
|
||||
);
|
||||
expect(r).toEqual({ width: 960, height: 540 });
|
||||
});
|
||||
|
||||
it("maintains aspect ratio by default for single-axis height", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = calculateVideoResize(
|
||||
{ width: 1920, height: 1080 },
|
||||
{ height: 540 }
|
||||
);
|
||||
expect(r).toEqual({ width: 960, height: 540 });
|
||||
});
|
||||
|
||||
it("ensures even dimensions", { timeout: 5000 }, () => {
|
||||
const r = calculateVideoResize(
|
||||
{ width: 1920, height: 1080 },
|
||||
{ width: 641, height: 481, maintainAspectRatio: false }
|
||||
);
|
||||
expect(r.width % 2).toBe(0);
|
||||
expect(r.height % 2).toBe(0);
|
||||
expect(r).toEqual({ width: 642, height: 482 });
|
||||
});
|
||||
|
||||
it("returns original when no options supplied", { timeout: 5000 }, () => {
|
||||
const r = calculateVideoResize({ width: 1920, height: 1080 }, {});
|
||||
expect(r).toEqual({ width: 1920, height: 1080 });
|
||||
});
|
||||
|
||||
it("skips aspect-ratio when both axes are supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = calculateVideoResize(
|
||||
{ width: 1920, height: 1080 },
|
||||
{ width: 800, height: 800 }
|
||||
);
|
||||
expect(r).toEqual({ width: 800, height: 800 });
|
||||
});
|
||||
|
||||
it("honours maintainAspectRatio: false", { timeout: 5000 }, () => {
|
||||
const r = calculateVideoResize(
|
||||
{ width: 1920, height: 1080 },
|
||||
{ width: 960, maintainAspectRatio: false }
|
||||
);
|
||||
expect(r).toEqual({ width: 960, height: 1080 });
|
||||
});
|
||||
|
||||
it("all presets are even", { timeout: 5000 }, () => {
|
||||
for (const preset of Object.keys(RESOLUTION_PRESETS) as Array<
|
||||
keyof typeof RESOLUTION_PRESETS
|
||||
>) {
|
||||
const r = calculateVideoResize(
|
||||
{ width: 1920, height: 1080 },
|
||||
{ preset }
|
||||
);
|
||||
expect(r.width % 2).toBe(0);
|
||||
expect(r.height % 2).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
49
packages/media/video/video/src/resize/resize.ts
Normal file
49
packages/media/video/video/src/resize/resize.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
RESOLUTION_PRESETS,
|
||||
type VideoDimensions,
|
||||
type VideoResizeOptions,
|
||||
} from "../types/video-types/video-types.js";
|
||||
|
||||
/**
|
||||
* Compute target dimensions for a resize operation.
|
||||
*
|
||||
* - Preset (when supplied) takes precedence and is used verbatim.
|
||||
* - Otherwise width/height are honoured, with the missing dimension
|
||||
* computed from the source aspect ratio unless
|
||||
* `maintainAspectRatio === false`.
|
||||
* - The result is always rounded UP to the next even pair — most codecs
|
||||
* require even chroma sub-sampling alignment.
|
||||
*/
|
||||
export function calculateVideoResize(
|
||||
original: VideoDimensions,
|
||||
options: VideoResizeOptions
|
||||
): VideoDimensions {
|
||||
let tw: number;
|
||||
let th: number;
|
||||
|
||||
if (options.preset) {
|
||||
const preset = RESOLUTION_PRESETS[options.preset];
|
||||
tw = preset.width;
|
||||
th = preset.height;
|
||||
} else {
|
||||
tw = options.width ?? original.width;
|
||||
th = options.height ?? original.height;
|
||||
}
|
||||
|
||||
if (options.maintainAspectRatio !== false && !options.preset) {
|
||||
const ratio = original.width / original.height;
|
||||
if (options.width !== undefined && options.height === undefined) {
|
||||
th = Math.round(tw / ratio);
|
||||
} else if (
|
||||
options.width === undefined &&
|
||||
options.height !== undefined
|
||||
) {
|
||||
tw = Math.round(th * ratio);
|
||||
}
|
||||
}
|
||||
|
||||
tw = tw % 2 === 0 ? tw : tw + 1;
|
||||
th = th % 2 === 0 ? th : th + 1;
|
||||
|
||||
return { width: tw, height: th };
|
||||
}
|
||||
1
packages/media/video/video/src/trim/index.ts
Normal file
1
packages/media/video/video/src/trim/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { validateTrimRange } from "./trim.js";
|
||||
73
packages/media/video/video/src/trim/trim.md
Normal file
73
packages/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.
|
||||
38
packages/media/video/video/src/trim/trim.test.ts
Normal file
38
packages/media/video/video/src/trim/trim.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { validateTrimRange } from "./trim.js";
|
||||
|
||||
describe("validateTrimRange", () => {
|
||||
it("accepts valid range", { timeout: 5000 }, () => {
|
||||
expect(validateTrimRange(60, { startTime: 10, endTime: 30 })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts full duration trim", { timeout: 5000 }, () => {
|
||||
expect(validateTrimRange(60, { startTime: 0, endTime: 60 })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects negative start", { timeout: 5000 }, () => {
|
||||
expect(validateTrimRange(60, { startTime: -1, endTime: 30 })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects end before start", { timeout: 5000 }, () => {
|
||||
expect(validateTrimRange(60, { startTime: 30, endTime: 10 })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects zero-length range", { timeout: 5000 }, () => {
|
||||
expect(validateTrimRange(60, { startTime: 10, endTime: 10 })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects end beyond duration", { timeout: 5000 }, () => {
|
||||
expect(validateTrimRange(60, { startTime: 10, endTime: 70 })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
12
packages/media/video/video/src/trim/trim.ts
Normal file
12
packages/media/video/video/src/trim/trim.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { TrimRange } from "../types/video-types/video-types.js";
|
||||
|
||||
/**
|
||||
* Return `true` iff `range` is a non-empty trim range fully contained
|
||||
* within `[0, duration]`.
|
||||
*/
|
||||
export function validateTrimRange(duration: number, range: TrimRange): boolean {
|
||||
if (range.startTime < 0) return false;
|
||||
if (range.endTime <= range.startTime) return false;
|
||||
if (range.endTime > duration) return false;
|
||||
return true;
|
||||
}
|
||||
29
packages/media/video/video/src/types/video-types/index.ts
Normal file
29
packages/media/video/video/src/types/video-types/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export {
|
||||
type AudioCodec,
|
||||
AudioCodecSchema,
|
||||
RESOLUTION_PRESETS,
|
||||
type ResolutionPreset,
|
||||
ResolutionPresetSchema,
|
||||
type TrimRange,
|
||||
TrimRangeSchema,
|
||||
VIDEO_MIME_TYPES,
|
||||
type VideoCodec,
|
||||
VideoCodecSchema,
|
||||
type VideoDimensions,
|
||||
VideoDimensionsSchema,
|
||||
type VideoEventMap,
|
||||
type VideoFormat,
|
||||
VideoFormatSchema,
|
||||
type VideoMetadata,
|
||||
VideoMetadataSchema,
|
||||
type VideoProcessorConfig,
|
||||
VideoProcessorConfigSchema,
|
||||
type VideoResizeOptions,
|
||||
VideoResizeOptionsSchema,
|
||||
type VideoTransformOp,
|
||||
VideoTransformOpSchema,
|
||||
type VideoTransformPipeline,
|
||||
VideoTransformPipelineSchema,
|
||||
type VideoTransformResult,
|
||||
VideoTransformResultSchema,
|
||||
} from "./video-types.js";
|
||||
111
packages/media/video/video/src/types/video-types/video-types.md
Normal file
111
packages/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.
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
RESOLUTION_PRESETS,
|
||||
VIDEO_MIME_TYPES,
|
||||
VideoMetadataSchema,
|
||||
VideoProcessorConfigSchema,
|
||||
VideoTransformOpSchema,
|
||||
VideoTransformPipelineSchema,
|
||||
VideoTransformResultSchema,
|
||||
} from "./video-types.js";
|
||||
|
||||
describe("VideoTransformOpSchema", () => {
|
||||
it("VideoTransformOpSchema parses every variant", { timeout: 5000 }, () => {
|
||||
const variants = [
|
||||
{ type: "resize" as const, options: { preset: "720p" as const } },
|
||||
{
|
||||
type: "trim" as const,
|
||||
range: { startTime: 0, endTime: 30 },
|
||||
},
|
||||
{ type: "rotate" as const, degrees: 90 },
|
||||
{ type: "flip" as const, direction: "horizontal" as const },
|
||||
{ type: "speed" as const, factor: 2 },
|
||||
{ type: "mute" as const },
|
||||
{ type: "format" as const, format: "mp4" as const },
|
||||
{ type: "codec" as const, codec: "h265" as const },
|
||||
{ type: "bitrate" as const, value: 5_000_000 },
|
||||
];
|
||||
for (const v of variants) {
|
||||
expect(() => VideoTransformOpSchema.parse(v)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("VideoTransformOpSchema rejects unknown discriminant", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = VideoTransformOpSchema.safeParse({ type: "explode" });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoMetadataSchema", () => {
|
||||
it("parses a full metadata object", { timeout: 5000 }, () => {
|
||||
const meta = {
|
||||
format: "mp4" as const,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: 60,
|
||||
frameRate: 30,
|
||||
hasAudio: true,
|
||||
};
|
||||
expect(VideoMetadataSchema.parse(meta)).toEqual(meta);
|
||||
});
|
||||
|
||||
it("rejects negative width", { timeout: 5000 }, () => {
|
||||
const result = VideoMetadataSchema.safeParse({
|
||||
format: "mp4",
|
||||
width: -1,
|
||||
height: 1080,
|
||||
duration: 60,
|
||||
frameRate: 30,
|
||||
hasAudio: true,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoTransformPipelineSchema", () => {
|
||||
it("parses an empty pipeline", { timeout: 5000 }, () => {
|
||||
const parsed = VideoTransformPipelineSchema.parse({ operations: [] });
|
||||
expect(parsed.operations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoTransformResultSchema", () => {
|
||||
it("parses a typical result", { timeout: 5000 }, () => {
|
||||
const result = VideoTransformResultSchema.parse({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
format: "webm",
|
||||
duration: 30,
|
||||
operations: [],
|
||||
hasAudio: true,
|
||||
});
|
||||
expect(result.width).toBe(1280);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoProcessorConfigSchema", () => {
|
||||
it("accepts an empty config", { timeout: 5000 }, () => {
|
||||
expect(VideoProcessorConfigSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects negative maxDuration", { timeout: 5000 }, () => {
|
||||
const r = VideoProcessorConfigSchema.safeParse({ maxDuration: -1 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RESOLUTION_PRESETS", () => {
|
||||
it("has 6 entries", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(RESOLUTION_PRESETS)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("4k is 3840x2160", { timeout: 5000 }, () => {
|
||||
expect(RESOLUTION_PRESETS["4k"]).toEqual({ width: 3840, height: 2160 });
|
||||
});
|
||||
|
||||
it("720p is 1280x720", { timeout: 5000 }, () => {
|
||||
expect(RESOLUTION_PRESETS["720p"]).toEqual({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("VIDEO_MIME_TYPES", () => {
|
||||
it("has 8 entries", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(VIDEO_MIME_TYPES)).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("mp4 maps to video/mp4", { timeout: 5000 }, () => {
|
||||
expect(VIDEO_MIME_TYPES.mp4).toBe("video/mp4");
|
||||
});
|
||||
});
|
||||
231
packages/media/video/video/src/types/video-types/video-types.ts
Normal file
231
packages/media/video/video/src/types/video-types/video-types.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import z from "zod";
|
||||
|
||||
export const VideoFormatSchema = z.enum([
|
||||
"mp4",
|
||||
"webm",
|
||||
"avi",
|
||||
"mkv",
|
||||
"mov",
|
||||
"flv",
|
||||
"wmv",
|
||||
"ogg",
|
||||
]);
|
||||
|
||||
export type VideoFormat = z.infer<typeof VideoFormatSchema>;
|
||||
|
||||
export const VideoCodecSchema = z.enum([
|
||||
"h264",
|
||||
"h265",
|
||||
"vp8",
|
||||
"vp9",
|
||||
"av1",
|
||||
"mpeg4",
|
||||
]);
|
||||
|
||||
export type VideoCodec = z.infer<typeof VideoCodecSchema>;
|
||||
|
||||
export const AudioCodecSchema = z.enum([
|
||||
"aac",
|
||||
"mp3",
|
||||
"opus",
|
||||
"vorbis",
|
||||
"flac",
|
||||
"pcm",
|
||||
]);
|
||||
|
||||
export type AudioCodec = z.infer<typeof AudioCodecSchema>;
|
||||
|
||||
export const ResolutionPresetSchema = z.enum([
|
||||
"360p",
|
||||
"480p",
|
||||
"720p",
|
||||
"1080p",
|
||||
"1440p",
|
||||
"4k",
|
||||
]);
|
||||
|
||||
export type ResolutionPreset = z.infer<typeof ResolutionPresetSchema>;
|
||||
|
||||
export const VideoDimensionsSchema = z.object({
|
||||
width: z.number().int().nonnegative(),
|
||||
height: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export interface VideoDimensions {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export const VideoMetadataSchema = z.object({
|
||||
format: VideoFormatSchema,
|
||||
width: z.number().int().nonnegative(),
|
||||
height: z.number().int().nonnegative(),
|
||||
duration: z.number().nonnegative(),
|
||||
frameRate: z.number().nonnegative(),
|
||||
bitrate: z.number().nonnegative().optional(),
|
||||
videoCodec: VideoCodecSchema.optional(),
|
||||
audioCodec: AudioCodecSchema.optional(),
|
||||
hasAudio: z.boolean(),
|
||||
fileSize: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export interface VideoMetadata {
|
||||
readonly format: VideoFormat;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly duration: number;
|
||||
readonly frameRate: number;
|
||||
readonly bitrate?: number;
|
||||
readonly videoCodec?: VideoCodec;
|
||||
readonly audioCodec?: AudioCodec;
|
||||
readonly hasAudio: boolean;
|
||||
readonly fileSize?: number;
|
||||
}
|
||||
|
||||
export const TrimRangeSchema = z.object({
|
||||
startTime: z.number().nonnegative(),
|
||||
endTime: z.number().nonnegative(),
|
||||
});
|
||||
|
||||
export interface TrimRange {
|
||||
readonly startTime: number;
|
||||
readonly endTime: number;
|
||||
}
|
||||
|
||||
export const VideoResizeOptionsSchema = z.object({
|
||||
width: z.number().int().positive().optional(),
|
||||
height: z.number().int().positive().optional(),
|
||||
preset: ResolutionPresetSchema.optional(),
|
||||
maintainAspectRatio: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export interface VideoResizeOptions {
|
||||
readonly width?: number;
|
||||
readonly height?: number;
|
||||
readonly preset?: ResolutionPreset;
|
||||
readonly maintainAspectRatio?: boolean;
|
||||
}
|
||||
|
||||
const ResizeOpSchema = z.object({
|
||||
type: z.literal("resize"),
|
||||
options: VideoResizeOptionsSchema,
|
||||
});
|
||||
|
||||
const TrimOpSchema = z.object({
|
||||
type: z.literal("trim"),
|
||||
range: TrimRangeSchema,
|
||||
});
|
||||
|
||||
const RotateOpSchema = z.object({
|
||||
type: z.literal("rotate"),
|
||||
degrees: z.number().int(),
|
||||
});
|
||||
|
||||
const FlipOpSchema = z.object({
|
||||
type: z.literal("flip"),
|
||||
direction: z.enum(["horizontal", "vertical"]),
|
||||
});
|
||||
|
||||
const SpeedOpSchema = z.object({
|
||||
type: z.literal("speed"),
|
||||
factor: z.number(),
|
||||
});
|
||||
|
||||
const MuteOpSchema = z.object({
|
||||
type: z.literal("mute"),
|
||||
});
|
||||
|
||||
const FormatOpSchema = z.object({
|
||||
type: z.literal("format"),
|
||||
format: VideoFormatSchema,
|
||||
});
|
||||
|
||||
const CodecOpSchema = z.object({
|
||||
type: z.literal("codec"),
|
||||
codec: VideoCodecSchema,
|
||||
});
|
||||
|
||||
const BitrateOpSchema = z.object({
|
||||
type: z.literal("bitrate"),
|
||||
value: z.number().positive(),
|
||||
});
|
||||
|
||||
export const VideoTransformOpSchema = z.discriminatedUnion("type", [
|
||||
ResizeOpSchema,
|
||||
TrimOpSchema,
|
||||
RotateOpSchema,
|
||||
FlipOpSchema,
|
||||
SpeedOpSchema,
|
||||
MuteOpSchema,
|
||||
FormatOpSchema,
|
||||
CodecOpSchema,
|
||||
BitrateOpSchema,
|
||||
]);
|
||||
|
||||
export type VideoTransformOp = z.infer<typeof VideoTransformOpSchema>;
|
||||
|
||||
export const VideoTransformPipelineSchema = z.object({
|
||||
operations: z.array(VideoTransformOpSchema),
|
||||
outputFormat: VideoFormatSchema.optional(),
|
||||
});
|
||||
|
||||
export interface VideoTransformPipeline {
|
||||
readonly operations: readonly VideoTransformOp[];
|
||||
readonly outputFormat?: VideoFormat;
|
||||
}
|
||||
|
||||
export const VideoTransformResultSchema = z.object({
|
||||
width: z.number().int().nonnegative(),
|
||||
height: z.number().int().nonnegative(),
|
||||
format: VideoFormatSchema,
|
||||
duration: z.number().nonnegative(),
|
||||
operations: z.array(VideoTransformOpSchema),
|
||||
hasAudio: z.boolean(),
|
||||
});
|
||||
|
||||
export interface VideoTransformResult {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly format: VideoFormat;
|
||||
readonly duration: number;
|
||||
readonly operations: readonly VideoTransformOp[];
|
||||
readonly hasAudio: boolean;
|
||||
}
|
||||
|
||||
export const VideoProcessorConfigSchema = z.object({
|
||||
maxDuration: z.number().positive().optional(),
|
||||
defaultFormat: VideoFormatSchema.optional(),
|
||||
});
|
||||
|
||||
export interface VideoProcessorConfig {
|
||||
readonly maxDuration?: number;
|
||||
readonly defaultFormat?: VideoFormat;
|
||||
}
|
||||
|
||||
export type VideoEventMap = {
|
||||
"metadata.extracted": VideoMetadata;
|
||||
"transform.planned": VideoTransformResult;
|
||||
"pipeline.built": VideoTransformPipeline;
|
||||
};
|
||||
|
||||
export const RESOLUTION_PRESETS: Readonly<
|
||||
Record<ResolutionPreset, VideoDimensions>
|
||||
> = {
|
||||
"360p": { width: 640, height: 360 },
|
||||
"480p": { width: 854, height: 480 },
|
||||
"720p": { width: 1280, height: 720 },
|
||||
"1080p": { width: 1920, height: 1080 },
|
||||
"1440p": { width: 2560, height: 1440 },
|
||||
"4k": { width: 3840, height: 2160 },
|
||||
};
|
||||
|
||||
export const VIDEO_MIME_TYPES: Readonly<Record<VideoFormat, string>> = {
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
avi: "video/x-msvideo",
|
||||
mkv: "video/x-matroska",
|
||||
mov: "video/quicktime",
|
||||
flv: "video/x-flv",
|
||||
wmv: "video/x-ms-wmv",
|
||||
ogg: "video/ogg",
|
||||
};
|
||||
1
packages/media/video/video/tests/setup/polyfills.ts
Normal file
1
packages/media/video/video/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@suaveplan/testing/polyfills";
|
||||
13
packages/media/video/video/tsconfig.lib.json
Normal file
13
packages/media/video/video/tsconfig.lib.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"exactOptionalPropertyTypes": false
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
31
packages/media/video/video/vite.config.ts
Normal file
31
packages/media/video/video/vite.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: {
|
||||
index: "./src/index.ts",
|
||||
"error-codes/error-codes": "./src/error-codes/error-codes.ts",
|
||||
"errors/errors": "./src/errors/errors.ts",
|
||||
"types/video-types": "./src/types/video-types/video-types.ts",
|
||||
"detection/detection": "./src/detection/detection.ts",
|
||||
"resize/resize": "./src/resize/resize.ts",
|
||||
"trim/trim": "./src/trim/trim.ts",
|
||||
"duration/duration": "./src/duration/duration.ts",
|
||||
"format/format": "./src/format/format.ts",
|
||||
"instruments/instruments":
|
||||
"./src/instruments/instruments.ts",
|
||||
"processor/processor": "./src/processor/processor.ts",
|
||||
"pipeline-builder/pipeline-builder":
|
||||
"./src/pipeline-builder/pipeline-builder.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
external: [/^@suaveplan\//, /^@opentelemetry\//, "zod", "tslib"],
|
||||
},
|
||||
},
|
||||
plugins: [dts({ rollupTypes: false, include: ["src/**/*.ts"], tsconfigPath: "./tsconfig.lib.json" })],
|
||||
});
|
||||
31
packages/media/video/video/vitest.config.ts
Normal file
31
packages/media/video/video/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user