166 lines
8.5 KiB
Markdown
166 lines
8.5 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
||
|
||
# Capability Spec: video-thumbnails
|
||
|
||
**Package**: `@suaveplan/video-thumbnails`
|
||
**Status**: SHIPPED — 2026-05-25
|
||
|
||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||
|
||
## ADDED Requirements
|
||
|
||
### Requirement: Single-frame thumbnail extraction
|
||
|
||
`ThumbnailGenerator.generate(inputPath, options?, signal?)` MUST extract one frame from the video at the requested timestamp using the configured `FfmpegRunner`, write the encoded image to `${outputDir}/thumb_<timestamp>.<ext>` honouring the configured `format` and `quality`, and resolve with `ThumbnailResult { outputPath, width, height, format, sizeBytes, timestamp }`. The default timestamp is `00:00:01` and the default `fit` is `cover`.
|
||
|
||
#### Scenario: extracts a JPEG at the default timestamp
|
||
|
||
- **WHEN** `generate(fixturePath)` is invoked with a real 5-second fixture video and `{ outputDir, format: "jpg" }`
|
||
- **THEN** the returned `outputPath` exists on disk, has `sizeBytes > 0`, and `format === "jpg"`
|
||
|
||
**Verified by:** `src/thumbnail-generator/thumbnail-generator.test.ts::"extracts a JPEG at the default timestamp"`
|
||
|
||
---
|
||
|
||
### Requirement: Multi-timestamp extraction
|
||
|
||
`ThumbnailGenerator.generateMultiple(inputPath, timestamps, signal?)` MUST extract one thumbnail per supplied timestamp in input order and return the resulting `ThumbnailResult[]`. The operation MUST honour `AbortSignal`; if the signal aborts before the next iteration starts, the method MUST reject with `VIDEO_THUMBNAILS.ABORTED`.
|
||
|
||
#### Scenario: returns one result per timestamp
|
||
|
||
- **WHEN** `generateMultiple(fixture, ["00:00:01", "00:00:02", "00:00:03"])` is called
|
||
- **THEN** the result has length 3 and each entry's `outputPath` exists on disk
|
||
|
||
**Verified by:** `src/thumbnail-generator/thumbnail-generator.test.ts::"returns one result per timestamp"`
|
||
|
||
---
|
||
|
||
### Requirement: Sliding-window extraction
|
||
|
||
`ThumbnailGenerator.extractSlidingWindow(inputPath, count, durationSeconds, signal?)` MUST produce exactly `count` thumbnails evenly distributed across the supplied duration, with each timestamp formatted as `HH:MM:SS.mmm`. Invalid `count` (≤ 0) or non-finite `durationSeconds` MUST raise `VIDEO_THUMBNAILS.INVALID_DIMENSIONS`.
|
||
|
||
#### Scenario: produces exactly N evenly-spaced thumbnails
|
||
|
||
- **WHEN** `extractSlidingWindow(fixture, 4, 5)` is called on the 5-second fixture
|
||
- **THEN** the result has length 4 and the timestamps reported on each `ThumbnailResult` are strictly monotonic
|
||
|
||
**Verified by:** `src/thumbnail-generator/thumbnail-generator.test.ts::"produces exactly N evenly-spaced thumbnails"`
|
||
|
||
---
|
||
|
||
### Requirement: Video metadata probe
|
||
|
||
`ThumbnailGenerator.getMetadata(inputPath, signal?)` MUST invoke `runner.probe` and return a `VideoMetadata { duration, width, height, codec, fps, bitrate }`. If the probe reports no video stream the method MUST reject with `VIDEO_THUMBNAILS.VIDEO_INVALID`.
|
||
|
||
#### Scenario: returns metadata for a real fixture
|
||
|
||
- **WHEN** `getMetadata(fixture)` is called on the 5-second 320×240 fixture
|
||
- **THEN** `duration ≈ 5`, `width === 320`, `height === 240`, `codec === "h264"`
|
||
|
||
**Verified by:** `src/thumbnail-generator/thumbnail-generator.test.ts::"returns metadata for a real fixture"`
|
||
|
||
---
|
||
|
||
### Requirement: Runner availability probe
|
||
|
||
`ThumbnailGenerator.isAvailable(signal?)` MUST delegate to `runner.isAvailable()` and return its boolean result.
|
||
|
||
#### Scenario: reports ffmpeg available when binary is installed
|
||
|
||
- **WHEN** `isAvailable()` is called against the bundled `@ffmpeg-installer/ffmpeg` binary
|
||
- **THEN** the result is `true`
|
||
|
||
**Verified by:** `src/thumbnail-generator/thumbnail-generator.test.ts::"reports ffmpeg available when binary is installed"`
|
||
|
||
---
|
||
|
||
### Requirement: Sprite-sheet generation
|
||
|
||
`SpriteSheetGenerator.generate(inputPath, outputPath, duration?, signal?)` MUST produce one tiled image of `columns × rows` thumbnails (defaults 5 × 5) sampled at the configured interval, and resolve with `SpriteSheetResult { outputPath, width, height, columns, rows, frameCount, interval, sizeBytes }`. When `duration` is omitted, the generator MUST auto-detect duration via `runner.probe`.
|
||
|
||
#### Scenario: produces a 3×3 sprite sheet for the fixture
|
||
|
||
- **WHEN** `generate(fixture, output, 5)` is called with `{ columns: 3, rows: 3, thumbnailWidth: 80, thumbnailHeight: 60, interval: 1, format: "jpg" }`
|
||
- **THEN** the returned `width === 240`, `height === 180`, `frameCount === 5`, `outputPath` exists with non-zero size
|
||
|
||
**Verified by:** `src/sprite-sheet/sprite-sheet.test.ts::"produces a 3x3 sprite sheet for the fixture"`
|
||
|
||
---
|
||
|
||
### Requirement: AbortSignal propagation
|
||
|
||
Every async public method on `ThumbnailGenerator` and `SpriteSheetGenerator` MUST honour an `AbortSignal`. When the signal aborts mid-operation the underlying `FfmpegRunner` MUST terminate the child process and the call MUST reject with `VIDEO_THUMBNAILS.ABORTED`.
|
||
|
||
#### Scenario: aborts a long-running extract
|
||
|
||
- **WHEN** `generate(fixture, { timestamp: "00:00:01" }, signal)` is invoked and the `AbortController` is aborted immediately
|
||
- **THEN** the promise rejects with a `VideoThumbnailsError` whose `code === "VIDEO_THUMBNAILS.ABORTED"`
|
||
|
||
**Verified by:** `src/thumbnail-generator/thumbnail-generator.test.ts::"aborts a long-running extract"`
|
||
|
||
---
|
||
|
||
### Requirement: Zod boundary validation
|
||
|
||
`ThumbnailGenerator`, `SpriteSheetGenerator`, and their option arguments MUST be validated at the public boundary using Zod schemas. Invalid input MUST raise `VIDEO_THUMBNAILS.INVALID_OPTIONS` (or `INVALID_DIMENSIONS` / `INVALID_TIMESTAMP` / `FORMAT_UNSUPPORTED` where the underlying field maps to a more specific code) with the validation `issues` carried on the error context.
|
||
|
||
#### Scenario: rejects width ≤ 0
|
||
|
||
- **WHEN** a `ThumbnailGenerator` is constructed with `{ outputDir, width: 0 }`
|
||
- **THEN** the constructor throws `VideoThumbnailsError { code: "VIDEO_THUMBNAILS.INVALID_DIMENSIONS" }`
|
||
|
||
**Verified by:** `src/thumbnail-generator/thumbnail-generator.test.ts::"rejects width <= 0"`
|
||
|
||
#### Scenario: rejects malformed timestamp
|
||
|
||
- **WHEN** `generate(fixture, { timestamp: "not-a-time" })` is called
|
||
- **THEN** the promise rejects with `VideoThumbnailsError { code: "VIDEO_THUMBNAILS.INVALID_TIMESTAMP" }`
|
||
|
||
**Verified by:** `src/thumbnail-generator/thumbnail-generator.test.ts::"rejects malformed timestamp"`
|
||
|
||
#### Scenario: rejects unsupported output format
|
||
|
||
- **WHEN** a generator is constructed with an unsupported `format`
|
||
- **THEN** the constructor throws `VideoThumbnailsError { code: "VIDEO_THUMBNAILS.FORMAT_UNSUPPORTED" }`
|
||
|
||
**Verified by:** `src/thumbnail-generator/thumbnail-generator.test.ts::"rejects unsupported output format"`
|
||
|
||
---
|
||
|
||
### Requirement: Error code registry
|
||
|
||
The package MUST expose a stable code registry built with `defineCodes("VIDEO_THUMBNAILS", [...])` containing exactly the nine codes `EXTRACT_FAILED`, `PROBE_FAILED`, `VIDEO_INVALID`, `INVALID_TIMESTAMP`, `INVALID_DIMENSIONS`, `INVALID_OPTIONS`, `FORMAT_UNSUPPORTED`, `RUNNER_UNAVAILABLE`, `ABORTED`. Every code MUST be reachable from a real call site in `src/`.
|
||
|
||
#### Scenario: every code is thrown by a real call site
|
||
|
||
- **WHEN** the test suite walks every code in `VIDEO_THUMBNAILS_CODES`
|
||
- **THEN** a `src/` grep finds at least one `throw new VideoThumbnailsError(VIDEO_THUMBNAILS_CODES.<CODE>` for each
|
||
|
||
**Verified by:** `src/error-codes/error-codes.test.ts::"every code is thrown by a real call site"`
|
||
|
||
---
|
||
|
||
### Requirement: OTel instrumentation
|
||
|
||
Every public extract / sprite-sheet call MUST record `video_thumbnails.extract.duration_ms{format,fit,outcome}`, `video_thumbnails.extract.total{outcome,format}`, `video_thumbnails.sprite_sheet.duration_ms{outcome,format}`, and `video_thumbnails.sprite_sheet.frames{format}` against a meter named `@suaveplan/video-thumbnails`.
|
||
|
||
#### Scenario: emits success counter after a real extract
|
||
|
||
- **WHEN** `generate(fixture)` completes successfully against an in-memory OTel `InMemoryMetricExporter`
|
||
- **THEN** the exported metrics contain a `video_thumbnails.extract.total` counter increment with `outcome="success"`
|
||
|
||
**Verified by:** `src/telemetry/telemetry.test.ts::"emits success counter after a real extract"`
|
||
|
||
---
|
||
|
||
### Requirement: Subpath exports
|
||
|
||
The package MUST publish subpath exports `./errors`, `./schemas`, `./runner`, `./thumbnail-generator`, `./sprite-sheet`, and `./telemetry` in addition to the root `.`, each pointing at the corresponding feature barrel.
|
||
|
||
#### Scenario: feature subpaths resolve
|
||
|
||
- **WHEN** a consumer imports `@suaveplan/video-thumbnails/runner`
|
||
- **THEN** the `FfmpegRunner` interface and `FluentFfmpegRunner` class are importable
|
||
|
||
**Verified by:** `src/index.test.ts::"feature subpaths resolve"`
|