Files
suaveplan/openspec/specs/video/spec.md
2026-05-27 23:29:53 +01:00

376 lines
14 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: video (video)
**Package**: `@suaveplan/video`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Universal-tier package layout
The package SHALL ship at `packages/media/video/video/` under the `media`
super-category with the `video` plugin folder, at version `0.1.0`,
`type: module`, `sideEffects: false`, and feature-module subpath exports per
the §6 layout rule.
#### Scenario: Subpath exports are tree-shakeable
- **WHEN** a consumer imports `@suaveplan/video/detection/detection`
- **THEN** only the `detectVideoFormat` module entry is loaded — root `index`
is not pulled in
**Verified by:** `packages/media/video/video/package.json` exports map AND
`bunx turbo build --filter=@suaveplan/video` produces one `.js` per feature
under `dist/`.
---
### Requirement: Error code registry
The package SHALL expose a stable `VIDEO_CODES` registry minted via
`defineCodes("VIDEO", [...])` covering every upstream failure mode plus
`CONFIG_INVALID`. A typed `VIDEO_ERROR_METADATA` table SHALL map each code to
its `httpStatus`, `severity`, `retryable`, `userMessageKey`, `category`, and
`description`. `registerVideoErrorCodes()` SHALL remain callable and return
the metadata table.
#### Scenario: Every registry entry has metadata
- **WHEN** a consumer iterates `VIDEO_CODES` keys
- **THEN** each key has a corresponding entry in `VIDEO_ERROR_METADATA` with
all six metadata fields populated
#### Scenario: Codes are namespaced
- **WHEN** a consumer reads `VIDEO_CODES.DECODE_FAILED`
- **THEN** the value is `"VIDEO.DECODE_FAILED"` (genesis `defineCodes`
namespacing convention)
**Verified by:**
`packages/media/video/video/src/error-codes/error-codes.test.ts::"VIDEO_CODES
covers every upstream failure mode"` AND `packages/media/video/video/src/error-codes/error-codes.test.ts::"VIDEO_ERROR_METADATA mirrors VIDEO_CODES"`.
---
### Requirement: Typed VideoError
The package SHALL expose `VideoError extends SuaveplanError<VideoCode>` with
an optional `context?: VideoErrorContext` field carrying upstream-compatible
hints (`format`, `codec`, `duration`, `width`, `height`, `fileSize`,
`maxSize`, `operationMs`). The 15 upstream factory functions
(`unknownVideoFormat`, `invalidVideoDimensions`, `invalidTrimRange`,
`videoMetadataParseFailed`, `invalidVideoTransform`, `durationExceeded`,
`unsupportedCodec`, `videoNotFound`, `videoDecodeFailed`, `videoEncodeFailed`,
`videoFormatUnsupported`, `videoTooLarge`, `videoProcessingFailed`,
`videoCodecUnsupported`, `videoTimeout`) SHALL be exported with matching
signatures.
#### Scenario: Error carries code, message, cause, context
- **WHEN** `videoMetadataParseFailed(new Error("boom"))` is called
- **THEN** the returned `VideoError` has `code === "VIDEO.METADATA_PARSE_FAILED"`,
`cause` is the wrapped Error, `context` is defined (possibly empty), and
`message` starts with "Failed to parse video metadata"
**Verified by:**
`packages/media/video/video/src/errors/errors.test.ts::"VideoError carries
code, message, cause, and context"` AND `packages/media/video/video/src/errors/errors.test.ts::"every upstream factory is exported with matching shape"`.
---
### Requirement: Format detection from magic bytes
`detectVideoFormat(header: Uint8Array)` SHALL return one of `"mp4"`, `"mov"`,
`"mkv"`, `"avi"`, `"flv"`, `"ogg"` when the header matches the corresponding
magic-byte pattern, and `null` when no pattern matches or the buffer is
shorter than 12 bytes.
#### Scenario: MP4 ftyp branch
- **WHEN** `detectVideoFormat` receives bytes with `ftyp` at offset 4 and
`isom` brand at offset 8
- **THEN** the return value is `"mp4"`
#### Scenario: MOV qt brand branch
- **WHEN** the header carries `ftyp` at offset 4 and brand `qt ` at offset 8
- **THEN** the return value is `"mov"`
#### Scenario: Short buffer
- **WHEN** the header is fewer than 12 bytes
- **THEN** the return value is `null`
**Verified by:**
`packages/media/video/video/src/detection/detection.test.ts::"detects MP4
(ftyp)"`, `packages/media/video/video/src/detection/detection.test.ts::"detects MOV (qt brand)"`, `packages/media/video/video/src/detection/detection.test.ts::"returns null for short header"`.
---
### Requirement: Resolution math
`calculateVideoResize(original, options)` SHALL honor `preset` dimensions
verbatim, otherwise interpret `width`/`height` against
`maintainAspectRatio` (default `true`), and SHALL always round both output
dimensions up to the next even number.
#### Scenario: Preset uses canonical dimensions
- **WHEN** `calculateVideoResize({width: 1920, height: 1080}, { preset:
"720p" })` runs
- **THEN** the result is `{ width: 1280, height: 720 }`
#### Scenario: Even-dimension rounding
- **WHEN** the computed width is odd (e.g. caller asks for `width: 641`)
- **THEN** the returned width is `width + 1` so both dimensions are even
**Verified by:**
`packages/media/video/video/src/resize/resize.test.ts::"uses preset
dimensions"` AND `packages/media/video/video/src/resize/resize.test.ts::"ensures even dimensions"`.
---
### Requirement: Trim range validation
`validateTrimRange(duration, range)` SHALL return `true` iff `range.startTime
>= 0`, `range.endTime > range.startTime`, and `range.endTime <= duration`.
#### Scenario: Negative start
- **WHEN** the range has `startTime: -1`
- **THEN** the return value is `false`
#### Scenario: End beyond duration
- **WHEN** the range has `endTime > duration`
- **THEN** the return value is `false`
**Verified by:**
`packages/media/video/video/src/trim/trim.test.ts::"rejects negative start"`
AND `packages/media/video/video/src/trim/trim.test.ts::"rejects end beyond duration"`.
---
### Requirement: Duration round-trip
`formatDuration(seconds)` SHALL render `HH:MM:SS` with zero-padded fields and
`parseDuration` SHALL accept `SS`, `MM:SS`, or `HH:MM:SS` and produce a
seconds total. The two functions SHALL be exact inverses on integer-second
inputs.
#### Scenario: Round-trip
- **WHEN** `parseDuration(formatDuration(3661))` is evaluated
- **THEN** the result is `3661`
**Verified by:**
`packages/media/video/video/src/duration/duration.test.ts::"round-trips with
formatDuration"`.
---
### Requirement: Format-to-extension mapping
`videoFormatToExtension(format)` SHALL return `".<format>"` for every
member of `VideoFormat`.
#### Scenario: mp4 maps to .mp4
- **WHEN** `videoFormatToExtension("mp4")` is called
- **THEN** the return value is `".mp4"`
**Verified by:**
`packages/media/video/video/src/format/format.test.ts::"mp4 -> .mp4"`.
---
### Requirement: Public Zod schemas
The package SHALL expose Zod schemas for every public type
(`VideoFormatSchema`, `VideoCodecSchema`, `AudioCodecSchema`,
`ResolutionPresetSchema`, `VideoDimensionsSchema`, `VideoMetadataSchema`,
`TrimRangeSchema`, `VideoResizeOptionsSchema`, `VideoTransformOpSchema`
(discriminated union over 9 op types), `VideoTransformPipelineSchema`,
`VideoTransformResultSchema`, `VideoProcessorConfigSchema`).
`VideoTransformOpSchema` SHALL accept and reject by `type` discriminant.
#### Scenario: Discriminated union rejects unknown op type
- **WHEN** `VideoTransformOpSchema.safeParse({ type: "explode" })` runs
- **THEN** `result.success === false`
#### Scenario: Resize op accepts preset
- **WHEN** `VideoTransformOpSchema.parse({ type: "resize", options: { preset:
"1080p" } })` runs
- **THEN** the parse succeeds and the typed result is the same shape
**Verified by:**
`packages/media/video/video/src/types/video-types/video-types.test.ts::"VideoTransformOpSchema rejects unknown discriminant"` AND `packages/media/video/video/src/types/video-types/video-types.test.ts::"VideoTransformOpSchema parses every variant"`.
---
### Requirement: Constants
`RESOLUTION_PRESETS` SHALL expose six presets (`360p`, `480p`, `720p`,
`1080p`, `1440p`, `4k`) each with `width`/`height` rounded to canonical
dimensions. `VIDEO_MIME_TYPES` SHALL expose a MIME string for each of the
eight `VideoFormat` members.
#### Scenario: 4k is 3840x2160
- **WHEN** `RESOLUTION_PRESETS["4k"]` is read
- **THEN** the entry is `{ width: 3840, height: 2160 }`
**Verified by:**
`packages/media/video/video/src/types/video-types/video-types.test.ts::"4k is 3840x2160"`.
---
### Requirement: VideoProcessor.extractMetadata
`VideoProcessor.extractMetadata(data: Uint8Array, knownMetadata?:
Partial<VideoMetadata>, signal?: AbortSignal)` SHALL detect the format from
header bytes, merge caller-supplied known fields, default `hasAudio` to
`true`, default missing numeric fields to `0`, record `fileSize` from
`data.byteLength`, throw `unknownVideoFormat()` when no format can be
determined, throw `durationExceeded(...)` when the merged duration exceeds
the configured `maxDuration`, throw `videoMetadataParseFailed(cause)` on any
other unexpected error, emit `metadata.extracted`, and short-circuit with
`VideoError(VIDEO.PROCESSING_FAILED, cause: AbortError)` when `signal` is
already aborted.
#### Scenario: Format detected from MP4 header
- **WHEN** `extractMetadata` runs against an MP4 `ftyp/isom` header
- **THEN** the returned `metadata.format` is `"mp4"`
#### Scenario: Duration exceeded
- **WHEN** `new VideoProcessor({ maxDuration: 60 }).extractMetadata(header, {
duration: 120 })`
- **THEN** a `VideoError` with code `VIDEO.DURATION_EXCEEDED` is thrown
#### Scenario: Aborted signal short-circuits
- **WHEN** an already-aborted `AbortSignal` is passed
- **THEN** a `VideoError` with code `VIDEO.PROCESSING_FAILED` and an
`AbortError` cause is thrown before any work runs
**Verified by:**
`packages/media/video/video/src/processor/processor.test.ts::"extracts from
MP4 header"`, `packages/media/video/video/src/processor/processor.test.ts::"throws for duration exceeding max"`, AND `packages/media/video/video/src/processor/processor.spec-conformance.test.ts::"aborts extractMetadata before work"`.
---
### Requirement: VideoProcessor.planTransform
`planTransform(metadata, pipeline, signal?)` SHALL validate the pipeline via
the Zod `VideoTransformPipelineInputSchema`, walk the operations in order
applying the canonical algorithm (resize/trim/rotate/flip/speed/mute/format
/codec/bitrate), throw `invalidTrimRange` for invalid trim, throw
`invalidVideoTransform` for non-90-degree rotation or non-positive speed,
emit `transform.planned`, and short-circuit on an aborted `signal`.
#### Scenario: Resize op applies preset
- **WHEN** `planTransform(meta, { operations: [{ type: "resize", options:
{ preset: "720p" } }] })`
- **THEN** the result has `width: 1280` and `height: 720`
#### Scenario: Non-90 rotation rejected
- **WHEN** `planTransform(meta, { operations: [{ type: "rotate", degrees: 45 }] })`
- **THEN** `VideoError(VIDEO.INVALID_TRANSFORM)` is thrown
#### Scenario: Speed mutates duration
- **WHEN** speed `factor: 2` is applied to a 120s metadata
- **THEN** the result has `duration: 60`
**Verified by:**
`packages/media/video/video/src/processor/processor.test.ts::"applies
resize"`, `packages/media/video/video/src/processor/processor.test.ts::"throws for non-90 rotation"`, AND `packages/media/video/video/src/processor/processor.test.ts::"applies speed factor"`.
---
### Requirement: VideoPipelineBuilder fluent surface
`VideoPipelineBuilder` SHALL provide chainable `resize`, `trim`, `rotate`,
`flip`, `speed`, `mute`, `toFormat`, `codec`, `bitrate` methods, each
returning `this`. `build()` SHALL return a frozen-shape
`VideoTransformPipeline` carrying every appended operation. `apply(metadata,
processor)` SHALL run the built pipeline through `processor.planTransform`.
#### Scenario: Chained builder retains every op
- **WHEN** a builder calls `resize().trim().toFormat()` then `build()`
- **THEN** the resulting `pipeline.operations` has length 3 and
`pipeline.outputFormat` is the chosen format
**Verified by:**
`packages/media/video/video/src/pipeline-builder/pipeline-builder.test.ts::"build returns pipeline"`.
---
### Requirement: pipeline.built emission
A `VideoPipelineBuilder` created via `processor.createPipeline()` SHALL emit
`pipeline.built` on the processor's bus whenever `build()` is called.
#### Scenario: Build event fires
- **WHEN** `processor.createPipeline().resize({preset:"720p"}).build()` is
called and a listener is attached to `pipeline.built`
- **THEN** the listener receives the built pipeline
**Verified by:**
`packages/media/video/video/src/pipeline-builder/pipeline-builder.test.ts::"emits pipeline.built when bound to a processor"`.
---
### Requirement: OpenTelemetry instrumentation
The processor SHALL record exactly two counters
(`video.metadata.extracted_total`, `video.transform.planned_total`) and two
histograms (`video.metadata.extract_duration_ms`,
`video.transform.plan_duration_ms`) on every public-method invocation
(success and failure), tagged with an `outcome` attribute, and SHALL start
named spans `video.metadata.extract` and `video.transform.plan` that are
ended in a `finally` block.
#### Scenario: Counter increments on success
- **WHEN** `extractMetadata` succeeds against a registered MeterProvider
- **THEN** an entry with name `video.metadata.extracted_total`, value `1`,
and attribute `outcome: "success"` is recorded
#### Scenario: Counter increments on failure
- **WHEN** `extractMetadata` throws `unknownVideoFormat()`
- **THEN** the counter records an entry with `outcome: "unknown_format"`
**Verified by:**
`packages/media/video/video/src/processor/processor.spec-conformance.test.ts::"increments extracted counter on success"` AND `packages/media/video/video/src/processor/processor.spec-conformance.test.ts::"records failure outcome on unknown_format"`.
---
### Requirement: Zod-validated config
`new VideoProcessor(config)` SHALL run `config` through
`VideoProcessorConfigSchema.safeParse`; on failure SHALL throw
`VideoError(VIDEO.CONFIG_INVALID)` carrying the Zod error message in the
`message`.
#### Scenario: Invalid config rejected
- **WHEN** `new VideoProcessor({ maxDuration: -1 })`
- **THEN** a `VideoError` with code `VIDEO.CONFIG_INVALID` is thrown
**Verified by:**
`packages/media/video/video/src/processor/processor.test.ts::"throws CONFIG_INVALID for invalid config"`.