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

5.5 KiB
Raw Blame History

Design — enterprise-video-thumbnails

Non-normative. Architecture, file layout, rationale.

Architecture

The package is split into four feature modules under src/:

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

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.