Initial Commit
This commit is contained in:
192
openspec/archive/2026-05-25-enterprise-video/design.md
Normal file
192
openspec/archive/2026-05-25-enterprise-video/design.md
Normal 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.
|
||||
111
openspec/archive/2026-05-25-enterprise-video/proposal.md
Normal file
111
openspec/archive/2026-05-25-enterprise-video/proposal.md
Normal 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/"`.
|
||||
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"`.
|
||||
76
openspec/archive/2026-05-25-enterprise-video/tasks.md
Normal file
76
openspec/archive/2026-05-25-enterprise-video/tasks.md
Normal 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`.
|
||||
Reference in New Issue
Block a user