Files
2026-05-27 23:29:53 +01:00

166 lines
8.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: video-thumbnails
**Package**: `@suaveplan/video-thumbnails`
**Status**: PROPOSED
> 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"`