@stacksjs/video 0.70.258 → 0.70.259

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.
Files changed (2) hide show
  1. package/dist/index.js +5 -169
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,170 +1,6 @@
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(`
1
+ import{createHmac,timingSafeEqual}from"node:crypto";const edges=[240,360,480,540,720,1080,1440,2160],even=(value)=>Math.max(2,Math.round(value/2)*2);function validate(profile){for(const[name,value]of Object.entries({width:profile.width,height:profile.height,duration:profile.duration,frameRate:profile.frameRate}))if(!Number.isFinite(value)||value<=0)throw TypeError(`Video ${name} must be positive`)}export function deriveVideoLadder(profile,maximum){validate(profile);const short=Math.min(profile.width,profile.height),limit=Math.min(short,maximum??short),targets=edges.filter((edge)=>edge<=limit);if(!targets.includes(limit))targets.push(limit);return[...new Set(targets)].sort((a,b)=>a-b).map((edge)=>{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;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}}).filter((item,index,all)=>item.width<=profile.width&&item.height<=profile.height&&all.findIndex((value)=>value.width===item.width&&value.height===item.height)===index)}export class VideoBuilder{source;inspected;formats=["mp4","webm"];streams=["hls","dash"];maximum;capabilities={videoEncoder:!1,audioEncoder:!1,videoCodecs:[],audioCodecs:[]};constructor(source){this.source=source}profile(value){validate(value);this.inspected=value;return this}ladder(value){if(value!=="auto"&&(!Number.isInteger(value)||value<=0))throw TypeError("Video ladder height must be positive");this.maximum=value==="auto"?void 0:value;return this}output(formats){if(!formats.length)throw TypeError("Video formats are required");this.formats=[...new Set(formats)];return this}streaming(formats){this.streams=[...new Set(formats)];return this}runtime(value){this.capabilities=value;return this}generate(){if(!this.inspected)throw Error("Video inspection is required; pass its profile with .profile()");const profile=this.inspected,renditions=deriveVideoLadder(profile,this.maximum),outputs=this.formats.map((container)=>{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));return{container,videoCodec,audioCodec,action:copy?"copy":"transcode",available,reason:available?void 0:`Native ${videoCodec}${audioCodec?`/${audioCodec}`:""} encoding is unavailable`}}),segmentDuration=profile.duration<=30?2:profile.duration<=600?4:6;return{source:this.source,profile,renditions,outputs,streaming:this.streams,segmentDuration,keyframeInterval:Math.max(1,Math.round(profile.frameRate*segmentDuration))}}async process(options={}){return processVideoPlan(this.generate(),options)}}export function video(source){return new VideoBuilder(source)}export async function processVideoPlan(plan,options={}){assertVideoPlanExecutable(plan);const{createVideoDeliveryPipeline}=await import("ts-videos/delivery-pipeline");return createVideoDeliveryPipeline(plan.source,{source:plan.profile,renditions:plan.renditions,outputs:plan.outputs,streaming:plan.streaming,segmentDuration:plan.segmentDuration,keyframeInterval:plan.keyframeInterval},options)}export function assertVideoPlanExecutable(plan){const missing=plan.outputs.filter((value)=>!value.available);if(missing.length)throw Error(missing.map((value)=>`${value.container}: ${value.reason}`).join("; "))}export function createHlsMaster(plan,uri){const lines=["#EXTM3U","#EXT-X-VERSION:7","#EXT-X-INDEPENDENT-SEGMENTS"],output=plan.outputs.find((value)=>value.container==="mp4");for(const item of plan.renditions){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}`:""}"`);lines.push(uri(item))}return`${lines.join(`
94
2
  `)}
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
- }
3
+ `}function time(seconds){const ms=Math.round(seconds*1000);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")}`}export function createPreviewVtt(cues){let end=0;const lines=["WEBVTT",""];cues.forEach((cue,index)=>{if(cue.startTime<end||cue.endTime<=cue.startTime)throw TypeError(`Invalid preview cue ${index}`);const sprite=[cue.x,cue.y,cue.width,cue.height].every((value)=>value!==void 0);lines.push(`${time(cue.startTime)} --> ${time(cue.endTime)}`,sprite?`${cue.uri}#xywh=${cue.x},${cue.y},${cue.width},${cue.height}`:cue.uri,"");end=cue.endTime});return lines.join(`
4
+ `)}export function videoResponseHeaders(bytes,etag,contentType){return{"Accept-Ranges":"bytes","Content-Length":String(bytes),"Content-Type":contentType,"Cache-Control":"public, max-age=31536000, immutable",ETag:`"${etag}"`}}export function signVideoAsset(path,expires,secret){return createHmac("sha256",secret).update(`${path}
5
+ ${expires}`).digest("base64url")}export function verifyVideoAsset(path,expires,signature,secret,now=Date.now()){if(!Number.isInteger(expires)||expires*1000<=now)return!1;const expected=Buffer.from(signVideoAsset(path,expires,secret)),actual=Buffer.from(signature);return expected.length===actual.length&&timingSafeEqual(expected,actual)}function sequenceIv(index){const value=new Uint8Array(16);new DataView(value.buffer).setBigUint64(8,BigInt(index));return value}function hex(value){return[...value].map((byte)=>byte.toString(16).padStart(2,"0")).join("")}async function encryptAes(data,key,iv){if(key.byteLength!==16||iv.byteLength!==16)throw TypeError("HLS AES-128 keys and IVs must contain 16 bytes");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"]);return new Uint8Array(await crypto.subtle.encrypt({name:"AES-CBC",iv:ivBytes.buffer},cryptoKey,dataBytes.buffer))}export async function createProtectedVideoPlaylist(segments,protection){if(!segments.length)throw TypeError("Video playlist requires segments");if(protection&&/[\r\n"]/.test(protection.keyUri))throw TypeError("Invalid video key URI");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={};for(const[index,segment]of segments.entries()){if(!Number.isFinite(segment.duration)||segment.duration<=0||/[\r\n"]/.test(segment.uri))throw TypeError(`Invalid video segment ${index}`);let data=segment.data;if(protection){const iv=protection.iv?.(index)??sequenceIv(index);data=await encryptAes(data,protection.key,iv);lines.push(`#EXT-X-KEY:METHOD=AES-128,URI="${protection.keyUri}",IV=0x${hex(iv)}`)}lines.push(`#EXTINF:${segment.duration.toFixed(6)},`,segment.uri);files[segment.uri]=data}lines.push("#EXT-X-ENDLIST","");return{playlist:lines.join(`
6
+ `),files,encrypted:!!protection}}export function videoAssetHeaders(path,bytes,etag,protectedMedia=!1){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"};if(bytes!==void 0)headers["Content-Length"]=String(bytes);if(etag)headers.ETag=`"${etag}"`;return headers}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/video",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.258",
5
+ "version": "0.70.259",
6
6
  "description": "Native video delivery planning for Stacks.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",