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

8.5 KiB
Raw Permalink Blame History

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"