Files
suaveplan/openspec/archive/2026-05-25-enterprise-video-thumbnails/design.md
2026-05-27 23:29:53 +01:00

126 lines
5.5 KiB
Markdown
Raw 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.
# Design — enterprise-video-thumbnails
> Non-normative. Architecture, file layout, rationale.
## Architecture
The package is split into four feature modules under `src/`:
```text
packages/media/video/video-thumbnails/src/
index.ts # package root barrel (named re-exports)
error-codes/
index.ts
error-codes.ts # defineCodes("VIDEO_THUMBNAILS", [...])
error-codes.md
errors/
index.ts
video-thumbnails-error.ts # VideoThumbnailsError extends SuaveplanError
video-thumbnails-error.md
schemas/
index.ts
schemas.ts # Zod schemas for every option type
schemas.md
schemas.types.ts # inferred TS types
telemetry/
index.ts
telemetry.ts # meter + counters/histograms factory
telemetry.md
runner/
index.ts
runner.ts # FfmpegRunner interface + helpers
runner.md
runner.types.ts
fluent-ffmpeg-runner.ts # default impl using fluent-ffmpeg + @ffmpeg-installer
fluent-ffmpeg-runner.md
thumbnail-generator/
index.ts
thumbnail-generator.ts # ThumbnailGenerator class
thumbnail-generator.md
thumbnail-generator.types.ts
sprite-sheet/
index.ts
sprite-sheet.ts # SpriteSheetGenerator class
sprite-sheet.md
sprite-sheet.types.ts
```
## Key types
```ts
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>;
}
class ThumbnailGenerator {
constructor(config: ThumbnailGeneratorConfig, runner?: FfmpegRunner);
generate(input, options?, signal?): Promise<ThumbnailResult>;
generateMultiple(input, timestamps, signal?): Promise<ThumbnailResult[]>;
extractSlidingWindow(input, count, durationSeconds, signal?): Promise<ThumbnailResult[]>;
getMetadata(input, signal?): Promise<VideoMetadata>;
isAvailable(signal?): Promise<boolean>;
}
class SpriteSheetGenerator {
constructor(config?: SpriteSheetConfig, runner?: FfmpegRunner);
generate(input, outputPath, duration?, signal?): Promise<SpriteSheetResult>;
}
```
## Default runner
`FluentFfmpegRunner` wraps `fluent-ffmpeg`, pinning the binary to `require("@ffmpeg-installer/ffmpeg").path` so consumers do not need ffmpeg on PATH. `extract`, `tile`, `probe` translate the typed call into a fluent-ffmpeg chain and resolve when the command emits `end`/`error`. The runner registers an `abort` listener on the supplied `AbortSignal` and calls `command.kill("SIGKILL")` on trigger, rejecting with `VIDEO_THUMBNAILS.ABORTED`.
## Error model
All public methods throw `VideoThumbnailsError` (extends `SuaveplanError<VideoThumbnailsCode>`). Codes:
| Code | When raised |
|---|---|
| `VIDEO_THUMBNAILS.EXTRACT_FAILED` | ffmpeg exited non-zero on a single-frame extract or sprite render. |
| `VIDEO_THUMBNAILS.PROBE_FAILED` | ffprobe failed or produced unparseable JSON. |
| `VIDEO_THUMBNAILS.VIDEO_INVALID` | Probe succeeded but found no video stream or duration ≤ 0. |
| `VIDEO_THUMBNAILS.INVALID_TIMESTAMP` | Caller supplied a timestamp string that does not match `HH:MM:SS(.mmm)?`. |
| `VIDEO_THUMBNAILS.INVALID_DIMENSIONS` | Width or height ≤ 0, or `count` for sliding-window ≤ 0. |
| `VIDEO_THUMBNAILS.INVALID_OPTIONS` | Zod boundary validation failed for `ThumbnailConfig`/`ThumbnailOptions`/`SpriteSheetConfig`. |
| `VIDEO_THUMBNAILS.FORMAT_UNSUPPORTED` | Output format not in the supported set. |
| `VIDEO_THUMBNAILS.RUNNER_UNAVAILABLE` | `runner.isAvailable()` returned `false` and a guard was requested. |
| `VIDEO_THUMBNAILS.ABORTED` | `AbortSignal` triggered mid-operation. |
## Observability
A package-scoped meter (`@suaveplan/video-thumbnails`) emits:
- `video_thumbnails.extract.duration_ms` (histogram, attrs `{format, fit, outcome}`)
- `video_thumbnails.extract.total` (counter, attrs `{outcome, format}`)
- `video_thumbnails.sprite_sheet.duration_ms` (histogram, attrs `{outcome, format}`)
- `video_thumbnails.sprite_sheet.frames` (histogram, attrs `{format}`)
The meter is lazily fetched per call via `metrics.getMeter` so the package retains `sideEffects: false` and tree-shakes when callers do not import the generator entry.
## Testing strategy
A `tests/setup/fixtures.ts` helper synthesises a 5-second 320×240 colour-gradient MP4 once per worker using ffmpeg's `lavfi` source (`-f lavfi -i color=c=red:size=320x240:rate=10:duration=5`). The path is cached and reused across tests. Tests cover:
- Real single-frame extraction at 0s, mid, near-end for each format.
- `generateMultiple` over three timestamps.
- `extractSlidingWindow` returns exactly N evenly-spaced frames.
- `getMetadata` returns duration ≈ 5s, width 320, height 240, codec `h264`.
- Sprite sheet 3×3 generation against the same fixture.
- `AbortSignal` cancels mid-extract → `ABORTED`.
- Zod boundary rejects invalid options.
- Every error code is reachable from a real call site.
No mocks. ffmpeg binary is sourced from `@ffmpeg-installer/ffmpeg`.
## File layout vs. AGENTS rules
- All feature modules live under `src/<feature>/` (no flat `src/`-root impl files).
- Every `.ts` (except `index.ts`, `*.types.ts`, `*.test.ts`) has a sibling `.md`.
- Barrels are explicit named re-exports.
- Test files import from `@suaveplan/testing/runner`.
- No `biome-ignore`, no `export *`, no direct `vitest`/`bun:test` imports.