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,125 @@
# 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.

View File

@@ -0,0 +1,55 @@
# OpenSpec Change: enterprise-video-thumbnails
- **Change ID**: `enterprise-video-thumbnails`
- **Package**: `@suaveplan/video-thumbnails`
- **Category**: `media` / **Plugin**: `video`
- **Target path**: `packages/media/video/video-thumbnails`
- **Status**: PROPOSED
- **Date**: 2026-05-25
- **Design**: ./design.md
- **Spec delta**: ./specs/video-thumbnails/spec.md
- **Upstream**: [`../suaveplan/packages/universal/video-thumbnails`](/mnt/s/web/suaveplan/packages/universal/video-thumbnails)
- **Enterprise audit**: [`../suaveplan/enterprise/video-thumbnails.md`](/mnt/s/web/suaveplan/enterprise/video-thumbnails.md)
## Summary
Migrate `@suaveplan/video-thumbnails` from main into the genesis monorepo at enterprise quality. The package generates video thumbnails (single-frame extraction at arbitrary timestamps, batched time-based extracts, sliding-window sampling) and sprite sheets (NxM grid via ffmpeg `tile` filter) over JPEG/PNG/WebP outputs, on top of an injectable `FfmpegRunner` and the bundled `@ffmpeg-installer/ffmpeg` binary surfaced through `fluent-ffmpeg`. The migration adds OTel histograms/counters, structured `VideoThumbnailsError` (extends `SuaveplanError`) codes via `defineCodes`, Zod-validated option boundaries, `AbortSignal` propagation, a sliding-window extractor that upstream lacked, and 100/100/100/100 coverage using real fixture videos generated on-the-fly with ffmpeg (no mocks).
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| Single-frame extract `ThumbnailGenerator.generate` | ✅ | ✅ | Same `timestamp`, `fit`, `outputDir`, `format`, `quality` semantics. |
| Multi-timestamp extract `generateMultiple` | ✅ | ✅ | `AbortSignal`-aware. |
| `getMetadata` via ffprobe | ✅ | ✅ | Through `FfmpegRunner.probe`. |
| `isAvailable()` | ✅ | ✅ | Through `runner.isAvailable()`. |
| Sprite-sheet `SpriteSheetGenerator.generate` | ✅ | ✅ | Same N×M tile algorithm; auto-detect duration. |
| `CommandExecutor` injection | ✅ | ⬆️ | Replaced with the stronger `FfmpegRunner` interface (typed `extract`, `tile`, `probe`, `isAvailable`); default = `FluentFfmpegRunner`. |
| Error codes `THUMBNAIL_*` | ✅ | ⬆️ | Re-issued under `VIDEO_THUMBNAILS.*` namespace via `defineCodes`. Added: `RUNNER_UNAVAILABLE`, `INVALID_TIMESTAMP`, `INVALID_OPTIONS`, `ABORTED`. |
| Sliding-window extraction | 🚫 | ⬆️ | New `extractSlidingWindow(count, durationSeconds)` evenly samples N frames. |
| OTel counters + histograms | 🚫 | ⬆️ | `video_thumbnails.extract.duration_ms`, `extract.total{outcome,format}`, `sprite_sheet.duration_ms`, `sprite_sheet.frames`. |
| Zod option validation | 🚫 | ⬆️ | All public surfaces validate at the boundary. |
| `AbortSignal` propagation | 🚫 | ⬆️ | Threaded through `FfmpegRunner` (kills the child process). |
| Output jpeg / png / webp | partial | ✅ | All three exercised against real ffmpeg + fixtures. |
| 100/100/100/100 coverage | 🚫 | ✅ | Vitest istanbul, no mocks, real fixtures. |
## Current-state audit (genesis)
Package does not exist on `genesis`. Status: `–` in `STATUS.md`. Direct upstream consumers in genesis: none. The `video` plugin is added to the `media` super-category in `scripts/folder-reorg/assignment.yml` as part of this change (the closed-vocabulary supercategory list is unchanged).
## Dependencies
- Workspace: `@suaveplan/error`
- External (via catalog `audio`): `fluent-ffmpeg`, `@ffmpeg-installer/ffmpeg`, `@types/fluent-ffmpeg`
- External (other catalogs): `tslib` (`build`), `zod` (`forms`), `@opentelemetry/api` (`observability`)
- Dev: `@suaveplan/testing` (`workspace:^`), `vitest` + `@vitest/coverage-istanbul` (`testing`)
## Impact
- Adds package `packages/media/video/video-thumbnails`.
- Adds spec capability `video-thumbnails`.
- Adds `video` plugin to the `media` super-category in `scripts/folder-reorg/assignment.yml`.
### Affected dependents
None — fresh genesis migration, no existing consumers.

View File

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

View File

@@ -0,0 +1,62 @@
# Tasks: enterprise-video-thumbnails
## 1. Scaffold
- [x] 1.1 Create `packages/media/video/video-thumbnails/` skeleton: `package.json` at `0.1.0`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, polyfills setup.
- [x] 1.2 Add `video` plugin to `media` super-category in `scripts/folder-reorg/assignment.yml`.
## 2. Error codes + error class
- [x] 2.1 `src/error-codes/error-codes.ts` — `defineCodes("VIDEO_THUMBNAILS", [...])` for all nine codes.
- [x] 2.2 `src/errors/video-thumbnails-error.ts` — `VideoThumbnailsError extends SuaveplanError<VideoThumbnailsCode>`.
- [x] 2.3 Sibling `.md` for each.
## 3. Zod schemas
- [x] 3.1 `src/schemas/schemas.ts` — `ThumbnailConfigSchema`, `ThumbnailOptionsSchema`, `SpriteSheetConfigSchema`, `VideoMetadataSchema`, `ThumbnailResultSchema`, `SpriteSheetResultSchema`.
- [x] 3.2 Inferred types in `schemas.types.ts`.
## 4. Telemetry
- [x] 4.1 `src/telemetry/telemetry.ts` — `getTelemetry()` returns `{ extractDuration, extractTotal, spriteSheetDuration, spriteSheetFrames }` lazily via `metrics.getMeter`.
## 5. Runner
- [x] 5.1 `src/runner/runner.ts` — `FfmpegRunner` interface + `ExtractInput`/`TileInput`/`ProbeResult`.
- [x] 5.2 `src/runner/fluent-ffmpeg-runner.ts` — `FluentFfmpegRunner` default impl using `fluent-ffmpeg` + `@ffmpeg-installer/ffmpeg`; threads `AbortSignal` to `command.kill("SIGKILL")`.
## 6. ThumbnailGenerator
- [x] 6.1 `src/thumbnail-generator/thumbnail-generator.ts` — `generate`, `generateMultiple`, `extractSlidingWindow`, `getMetadata`, `isAvailable`.
- [x] 6.2 Boundary Zod validation; OTel emission; `AbortSignal` propagation.
## 7. SpriteSheetGenerator
- [x] 7.1 `src/sprite-sheet/sprite-sheet.ts` — N×M tile via `runner.tile`; auto-detect duration via `runner.probe`.
## 8. Barrel + subpath exports
- [x] 8.1 `src/index.ts` explicit named exports.
- [x] 8.2 `package.json` `exports` map covers `.`, `./errors`, `./schemas`, `./runner`, `./thumbnail-generator`, `./sprite-sheet`, `./telemetry`.
## 9. Real-fixture test suite
- [x] 9.1 `tests/setup/fixtures.ts` synthesises a 5-second 320×240 MP4 via ffmpeg `lavfi` once per worker; cached temp path.
- [x] 9.2 Tests exercise every public surface (extract, multiple, sliding-window, sprite-sheet, getMetadata, isAvailable, abort, validation errors, every error code).
## 10. Docs
- [x] 10.1 Sibling `.md` for every `.ts` (≥200 words, 6 sections).
- [x] 10.2 `README.md` at package root with modules table.
## 11. MANIFEST refresh
- [x] 11.1 `bun scripts/folder-reorg/generate-manifest.ts`.
## 12. Ship gate
- [x] 12.1 `bunx turbo lint typecheck test build --filter=@suaveplan/video-thumbnails` green.
- [x] 12.2 `bunx turbo test:coverage --filter=@suaveplan/video-thumbnails` 100/100/100/100.
- [x] 12.3 Fold `specs/video-thumbnails/spec.md` into `openspec/specs/video-thumbnails/spec.md`.
- [x] 12.4 Move change folder to `openspec/archive/2026-05-25-enterprise-video-thumbnails/`.
- [x] 12.5 Flip `STATUS.md` row to `✅`, update counters.