@stacksjs/video 0.70.163

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2023 Open Web Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @stacksjs/video
2
+
3
+ Deterministic, capability-aware video planning for Stacks. Unsupported codec operations fail early instead of being mislabeled as remuxing.
4
+
5
+ ## Inspect, plan, and process
6
+
7
+ ```ts
8
+ import { detectVideoRuntimeCapabilities } from 'ts-videos'
9
+ import { video } from '@stacksjs/video'
10
+
11
+ const delivery = await video('uploads/demo.mp4')
12
+ .profile({
13
+ width: 1920,
14
+ height: 1080,
15
+ duration: 92,
16
+ frameRate: 30,
17
+ container: 'mp4',
18
+ videoCodec: 'h264',
19
+ audioCodec: 'aac',
20
+ })
21
+ .ladder('auto')
22
+ .output(['mp4', 'webm'])
23
+ .streaming(['hls', 'dash'])
24
+ .runtime(await detectVideoRuntimeCapabilities())
25
+ .process({ signal: request.signal })
26
+ ```
27
+
28
+ `.generate()` returns the deterministic plan without processing. `.process()` executes its container and rendition matrix through native WebCodecs and the built-in MP4 or WebM muxers. The same call returns progressive files, CMAF segments, HLS and DASH manifests, poster art, preview sprites, and preview WebVTT when the native preview runtime is available. Compatible packets are copied, real codec changes are encoded, scaling never upscales, metadata is preserved, and work runs in bounded batches.
29
+
30
+ ```ts
31
+ await storage.putMany(delivery.files)
32
+ ```
33
+
34
+ ## HLS, DASH, and protected delivery
35
+
36
+ ```ts
37
+ import { createAdaptiveDeliveryBundle } from 'ts-videos/protected-delivery'
38
+
39
+ const bundle = await createAdaptiveDeliveryBundle(plan, segmentedRenditions, {
40
+ hlsAes128: {
41
+ key,
42
+ keyUri: '/media/keys/demo',
43
+ },
44
+ drm: {
45
+ descriptors: [{ system: 'widevine', keyId, licenseUrl }],
46
+ dashSegmentsEncrypted: true,
47
+ },
48
+ })
49
+ ```
50
+
51
+ HLS AES-128 is performed with Web Crypto. Widevine, PlayReady, FairPlay, and ClearKey descriptors require already encrypted CENC or SAMPLE-AES media. Clear segments cannot be labeled as proprietary DRM content.
52
+
53
+ For hosted delivery, publish immutable segments to S3 and use the media helpers from `ts-cloud`:
54
+
55
+ ```ts
56
+ import { buildMediaCdnPlan, signCloudFrontCookies } from 'ts-cloud'
57
+
58
+ const cdn = buildMediaCdnPlan({
59
+ bucket: 'example-media',
60
+ region: 'us-west-2',
61
+ domain: 'media.example.com',
62
+ protected: true,
63
+ })
64
+ const cookies = signCloudFrontCookies({ resource: 'https://media.example.com/demo/*', expires }, signer)
65
+ ```
66
+
67
+ Manifests remain short-lived or private, while content-addressed segments use immutable caching. Key routes must enforce the same application authorization or signed audience boundary.
68
+
69
+ ## Default STX player
70
+
71
+ ```stx
72
+ <Video
73
+ src="/media/demo/manifest.mpd"
74
+ title="Product demonstration"
75
+ poster="/media/demo/poster.avif"
76
+ :sources="progressiveFallbacks"
77
+ :tracks="captionAndChapterTracks"
78
+ :drm="drm"
79
+ />
80
+ ```
81
+
82
+ The default UI includes play, seek, live edge, time, mute, volume, speed, quality, captions, audio tracks, AirPlay, Remote Playback, Picture in Picture, fullscreen, and accessible errors. Native video controls remain as the no-JavaScript fallback.
@@ -0,0 +1,36 @@
1
+ export declare function deriveVideoLadder(profile: VideoProfile, maximum?: number): VideoRendition[];
2
+ export declare function video(source: string): VideoBuilder;
3
+ export declare function processVideoPlan(plan: VideoPlan, options?: VideoProcessOptions): Promise<ProcessedVideoDelivery>;
4
+ export declare function assertVideoPlanExecutable(plan: VideoPlan): void;
5
+ export declare function createHlsMaster(plan: VideoPlan, uri: (rendition: VideoRendition) => string): string;
6
+ export declare function createPreviewVtt(cues: readonly PreviewCue[]): string;
7
+ export declare function videoResponseHeaders(bytes: number, etag: string, contentType: string): Record<string, string>;
8
+ export declare function signVideoAsset(path: string, expires: number, secret: string): string;
9
+ export declare function verifyVideoAsset(path: string, expires: number, signature: string, secret: string, now?: number): boolean;
10
+ export declare function createProtectedVideoPlaylist(segments: VideoSegment[], protection?: VideoHlsProtection): Promise<ProtectedVideoPlaylist>;
11
+ export declare function videoAssetHeaders(path: string, bytes?: number, etag?: string, protectedMedia?: boolean): Record<string, string>;
12
+ export declare interface VideoProfile { width: number, height: number, duration: number, frameRate: number, container: VideoContainer, videoCodec: VideoCodec, audioCodec?: VideoAudioCodec, videoBitrate?: number, hasAudio?: boolean, hdr?: boolean }
13
+ export declare interface VideoRendition { name: string, width: number, height: number, frameRate: number, videoBitrate: number, audioBitrate: number }
14
+ export declare interface VideoCapabilities { videoEncoder: boolean, audioEncoder: boolean, videoCodecs: string[], audioCodecs: string[] }
15
+ export declare interface VideoOutput { container: VideoContainer, videoCodec: VideoCodec, audioCodec?: VideoAudioCodec, action: 'copy' | 'transcode', available: boolean, reason?: string }
16
+ export declare interface VideoPlan { source: string, profile: VideoProfile, renditions: VideoRendition[], outputs: VideoOutput[], streaming: StreamingFormat[], segmentDuration: number, keyframeInterval: number }
17
+ export declare interface PreviewCue { startTime: number, endTime: number, uri: string, x?: number, y?: number, width?: number, height?: number }
18
+ export declare interface VideoSegment { uri: string, duration: number, data: Uint8Array }
19
+ export declare interface VideoHlsProtection { key: Uint8Array, keyUri: string, iv?: (_index: number) => Uint8Array }
20
+ export declare interface ProtectedVideoPlaylist { playlist: string, files: Record<string, Uint8Array>, encrypted: boolean }
21
+ export type VideoContainer = 'mp4' | 'webm';
22
+ export type StreamingFormat = 'hls' | 'dash';
23
+ export type VideoCodec = 'h264' | 'h265' | 'vp8' | 'vp9' | 'av1' | 'mpeg1' | 'mpeg2' | 'mpeg4' | 'theora' | 'mjpeg' | 'prores' | 'dnxhd' | 'unknown';
24
+ export type VideoAudioCodec = 'aac' | 'mp3' | 'opus' | 'vorbis' | 'flac' | 'alac' | 'ac3' | 'eac3' | 'dts' | 'pcm_s16le' | 'pcm_s16be' | 'pcm_s24le' | 'pcm_s24be' | 'pcm_s32le' | 'pcm_s32be' | 'pcm_f32le' | 'pcm_f32be' | 'pcm_f64le' | 'pcm_f64be' | 'pcm_mulaw' | 'pcm_alaw' | 'unknown';
25
+ export type VideoProcessOptions = import('ts-videos/delivery-pipeline').VideoDeliveryPipelineOptions;
26
+ export type ProcessedVideoDelivery = import('ts-videos/delivery-pipeline').VideoDeliveryPipelineResult;
27
+ export declare class VideoBuilder {
28
+ constructor(source: string);
29
+ profile(value: VideoProfile): this;
30
+ ladder(value: 'auto' | number): this;
31
+ output(formats: VideoContainer[]): this;
32
+ streaming(formats: StreamingFormat[]): this;
33
+ runtime(value: VideoCapabilities): this;
34
+ generate(): VideoPlan;
35
+ process(options?: VideoProcessOptions): Promise<ProcessedVideoDelivery>;
36
+ }
package/dist/index.js ADDED
@@ -0,0 +1,170 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ const edges = [240, 360, 480, 540, 720, 1080, 1440, 2160], even = (value) => Math.max(2, Math.round(value / 2) * 2);
3
+ function validate(profile) {
4
+ for (const [name, value] of Object.entries({ width: profile.width, height: profile.height, duration: profile.duration, frameRate: profile.frameRate }))
5
+ if (!Number.isFinite(value) || value <= 0)
6
+ throw TypeError(`Video ${name} must be positive`);
7
+ }
8
+ export function deriveVideoLadder(profile, maximum) {
9
+ validate(profile);
10
+ const short = Math.min(profile.width, profile.height), limit = Math.min(short, maximum ?? short), targets = edges.filter((edge) => edge <= limit);
11
+ if (!targets.includes(limit))
12
+ targets.push(limit);
13
+ return [...new Set(targets)].sort((a, b) => a - b).map((edge) => {
14
+ const scale = edge / short, width = even(profile.width * scale), height = even(profile.height * scale), rate = Math.round(width * height * Math.min(60, profile.frameRate) * 0.075 * (profile.frameRate > 30 ? Math.min(2, profile.frameRate / 30) : 1) * (profile.hdr ? 1.25 : 1) / 1000) * 1000;
15
+ return { name: `${edge}p`, width, height, frameRate: profile.frameRate, videoBitrate: Math.max(250000, Math.min(profile.videoBitrate ?? 1 / 0, rate)), audioBitrate: profile.hasAudio === !1 ? 0 : width >= 1280 ? 192000 : 128000 };
16
+ }).filter((item, index, all) => item.width <= profile.width && item.height <= profile.height && all.findIndex((value) => value.width === item.width && value.height === item.height) === index);
17
+ }
18
+
19
+ export class VideoBuilder {
20
+ source;
21
+ inspected;
22
+ formats = ["mp4", "webm"];
23
+ streams = ["hls", "dash"];
24
+ maximum;
25
+ capabilities = { videoEncoder: !1, audioEncoder: !1, videoCodecs: [], audioCodecs: [] };
26
+ constructor(source) {
27
+ this.source = source;
28
+ }
29
+ profile(value) {
30
+ validate(value);
31
+ this.inspected = value;
32
+ return this;
33
+ }
34
+ ladder(value) {
35
+ if (value !== "auto" && (!Number.isInteger(value) || value <= 0))
36
+ throw TypeError("Video ladder height must be positive");
37
+ this.maximum = value === "auto" ? void 0 : value;
38
+ return this;
39
+ }
40
+ output(formats) {
41
+ if (!formats.length)
42
+ throw TypeError("Video formats are required");
43
+ this.formats = [...new Set(formats)];
44
+ return this;
45
+ }
46
+ streaming(formats) {
47
+ this.streams = [...new Set(formats)];
48
+ return this;
49
+ }
50
+ runtime(value) {
51
+ this.capabilities = value;
52
+ return this;
53
+ }
54
+ generate() {
55
+ if (!this.inspected)
56
+ throw Error("Video inspection is required; pass its profile with .profile()");
57
+ const profile = this.inspected, renditions = deriveVideoLadder(profile, this.maximum), outputs = this.formats.map((container) => {
58
+ const videoCodec = container === "mp4" ? "h264" : "vp9", audioCodec = profile.hasAudio === !1 ? void 0 : container === "mp4" ? "aac" : "opus", copy = renditions.length === 1 && renditions[0].width === profile.width && renditions[0].height === profile.height && profile.container === container && profile.videoCodec === videoCodec && (!audioCodec || profile.audioCodec === audioCodec), available = copy || this.capabilities.videoEncoder && this.capabilities.videoCodecs.includes(videoCodec) && (!audioCodec || this.capabilities.audioEncoder && this.capabilities.audioCodecs.includes(audioCodec));
59
+ return { container, videoCodec, audioCodec, action: copy ? "copy" : "transcode", available, reason: available ? void 0 : `Native ${videoCodec}${audioCodec ? `/${audioCodec}` : ""} encoding is unavailable` };
60
+ }), segmentDuration = profile.duration <= 30 ? 2 : profile.duration <= 600 ? 4 : 6;
61
+ return { source: this.source, profile, renditions, outputs, streaming: this.streams, segmentDuration, keyframeInterval: Math.max(1, Math.round(profile.frameRate * segmentDuration)) };
62
+ }
63
+ async process(options = {}) {
64
+ return processVideoPlan(this.generate(), options);
65
+ }
66
+ }
67
+ export function video(source) {
68
+ return new VideoBuilder(source);
69
+ }
70
+ export async function processVideoPlan(plan, options = {}) {
71
+ assertVideoPlanExecutable(plan);
72
+ const { createVideoDeliveryPipeline } = await import("ts-videos/delivery-pipeline");
73
+ return createVideoDeliveryPipeline(plan.source, {
74
+ source: plan.profile,
75
+ renditions: plan.renditions,
76
+ outputs: plan.outputs,
77
+ streaming: plan.streaming,
78
+ segmentDuration: plan.segmentDuration,
79
+ keyframeInterval: plan.keyframeInterval
80
+ }, options);
81
+ }
82
+ export function assertVideoPlanExecutable(plan) {
83
+ const missing = plan.outputs.filter((value) => !value.available);
84
+ if (missing.length)
85
+ throw Error(missing.map((value) => `${value.container}: ${value.reason}`).join("; "));
86
+ }
87
+ export function createHlsMaster(plan, uri) {
88
+ const lines = ["#EXTM3U", "#EXT-X-VERSION:7", "#EXT-X-INDEPENDENT-SEGMENTS"], output = plan.outputs.find((value) => value.container === "mp4");
89
+ for (const item of plan.renditions) {
90
+ lines.push(`#EXT-X-STREAM-INF:BANDWIDTH=${item.videoBitrate + item.audioBitrate},AVERAGE-BANDWIDTH=${Math.round((item.videoBitrate + item.audioBitrate) * 0.9)},RESOLUTION=${item.width}x${item.height},FRAME-RATE=${item.frameRate.toFixed(3)},CODECS="${output?.videoCodec ?? plan.profile.videoCodec}${output?.audioCodec ? `,${output.audioCodec}` : ""}"`);
91
+ lines.push(uri(item));
92
+ }
93
+ return `${lines.join(`
94
+ `)}
95
+ `;
96
+ }
97
+ function time(seconds) {
98
+ const ms = Math.round(seconds * 1000);
99
+ return `${String(Math.floor(ms / 3600000)).padStart(2, "0")}:${String(Math.floor(ms % 3600000 / 60000)).padStart(2, "0")}:${String(Math.floor(ms % 60000 / 1000)).padStart(2, "0")}.${String(ms % 1000).padStart(3, "0")}`;
100
+ }
101
+ export function createPreviewVtt(cues) {
102
+ let end = 0;
103
+ const lines = ["WEBVTT", ""];
104
+ cues.forEach((cue, index) => {
105
+ if (cue.startTime < end || cue.endTime <= cue.startTime)
106
+ throw TypeError(`Invalid preview cue ${index}`);
107
+ const sprite = [cue.x, cue.y, cue.width, cue.height].every((value) => value !== void 0);
108
+ lines.push(`${time(cue.startTime)} --> ${time(cue.endTime)}`, sprite ? `${cue.uri}#xywh=${cue.x},${cue.y},${cue.width},${cue.height}` : cue.uri, "");
109
+ end = cue.endTime;
110
+ });
111
+ return lines.join(`
112
+ `);
113
+ }
114
+ export function videoResponseHeaders(bytes, etag, contentType) {
115
+ return { "Accept-Ranges": "bytes", "Content-Length": String(bytes), "Content-Type": contentType, "Cache-Control": "public, max-age=31536000, immutable", ETag: `"${etag}"` };
116
+ }
117
+ export function signVideoAsset(path, expires, secret) {
118
+ return createHmac("sha256", secret).update(`${path}
119
+ ${expires}`).digest("base64url");
120
+ }
121
+ export function verifyVideoAsset(path, expires, signature, secret, now = Date.now()) {
122
+ if (!Number.isInteger(expires) || expires * 1000 <= now)
123
+ return !1;
124
+ const expected = Buffer.from(signVideoAsset(path, expires, secret)), actual = Buffer.from(signature);
125
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
126
+ }
127
+ function sequenceIv(index) {
128
+ const value = new Uint8Array(16);
129
+ new DataView(value.buffer).setBigUint64(8, BigInt(index));
130
+ return value;
131
+ }
132
+ function hex(value) {
133
+ return [...value].map((byte) => byte.toString(16).padStart(2, "0")).join("");
134
+ }
135
+ async function encryptAes(data, key, iv) {
136
+ if (key.byteLength !== 16 || iv.byteLength !== 16)
137
+ throw TypeError("HLS AES-128 keys and IVs must contain 16 bytes");
138
+ const keyBytes = Uint8Array.from(key), ivBytes = Uint8Array.from(iv), dataBytes = Uint8Array.from(data), cryptoKey = await crypto.subtle.importKey("raw", keyBytes.buffer, { name: "AES-CBC" }, !1, ["encrypt"]);
139
+ return new Uint8Array(await crypto.subtle.encrypt({ name: "AES-CBC", iv: ivBytes.buffer }, cryptoKey, dataBytes.buffer));
140
+ }
141
+ export async function createProtectedVideoPlaylist(segments, protection) {
142
+ if (!segments.length)
143
+ throw TypeError("Video playlist requires segments");
144
+ if (protection && /[\r\n"]/.test(protection.keyUri))
145
+ throw TypeError("Invalid video key URI");
146
+ const lines = ["#EXTM3U", "#EXT-X-VERSION:7", `#EXT-X-TARGETDURATION:${Math.ceil(Math.max(...segments.map((item) => item.duration)))}`, "#EXT-X-PLAYLIST-TYPE:VOD", "#EXT-X-INDEPENDENT-SEGMENTS"], files = {};
147
+ for (const [index, segment] of segments.entries()) {
148
+ if (!Number.isFinite(segment.duration) || segment.duration <= 0 || /[\r\n"]/.test(segment.uri))
149
+ throw TypeError(`Invalid video segment ${index}`);
150
+ let data = segment.data;
151
+ if (protection) {
152
+ const iv = protection.iv?.(index) ?? sequenceIv(index);
153
+ data = await encryptAes(data, protection.key, iv);
154
+ lines.push(`#EXT-X-KEY:METHOD=AES-128,URI="${protection.keyUri}",IV=0x${hex(iv)}`);
155
+ }
156
+ lines.push(`#EXTINF:${segment.duration.toFixed(6)},`, segment.uri);
157
+ files[segment.uri] = data;
158
+ }
159
+ lines.push("#EXT-X-ENDLIST", "");
160
+ return { playlist: lines.join(`
161
+ `), files, encrypted: !!protection };
162
+ }
163
+ export function videoAssetHeaders(path, bytes, etag, protectedMedia = !1) {
164
+ const manifest = /\.(?:m3u8|mpd|vtt)$/i.test(path), headers = { "Accept-Ranges": "bytes", "Cache-Control": protectedMedia && manifest ? "private, no-store" : manifest ? "public, max-age=5, s-maxage=30" : "public, max-age=31536000, immutable", "X-Content-Type-Options": "nosniff" };
165
+ if (bytes !== void 0)
166
+ headers["Content-Length"] = String(bytes);
167
+ if (etag)
168
+ headers.ETag = `"${etag}"`;
169
+ return headers;
170
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@stacksjs/video",
3
+ "type": "module",
4
+ "sideEffects": false,
5
+ "version": "0.70.163",
6
+ "description": "Native video delivery planning for Stacks.",
7
+ "author": "Chris Breuer",
8
+ "license": "MIT",
9
+ "funding": "https://github.com/sponsors/chrisbbreuer",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/stacksjs/stacks.git",
13
+ "directory": "./storage/framework/core/video"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "development": "./src/index.ts",
19
+ "bun": "./dist/index.js",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "module": "dist/index.js",
24
+ "types": "dist/index.d.ts",
25
+ "files": [
26
+ "README.md",
27
+ "dist"
28
+ ],
29
+ "scripts": {
30
+ "build": "bun build.ts",
31
+ "typecheck": "bun tsc --noEmit",
32
+ "prepublishOnly": "bun run build"
33
+ },
34
+ "dependencies": {
35
+ "ts-video-player": "^0.1.0",
36
+ "ts-videos": "^0.1.1"
37
+ },
38
+ "devDependencies": {
39
+ "better-dx": "^0.2.17"
40
+ }
41
+ }