Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
<!-- 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"`