8.5 KiB
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
outputPathexists on disk, hassizeBytes > 0, andformat === "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
outputPathexists 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
ThumbnailResultare 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/ffmpegbinary - 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,outputPathexists 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 theAbortControlleris aborted immediately - THEN the promise rejects with a
VideoThumbnailsErrorwhosecode === "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
ThumbnailGeneratoris 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 onethrow 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 OTelInMemoryMetricExporter - THEN the exported metrics contain a
video_thumbnails.extract.totalcounter increment withoutcome="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
FfmpegRunnerinterface andFluentFfmpegRunnerclass are importable
Verified by: src/index.test.ts::"feature subpaths resolve"