Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
# Design: enterprise-video
## Architecture
`@suaveplan/video` is a universal-tier metadata + transform-planning library.
It has no Node-only or browser-only imports in `src/` — every public surface
operates on `Uint8Array` inputs and plain JS objects so the same module ships
to Bun, Node, and browsers. Heavy codec work (real transcoding, frame
extraction, thumbnail render) is delegated to sibling packages
(`@suaveplan/video-thumbnails` ffmpeg renderer, `@suaveplan/react-video`
HTMLVideoElement-backed playback) that consume this package's planning surface.
## Module layout
```
packages/media/video/video/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
tests/setup/polyfills.ts
src/
index.ts # explicit named re-exports
error-codes/{error-codes.ts,error-codes.md,error-codes.test.ts,index.ts}
errors/{errors.ts,errors.md,errors.test.ts,index.ts}
types/video-types/{video-types.ts,video-types.md,video-types.test.ts,index.ts}
detection/{detection.ts,detection.md,detection.test.ts,index.ts}
resize/{resize.ts,resize.md,resize.test.ts,index.ts}
trim/{trim.ts,trim.md,trim.test.ts,index.ts}
duration/{duration.ts,duration.md,duration.test.ts,index.ts}
format/{format.ts,format.md,format.test.ts,index.ts}
instruments/{instruments.ts,instruments.md,index.ts}
processor/{processor.ts,processor.md,processor.test.ts,processor.spec-conformance.test.ts,index.ts}
pipeline-builder/{pipeline-builder.ts,pipeline-builder.md,pipeline-builder.test.ts,index.ts}
```
## Public surface (mirrors upstream)
- Error layer: `VideoError`, 15 factories, `VIDEO_ERROR_METADATA`,
`registerVideoErrorCodes`, `VideoCode` union, `VIDEO_CODES` registry.
- Types layer: every upstream type plus Zod schemas.
- Helpers: `detectVideoFormat`, `calculateVideoResize`, `validateTrimRange`,
`formatDuration`, `parseDuration`, `videoFormatToExtension`.
- Core: `VideoProcessor`, `VideoPipelineBuilder`.
- Constants: `RESOLUTION_PRESETS`, `VIDEO_MIME_TYPES`.
## Genesis-specific changes
1. **Error base**: `SuaveplanError<VideoCode>` (genesis foundation) rather
than the upstream `BaseError`. Public discriminants (`err.code`,
`err.message`, `err.cause`, `err.context`) are identical.
2. **Event bus**: composes `EventBus<TMap>` where `TMap = VideoEventMap` and
payloads are single values (no `[T]` tuples). The processor exposes
`on`/`once`/`off` so call sites read the same. Genesis pattern matches
`@suaveplan/image`.
3. **OTel**: meter `@suaveplan/video` with:
- counter `video.metadata.extracted_total{outcome}`
- histogram `video.metadata.extract_duration_ms{outcome}`
- counter `video.transform.planned_total{outcome}`
- histogram `video.transform.plan_duration_ms{outcome}`
- tracer spans `video.metadata.extract` + `video.transform.plan`.
4. **Validation**: Zod schemas at every public boundary
(`VideoProcessorConfigSchema`, `VideoTransformPipelineInputSchema`).
Invalid input throws `VideoError(VIDEO.CONFIG_INVALID)` /
`VideoError(VIDEO.INVALID_TRANSFORM)`.
5. **AbortSignal**: both `extractMetadata` and `planTransform` accept an
optional `AbortSignal` and short-circuit with
`VideoError(VIDEO.PROCESSING_FAILED, cause: AbortError)` when aborted.
6. **`pipeline.built` event**: declared upstream but never emitted; genesis
fires it from `VideoPipelineBuilder.build()` whenever the builder was
created via `VideoProcessor.createPipeline()` (so the builder has a bus
reference).
## Algorithm notes
### `detectVideoFormat`
Byte-for-byte port of the upstream sniffer (header must be ≥12 bytes):
- bytes 4-7 = `ftyp` ASCII → MP4 family; sub-brand bytes 8-11 starting `qt`
return `mov`, else `mp4`.
- bytes 0-3 = `1A 45 DF A3` → `mkv` (Matroska EBML).
- bytes 0-3 = `RIFF`, bytes 8-11 = `AVI ` → `avi`.
- bytes 0-2 = `FLV` → `flv`.
- bytes 0-3 = `OggS` → `ogg`.
- else `null`.
### `calculateVideoResize`
Identical to upstream:
- if `preset` set: use preset dimensions verbatim.
- else if `maintainAspectRatio !== false` and exactly one dimension set:
compute the other from `original.width / original.height`.
- else: use options as-is (or `original` when both omitted).
- finally: round both dims up to the next even number (`x % 2 === 0 ? x : x+1`)
because most codecs require even chroma sub-sampling alignment.
### `validateTrimRange`
`start ≥ 0 && end > start && end ≤ duration`.
### `formatDuration` / `parseDuration`
`HH:MM:SS` ↔ seconds. `parseDuration` accepts `SS`, `MM:SS`, or `HH:MM:SS`
and silently returns `0` for empty string.
### `VideoProcessor.extractMetadata`
1. AbortSignal pre-check.
2. Start OTel span + timer.
3. `detectVideoFormat(data)`; if `null` and no `knownMetadata.format`,
throw `unknownVideoFormat()` (outcome=`unknown_format`).
4. Compose `VideoMetadata` from `knownMetadata` + detected format,
defaulting numeric fields to 0 and `hasAudio` to `true`.
5. If `metadata.duration > maxDuration`, throw `durationExceeded(...)`
(outcome=`duration_exceeded`).
6. Emit `metadata.extracted`.
7. On any non-`VideoError` exception: wrap in `videoMetadataParseFailed(cause)`.
8. Finally: record counter + histogram + end span.
### `VideoProcessor.planTransform`
1. AbortSignal pre-check.
2. Start OTel span + timer.
3. Validate pipeline via `VideoTransformPipelineInputSchema`.
4. Walk operations in order, mutating local `(width, height, duration,
format, hasAudio)`:
- `resize` → `calculateVideoResize`.
- `trim` → `validateTrimRange` else `invalidTrimRange`; new duration.
- `rotate` → require `degrees % 90 === 0` else `invalidVideoTransform`;
swap dims when `degrees % 180 !== 0`.
- `flip` → no-op for planning.
- `speed` → require `factor > 0` else `invalidVideoTransform`;
`duration /= factor`.
- `mute` → `hasAudio = false`.
- `format` → set output format.
- `codec` / `bitrate` → no-op for planning (informational only).
5. Emit `transform.planned`.
6. Return `VideoTransformResult`.
## Error code registry
```ts
export const VIDEO_CODES = defineCodes("VIDEO", [
"UNKNOWN_FORMAT",
"INVALID_DIMENSIONS",
"INVALID_TRIM_RANGE",
"METADATA_PARSE_FAILED",
"INVALID_TRANSFORM",
"DURATION_EXCEEDED",
"UNSUPPORTED_CODEC",
"NOT_FOUND",
"DECODE_FAILED",
"ENCODE_FAILED",
"FORMAT_UNSUPPORTED",
"TOO_LARGE",
"PROCESSING_FAILED",
"CODEC_UNSUPPORTED",
"TIMEOUT",
"CONFIG_INVALID",
] as const);
```
`VIDEO_ERROR_METADATA` is a separately-exported `Record<VideoCode,
VideoCodeMetadata>` (httpStatus, severity, retryable, userMessageKey,
category, description) — used by HTTP error mappers and retry policies.
`registerVideoErrorCodes()` returns it (no-op for compatibility).
## File-size budget
Every implementation file targets ≤250 lines (300 hard). The processor splits
naturally into:
- `processor/processor.ts` (orchestration ~200 lines)
- `pipeline-builder/pipeline-builder.ts` (fluent builder ~110 lines)
- `instruments/instruments.ts` (~60 lines)
- helpers each ≤60 lines.
## Test strategy
- **Helper tests** (detection, resize, trim, duration, format) — table-driven
byte-pattern checks + boundary cases.
- **Errors tests** — every factory exercised; `VideoError` instance/code/context/
cause; `VIDEO_ERROR_METADATA` table shape.
- **Processor tests** — every branch of `extractMetadata` + `planTransform`
(including each `VideoTransformOp` variant + abort + zod-config invalid).
- **Pipeline builder tests** — every fluent method + `build()` + `apply()` +
`pipeline.built` emission when bound to a processor.
- **Spec conformance** — OTel counter/histogram/span observation + AbortSignal
propagation + event emission end-to-end via in-test MeterProvider /
TracerProvider.
100/100/100/100 istanbul coverage is mandatory.

View File

@@ -0,0 +1,111 @@
# Change: enterprise-video
- **Package**: `@suaveplan/video`
- **Status**: PROPOSED
- **Design**: ./design.md
- **Spec delta**: ./specs/video/spec.md
- **Layout**: `packages/media/video/video/`
- **Tier**: universal (no Node-only / browser-only APIs in `src/`)
- **Genesis deps**: `@suaveplan/error` shipped, `@suaveplan/events` shipped
## Why
`@suaveplan/video` is the canonical universal-tier video-metadata and
transform-planning capability on the suaveplan platform. It underpins the
pending `@suaveplan/video-thumbnails` (ffmpeg-backed thumbnail renderer) and
`@suaveplan/react-video` (browser playback) siblings, both of which read
`VideoMetadata`/`VideoTransformPipeline`/`VideoTransformResult` from this
package. Upstream ships 1517 LOC covering magic-byte format detection,
metadata extraction with optional caller-supplied probe data, resolution
presets, an evented `VideoProcessor` + fluent `VideoPipelineBuilder`, and a
15-factory typed error registry with HTTP/severity/retry metadata.
Genesis must own this capability before the siblings can land. The legacy
package uses the deprecated `BaseError` / `ErrorCodes.registerMany` registry
and the Node-style `EventEmitter<[T]>` array-tuple shape — genesis migrates
these to `SuaveplanError` + `defineCodes("VIDEO", …)` + `EventBus<TMap>` while
preserving the full public surface and adding the enterprise quality bar the
audit calls for: Zod boundary validation, OTel counters + histograms + spans,
`AbortSignal` propagation, schema-validated config, deterministic clocks,
100/100/100/100 coverage, and tier-pure imports.
## What changes
1. New package `packages/media/video/video/` at `@suaveplan/video@0.1.0`.
2. `assignment.yml` `__categories__:` row updated: `media` now includes the
`video` plugin folder (alongside `audio`/`image`/`fonts`/etc.).
3. Full upstream public surface preserved — every type, every constant, every
error factory, every method on `VideoProcessor` / `VideoPipelineBuilder`.
4. `VideoError` migrates from `BaseError<Code, Context>` to a
`SuaveplanError<VideoCode>` subclass that retains a typed `context` field;
codes move from `ErrorCodes.registerMany` to `defineCodes("VIDEO", […])`.
5. `VideoProcessor` migrates from Node-style `EventEmitter<VideoEventMap>`
(array-tuple payloads) to composing `EventBus<VideoEventMap>` (single
payload per topic, matching `@suaveplan/image`'s convention) and exposes
`on` / `once` / `off`.
6. Enterprise enhancements: Zod-validated config + pipeline boundaries, OTel
meter `@suaveplan/video` exposing 2 counters + 2 histograms + named spans,
`AbortSignal` on every public entrypoint that can do work, injectable
`Clock`.
7. Co-located `.md` docs per implementation file (≥200 words, 6 required
sections), package-root `README.md` with a Modules index.
8. Subpath exports per feature module so consumers can tree-shake.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `VideoFormat` / `VideoCodec` / `AudioCodec` / `ResolutionPreset` enums | x | x | Re-expressed as Zod enums + inferred types. |
| `VideoDimensions` / `VideoMetadata` / `TrimRange` / `VideoResizeOptions` | x | x | Mirrored with Zod schemas (`VideoMetadataSchema` etc.). |
| `VideoTransformOp` discriminated union (9 variants) | x | x | Migrated to `z.discriminatedUnion` for boundary validation. |
| `VideoTransformPipeline` / `VideoTransformResult` | x | x | Schemas + inferred types. |
| `VideoEventMap` | x | up | Re-shaped from `[T]` tuples to single-payload `T` to match `EventBus<TMap>`. Semantic `metadata.extracted` / `transform.planned` / `pipeline.built` keys preserved. |
| `detectVideoFormat` (MP4/MOV/MKV/AVI/FLV/OGG magic bytes) | x | x | Byte-for-byte identical sniffer. |
| `calculateVideoResize` (preset + maintainAspectRatio + even-dimensions) | x | x | Identical algorithm; Zod-validated input. |
| `validateTrimRange` | x | x | Identical. |
| `formatDuration` / `parseDuration` round-trip | x | x | Identical. |
| `videoFormatToExtension` | x | x | Identical. |
| `RESOLUTION_PRESETS` (6 presets) | x | x | Identical. |
| `VIDEO_MIME_TYPES` (8 formats) | x | x | Identical. |
| `VideoError` typed error | x | up | Migrated to `SuaveplanError<VideoCode>`; codes namespaced via `defineCodes("VIDEO", …)`. |
| 15 error-factory functions | x | x | All preserved: `unknownVideoFormat`, `invalidVideoDimensions`, `invalidTrimRange`, `videoMetadataParseFailed`, `invalidVideoTransform`, `durationExceeded`, `unsupportedCodec`, `videoNotFound`, `videoDecodeFailed`, `videoEncodeFailed`, `videoFormatUnsupported`, `videoTooLarge`, `videoProcessingFailed`, `videoCodecUnsupported`, `videoTimeout`. |
| `VIDEO_ERROR_METADATA` HTTP/severity/retry table | x | x | Preserved as a typed const for problem+json / retry consumers. |
| `registerVideoErrorCodes` callable | x | up | Kept as a callable; genesis `defineCodes` is module-local so the function is a no-op alias returning the metadata table — same name, same shape, callers keep compiling. |
| `VideoProcessor.extractMetadata` | x | up | Adds `AbortSignal`, OTel counter + histogram + span, Zod-validated config, deterministic clock. |
| `VideoProcessor.planTransform` (9 op types) | x | up | Adds `AbortSignal`, Zod-validated pipeline, OTel counter + histogram + span. |
| `VideoProcessor.createPipeline()` | x | x | Identical. |
| `VideoPipelineBuilder` (resize/trim/rotate/flip/speed/mute/toFormat/codec/bitrate/build/apply) | x | x | Identical fluent surface. |
| `VideoProcessorConfig` (`maxDuration`, `defaultFormat`) | x | up | Zod-validated; configs that fail validation throw `VideoError(VIDEO.CONFIG_INVALID)`. |
| `pipeline.built` event | x | up | Now actually emitted from `VideoPipelineBuilder.build()` (upstream declared but never fired). |
| Side-effect `import "./error-codes.js"` registration | x | down | Genesis `defineCodes` is module-local and side-effect-free; equivalent metadata is exposed via `VIDEO_ERROR_METADATA` re-export and `registerVideoErrorCodes()` is retained as a no-op alias. |
The single `down` row (the side-effect import) is structurally replaced by
the `defineCodes` model — same callers, same outcome, no functional drop.
## Impact
### Affected dependents
`bunx turbo ls --filter=...@suaveplan/video` — package is brand-new on genesis,
so no genesis consumers exist yet. The future `@suaveplan/video-thumbnails`
(ffmpeg renderer) and `@suaveplan/react-video` (browser playback) packages
will consume this; both are still `-` (pending) in `STATUS.md` and will be
migrated in follow-up changes against this stable interface.
No dependents (verified by `bunx turbo ls --filter=...@suaveplan/video`).
## Migration
For consumers porting from the upstream `@suaveplan/video` shape:
- **VideoError**: same import path, same `err.code` string values, same
`err.message`, `err.cause`, `err.context`. Base class moved from
`BaseError` to `SuaveplanError`; `instanceof VideoError` keeps working.
- **Event listeners**: upstream listeners on Node `EventEmitter` received the
payload as the first element of a tuple (`(meta) => …` since `args[0]`
destructures the tuple); under `EventBus` the listener receives the payload
directly. Idiomatic call sites are unchanged.
- **`registerVideoErrorCodes()`**: still callable, still side-effect-safe;
registration is now driven by `defineCodes` at module-load time.
- Search command for upstream consumers (none on genesis today):
`grep -rln "@suaveplan/video" packages/ | grep -v "/dist/\|/coverage/"`.

View 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"`.

View File

@@ -0,0 +1,76 @@
# Tasks: enterprise-video
**Change ID**: enterprise-video
**Package**: `@suaveplan/video`
**Layout**: `packages/media/video/video/`
## 0. Pre-flight
- [x] Confirm `@suaveplan/error` + `@suaveplan/events` are shipped on `genesis` via `workspace:^`.
- [x] Add `video` to the `media` super-category in `scripts/folder-reorg/assignment.yml` `__categories__:` row.
## 1. Scaffold
- [x] Create `packages/media/video/video/{src,tests/setup}` with feature directories.
- [x] `package.json` at `0.1.0`, `type: module`, `sideEffects: false`.
- [x] Subpath exports per feature: `./error-codes/error-codes`, `./errors/errors`, `./types/video-types`, `./processor/processor`, `./pipeline-builder/pipeline-builder`, `./detection/detection`, `./duration/duration`, `./resize/resize`, `./trim/trim`, `./format/format`, `./instruments/instruments`.
- [x] `tsconfig.lib.json` mirroring `@suaveplan/image`.
- [x] `vite.config.ts` mirroring `@suaveplan/image`.
- [x] `vitest.config.ts` mirroring `@suaveplan/image` (forks pool, istanbul 100/100/100/100, polyfills setup).
- [x] `tests/setup/polyfills.ts` re-exporting `@suaveplan/testing/polyfills`.
## 2. Error codes + typed error
- [x] `src/error-codes/error-codes.ts` — `defineCodes("VIDEO", […])` with 16 codes covering every upstream factory plus `CONFIG_INVALID`.
- [x] `VIDEO_ERROR_METADATA` — preserved HTTP/severity/retry table.
- [x] `registerVideoErrorCodes()` — callable alias returning the metadata table.
- [x] `src/errors/errors.ts` — `VideoError extends SuaveplanError<VideoCode>` with typed `context` + 15 upstream factories.
- [x] Co-located `.md` for both modules.
## 3. Types + schemas
- [x] `src/types/video-types/video-types.ts` — Zod schemas for every public type; inferred types; `RESOLUTION_PRESETS`; `VIDEO_MIME_TYPES`; `VideoEventMap`.
- [x] Discriminated union `VideoTransformOpSchema` (9 variants).
## 4. Pure helpers
- [x] `src/detection/detection.ts` — `detectVideoFormat`.
- [x] `src/resize/resize.ts` — `calculateVideoResize`.
- [x] `src/trim/trim.ts` — `validateTrimRange`.
- [x] `src/duration/duration.ts` — `formatDuration` + `parseDuration`.
- [x] `src/format/format.ts` — `videoFormatToExtension`.
- [x] Co-located `.md` per module.
## 5. Processor + builder
- [x] `src/instruments/instruments.ts` — meter, 2 counters + 2 histograms, tracer.
- [x] `src/processor/processor.ts` — `VideoProcessor` composing `EventBus<VideoEventMap>`; `extractMetadata` + `planTransform` + `createPipeline` + `on`/`once`/`off`; AbortSignal + OTel + Zod config validation.
- [x] `src/pipeline-builder/pipeline-builder.ts` — `VideoPipelineBuilder` fluent surface; `build()` emits `pipeline.built` when wired.
## 6. Tests
- [x] Per-feature `.test.ts` files; all import from `@suaveplan/testing/runner`; every `it(...)` carries `{ timeout: 5000 }`.
- [x] `processor.spec-conformance.test.ts` validates OTel + AbortSignal + event-emission contracts end-to-end.
## 7. Barrel + README
- [x] `src/index.ts` with explicit named re-exports only.
- [x] Package `README.md` with Modules index.
## 8. Pre-publish gate
- [x] `bunx turbo lint typecheck test build --filter=@suaveplan/video` green.
- [x] `bunx turbo test:coverage --filter=@suaveplan/video` at 100/100/100/100.
- [x] Banned-pattern scan clean.
- [x] Subpath-export count matches feature-module count.
## 9. Fold + archive
- [x] Fold `specs/video/spec.md` into `openspec/specs/video/spec.md`.
- [x] Move `openspec/changes/enterprise-video/` to `openspec/archive/2026-05-25-enterprise-video/`.
## 10. STATUS + MANIFEST refresh
- [x] Flip `@suaveplan/video` from `-` to migrated in `STATUS.md`.
- [x] Update migrated/pending counter.
- [x] Run `bun scripts/folder-reorg/generate-manifest.ts`.