Initial Commit
This commit is contained in:
375
openspec/archive/2026-05-25-enterprise-video/specs/video/spec.md
Normal file
375
openspec/archive/2026-05-25-enterprise-video/specs/video/spec.md
Normal file
@@ -0,0 +1,375 @@
|
||||
<!-- 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"`.
|
||||
Reference in New Issue
Block a user