# 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_.` 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.` 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"`