# 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` 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 `"."` 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, 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"`.