@aelionsdk/media 0.1.0-beta.1

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.
@@ -0,0 +1,11 @@
1
+ import { type CacheAddress, type CacheStore, type CacheStoreSnapshot } from './cache-store.js';
2
+ export declare class OpfsCacheStore implements CacheStore {
3
+ #private;
4
+ constructor(directoryName?: string, maxBytes?: number);
5
+ get(address: CacheAddress, signal?: AbortSignal): Promise<Uint8Array | undefined>;
6
+ put(address: CacheAddress, value: Uint8Array, signal?: AbortSignal): Promise<void>;
7
+ delete(address: CacheAddress, signal?: AbortSignal): Promise<void>;
8
+ clear(signal?: AbortSignal): Promise<void>;
9
+ snapshot(): CacheStoreSnapshot;
10
+ }
11
+ //# sourceMappingURL=opfs-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opfs-cache.d.ts","sourceRoot":"","sources":["../src/opfs-cache.ts"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACxB,MAAM,kBAAkB,CAAC;AAqB1B,qBAAa,cAAe,YAAW,UAAU;;gBAY5B,aAAa,SAAoB,EAAE,QAAQ,SAAsB;IAcvE,GAAG,CAAC,OAAO,EAAE,YAAY,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC;IA4BvF,GAAG,CAAC,OAAO,EAAE,YAAY,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAmClF,MAAM,CAAC,OAAO,EAAE,YAAY,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAclE,KAAK,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAgB1C,QAAQ,IAAI,kBAAkB;CAiEtC"}
@@ -0,0 +1,179 @@
1
+ import { throwIfAborted } from '@aelionsdk/core';
2
+ import { cacheAddressKey, } from './cache-store.js';
3
+ async function fileName(key) {
4
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key)));
5
+ return `${[...digest].map(value => value.toString(16).padStart(2, '0')).join('')}.bin`;
6
+ }
7
+ export class OpfsCacheStore {
8
+ #maxBytes;
9
+ #directory;
10
+ #entries = new Map();
11
+ #ready;
12
+ #mutation = Promise.resolve();
13
+ #bytes = 0;
14
+ #clock = 0;
15
+ #hits = 0;
16
+ #misses = 0;
17
+ #evictions = 0;
18
+ constructor(directoryName = 'aelion-cache-v1', maxBytes = 512 * 1_024 * 1_024) {
19
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
20
+ throw new RangeError('maxBytes must be a positive safe integer');
21
+ }
22
+ if (directoryName.length === 0 || directoryName.includes('/')) {
23
+ throw new TypeError('OPFS cache directoryName must be a leaf name');
24
+ }
25
+ this.#maxBytes = maxBytes;
26
+ this.#directory = navigator.storage
27
+ .getDirectory()
28
+ .then(root => root.getDirectoryHandle(directoryName, { create: true }));
29
+ this.#ready = this.#loadIndex();
30
+ }
31
+ async get(address, signal) {
32
+ throwIfAborted(signal, 'OPFS cache read');
33
+ await this.#ready;
34
+ const key = cacheAddressKey(address);
35
+ const entry = this.#entries.get(key);
36
+ if (entry === undefined) {
37
+ this.#misses += 1;
38
+ return undefined;
39
+ }
40
+ try {
41
+ const handle = await (await this.#directory).getFileHandle(entry.file);
42
+ const bytes = new Uint8Array(await (await handle.getFile()).arrayBuffer());
43
+ throwIfAborted(signal, 'OPFS cache read');
44
+ if (bytes.byteLength !== entry.bytes)
45
+ throw new Error('OPFS cache entry size mismatch');
46
+ entry.access = ++this.#clock;
47
+ this.#hits += 1;
48
+ return bytes;
49
+ }
50
+ catch (error) {
51
+ if (error instanceof DOMException && error.name === 'NotFoundError') {
52
+ this.#entries.delete(key);
53
+ this.#bytes -= entry.bytes;
54
+ this.#misses += 1;
55
+ return undefined;
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+ put(address, value, signal) {
61
+ const task = this.#mutation.then(async () => {
62
+ throwIfAborted(signal, 'OPFS cache write');
63
+ await this.#ready;
64
+ if (value.byteLength > this.#maxBytes)
65
+ throw new RangeError('Cache entry exceeds OPFS budget');
66
+ const estimate = await navigator.storage.estimate();
67
+ const available = (estimate.quota ?? Number.MAX_SAFE_INTEGER) - (estimate.usage ?? 0);
68
+ if (value.byteLength > available) {
69
+ throw new DOMException('Insufficient OPFS quota', 'QuotaExceededError');
70
+ }
71
+ const key = cacheAddressKey(address);
72
+ const name = await fileName(key);
73
+ const directory = await this.#directory;
74
+ const handle = await directory.getFileHandle(name, { create: true });
75
+ const stream = await handle.createWritable();
76
+ try {
77
+ await stream.write(value);
78
+ await stream.close();
79
+ }
80
+ catch (error) {
81
+ await stream.abort().catch(() => undefined);
82
+ await directory.removeEntry(name).catch(() => undefined);
83
+ throw error;
84
+ }
85
+ const previous = this.#entries.get(key);
86
+ if (previous !== undefined)
87
+ this.#bytes -= previous.bytes;
88
+ this.#entries.set(key, { file: name, bytes: value.byteLength, access: ++this.#clock });
89
+ this.#bytes += value.byteLength;
90
+ await this.#evict();
91
+ await this.#saveIndex();
92
+ });
93
+ this.#mutation = task.catch(() => undefined);
94
+ return task;
95
+ }
96
+ delete(address, signal) {
97
+ return this.#enqueue(async () => {
98
+ throwIfAborted(signal, 'OPFS cache delete');
99
+ await this.#ready;
100
+ const key = cacheAddressKey(address);
101
+ const entry = this.#entries.get(key);
102
+ if (entry === undefined)
103
+ return;
104
+ this.#entries.delete(key);
105
+ this.#bytes -= entry.bytes;
106
+ await (await this.#directory).removeEntry(entry.file).catch(() => undefined);
107
+ await this.#saveIndex();
108
+ });
109
+ }
110
+ clear(signal) {
111
+ return this.#enqueue(async () => {
112
+ throwIfAborted(signal, 'OPFS cache clear');
113
+ await this.#ready;
114
+ const directory = await this.#directory;
115
+ await Promise.all([...this.#entries.values()].map(entry => directory.removeEntry(entry.file).catch(() => undefined)));
116
+ this.#entries.clear();
117
+ this.#bytes = 0;
118
+ await this.#saveIndex();
119
+ });
120
+ }
121
+ snapshot() {
122
+ return {
123
+ entries: this.#entries.size,
124
+ bytes: this.#bytes,
125
+ maxBytes: this.#maxBytes,
126
+ hits: this.#hits,
127
+ misses: this.#misses,
128
+ evictions: this.#evictions,
129
+ };
130
+ }
131
+ async #loadIndex() {
132
+ try {
133
+ const handle = await (await this.#directory).getFileHandle('index.json');
134
+ const parsed = JSON.parse(await (await handle.getFile()).text());
135
+ this.#clock = parsed.clock;
136
+ for (const [key, entry] of Object.entries(parsed.entries)) {
137
+ if (typeof entry.file === 'string' &&
138
+ Number.isSafeInteger(entry.bytes) &&
139
+ entry.bytes >= 0 &&
140
+ Number.isSafeInteger(entry.access)) {
141
+ this.#entries.set(key, { ...entry });
142
+ this.#bytes += entry.bytes;
143
+ }
144
+ }
145
+ await this.#evict();
146
+ }
147
+ catch (error) {
148
+ if (!(error instanceof DOMException && error.name === 'NotFoundError'))
149
+ throw error;
150
+ }
151
+ }
152
+ async #saveIndex() {
153
+ const handle = await (await this.#directory).getFileHandle('index.json', { create: true });
154
+ const stream = await handle.createWritable();
155
+ await stream.write(JSON.stringify({
156
+ version: 1,
157
+ clock: this.#clock,
158
+ entries: Object.fromEntries(this.#entries),
159
+ }));
160
+ await stream.close();
161
+ }
162
+ async #evict() {
163
+ const directory = await this.#directory;
164
+ while (this.#bytes > this.#maxBytes) {
165
+ const oldest = [...this.#entries.entries()].sort((left, right) => left[1].access - right[1].access)[0];
166
+ if (oldest === undefined)
167
+ return;
168
+ this.#entries.delete(oldest[0]);
169
+ this.#bytes -= oldest[1].bytes;
170
+ await directory.removeEntry(oldest[1].file).catch(() => undefined);
171
+ this.#evictions += 1;
172
+ }
173
+ }
174
+ #enqueue(operation) {
175
+ const task = this.#mutation.then(operation);
176
+ this.#mutation = task.catch(() => undefined);
177
+ return task;
178
+ }
179
+ }
@@ -0,0 +1,26 @@
1
+ import type { JsonObject, JsonValue } from '@aelionsdk/core';
2
+ export interface AssetRepresentation {
3
+ readonly id: string;
4
+ readonly role: 'original' | 'proxy' | 'thumbnail' | 'waveform';
5
+ readonly locator: JsonValue;
6
+ readonly durationUs?: number;
7
+ readonly width?: number;
8
+ readonly height?: number;
9
+ readonly contentHash?: string;
10
+ readonly sourceStartUs?: number;
11
+ }
12
+ export interface SelectRepresentationOptions {
13
+ readonly purpose: 'preview' | 'export' | 'thumbnail' | 'waveform';
14
+ readonly maxDimension?: number;
15
+ readonly sourceDurationUs?: number;
16
+ readonly durationToleranceUs?: number;
17
+ }
18
+ export interface RepresentationSelection {
19
+ readonly representation: AssetRepresentation;
20
+ readonly usedProxy: boolean;
21
+ readonly diagnostics: readonly string[];
22
+ }
23
+ export declare function selectAssetRepresentation(asset: Readonly<JsonObject>, options: SelectRepresentationOptions): RepresentationSelection;
24
+ /** Proxy and source use the same normalized presentation timeline. */
25
+ export declare function proxyPresentationTimeUs(sourceTimeUs: number, representation: AssetRepresentation): number;
26
+ //# sourceMappingURL=proxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE7D,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,GAAG,WAAW,GAAG,UAAU,CAAC;IAC/D,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC;IAC5B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CACjC;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,OAAO,EAAE,SAAS,GAAG,QAAQ,GAAG,WAAW,GAAG,UAAU,CAAC;IAClE,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,cAAc,EAAE,mBAAmB,CAAC;IAC7C,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;CACzC;AAiCD,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,EAC3B,OAAO,EAAE,2BAA2B,GACnC,uBAAuB,CA8CzB;AAED,sEAAsE;AACtE,wBAAgB,uBAAuB,CACrC,YAAY,EAAE,MAAM,EACpB,cAAc,EAAE,mBAAmB,GAClC,MAAM,CAYR"}
package/dist/proxy.js ADDED
@@ -0,0 +1,86 @@
1
+ function object(value) {
2
+ return value !== null && value !== undefined && typeof value === 'object' && !Array.isArray(value)
3
+ ? value
4
+ : undefined;
5
+ }
6
+ function representation(value) {
7
+ const entry = object(value);
8
+ if (entry === undefined ||
9
+ typeof entry.id !== 'string' ||
10
+ (entry.role !== 'original' &&
11
+ entry.role !== 'proxy' &&
12
+ entry.role !== 'thumbnail' &&
13
+ entry.role !== 'waveform') ||
14
+ entry.locator === undefined) {
15
+ return undefined;
16
+ }
17
+ return {
18
+ id: entry.id,
19
+ role: entry.role,
20
+ locator: entry.locator,
21
+ ...(typeof entry.durationUs === 'number' ? { durationUs: entry.durationUs } : {}),
22
+ ...(typeof entry.width === 'number' ? { width: entry.width } : {}),
23
+ ...(typeof entry.height === 'number' ? { height: entry.height } : {}),
24
+ ...(typeof entry.contentHash === 'string' ? { contentHash: entry.contentHash } : {}),
25
+ ...(typeof entry.sourceStartUs === 'number' ? { sourceStartUs: entry.sourceStartUs } : {}),
26
+ };
27
+ }
28
+ export function selectAssetRepresentation(asset, options) {
29
+ const values = Array.isArray(asset.representations)
30
+ ? asset.representations.flatMap(value => {
31
+ const parsed = representation(value);
32
+ return parsed === undefined ? [] : [parsed];
33
+ })
34
+ : [];
35
+ const original = values.find(value => value.role === 'original') ??
36
+ {
37
+ id: `${typeof asset.id === 'string' ? asset.id : 'asset'}:original`,
38
+ role: 'original',
39
+ locator: asset.locator ?? null,
40
+ ...(options.sourceDurationUs === undefined ? {} : { durationUs: options.sourceDurationUs }),
41
+ };
42
+ if (options.purpose === 'export') {
43
+ return { representation: original, usedProxy: false, diagnostics: [] };
44
+ }
45
+ const role = options.purpose === 'preview'
46
+ ? 'proxy'
47
+ : options.purpose === 'thumbnail'
48
+ ? 'thumbnail'
49
+ : 'waveform';
50
+ const diagnostics = [];
51
+ const candidates = values
52
+ .filter(value => value.role === role)
53
+ .filter(value => {
54
+ if (options.sourceDurationUs === undefined || value.durationUs === undefined)
55
+ return true;
56
+ const tolerance = options.durationToleranceUs ?? 1_000;
57
+ const consistent = Math.abs(value.durationUs - options.sourceDurationUs) <= tolerance;
58
+ if (!consistent)
59
+ diagnostics.push('MEDIA_PROXY_DURATION_MISMATCH');
60
+ return consistent;
61
+ })
62
+ .sort((left, right) => {
63
+ const leftSize = Math.max(left.width ?? Number.MAX_SAFE_INTEGER, left.height ?? 0);
64
+ const rightSize = Math.max(right.width ?? Number.MAX_SAFE_INTEGER, right.height ?? 0);
65
+ const target = options.maxDimension ?? Number.MAX_SAFE_INTEGER;
66
+ const leftPenalty = leftSize > target ? leftSize - target + target : target - leftSize;
67
+ const rightPenalty = rightSize > target ? rightSize - target + target : target - rightSize;
68
+ return leftPenalty - rightPenalty || left.id.localeCompare(right.id);
69
+ });
70
+ const selected = candidates[0];
71
+ return selected === undefined
72
+ ? { representation: original, usedProxy: false, diagnostics }
73
+ : { representation: selected, usedProxy: selected.role === 'proxy', diagnostics };
74
+ }
75
+ /** Proxy and source use the same normalized presentation timeline. */
76
+ export function proxyPresentationTimeUs(sourceTimeUs, representation) {
77
+ if (!Number.isSafeInteger(sourceTimeUs) || sourceTimeUs < 0) {
78
+ throw new RangeError('sourceTimeUs must be a non-negative safe integer');
79
+ }
80
+ const mapped = sourceTimeUs - (representation.sourceStartUs ?? 0);
81
+ if (mapped < 0 ||
82
+ (representation.durationUs !== undefined && mapped >= representation.durationUs)) {
83
+ throw new RangeError('Source time is outside the representation timeline');
84
+ }
85
+ return mapped;
86
+ }
@@ -0,0 +1,31 @@
1
+ import type { ByteRange, RangeRead, RangeReader } from './types.js';
2
+ export declare class MemoryRangeReader implements RangeReader {
3
+ #private;
4
+ readonly id: string;
5
+ readonly kind: "memory";
6
+ constructor(id: string, bytes: Uint8Array);
7
+ size(signal?: AbortSignal): Promise<number>;
8
+ read(range: ByteRange, signal?: AbortSignal): Promise<RangeRead>;
9
+ }
10
+ export declare class BlobRangeReader implements RangeReader {
11
+ #private;
12
+ readonly id: string;
13
+ readonly kind: "blob";
14
+ constructor(id: string, blob: Blob);
15
+ size(signal?: AbortSignal): Promise<number>;
16
+ read(range: ByteRange, signal?: AbortSignal): Promise<RangeRead>;
17
+ }
18
+ export interface FetchRangeReaderOptions {
19
+ readonly headers?: Readonly<Record<string, string>>;
20
+ readonly fetch?: typeof globalThis.fetch;
21
+ }
22
+ export declare class FetchRangeReader implements RangeReader {
23
+ #private;
24
+ readonly id: string;
25
+ readonly url: string;
26
+ readonly kind: "network";
27
+ constructor(id: string, url: string, options?: FetchRangeReaderOptions);
28
+ size(signal?: AbortSignal): Promise<number>;
29
+ read(range: ByteRange, signal?: AbortSignal): Promise<RangeRead>;
30
+ }
31
+ //# sourceMappingURL=range-reader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"range-reader.d.ts","sourceRoot":"","sources":["../src/range-reader.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAgCpE,qBAAa,iBAAkB,YAAW,WAAW;;aAKjC,EAAE,EAAE,MAAM;IAJ5B,SAAgB,IAAI,EAAG,QAAQ,CAAU;gBAIvB,EAAE,EAAE,MAAM,EAC1B,KAAK,EAAE,UAAU;IAKZ,IAAI,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC;IAS3C,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,CAAC;CAiBxE;AAED,qBAAa,eAAgB,YAAW,WAAW;;aAK/B,EAAE,EAAE,MAAM;IAJ5B,SAAgB,IAAI,EAAG,MAAM,CAAU;gBAIrB,EAAE,EAAE,MAAM,EAC1B,IAAI,EAAE,IAAI;IAKL,IAAI,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC;IASrC,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,CAAC;CAe9E;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CAC1C;AAED,qBAAa,gBAAiB,YAAW,WAAW;;aAOhC,EAAE,EAAE,MAAM;aACV,GAAG,EAAE,MAAM;IAP7B,SAAgB,IAAI,EAAG,SAAS,CAAU;gBAMxB,EAAE,EAAE,MAAM,EACV,GAAG,EAAE,MAAM,EAC3B,OAAO,GAAE,uBAA4B;IAM1B,IAAI,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC;IA+B3C,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,CAAC;CA8E9E"}
@@ -0,0 +1,200 @@
1
+ import { AelionError, throwIfAborted } from '@aelionsdk/core';
2
+ function assertRange(range) {
3
+ if (!Number.isSafeInteger(range.offset) ||
4
+ !Number.isSafeInteger(range.length) ||
5
+ range.offset < 0 ||
6
+ range.length <= 0) {
7
+ throw new RangeError('Byte range offset must be non-negative and length must be positive');
8
+ }
9
+ if (!Number.isSafeInteger(range.offset + range.length)) {
10
+ throw new RangeError('Byte range end exceeds the safe integer range');
11
+ }
12
+ }
13
+ function asError(value) {
14
+ return value instanceof Error ? value : new Error('RangeReader failed', { cause: value });
15
+ }
16
+ function mediaNetworkError(code, message, cause) {
17
+ return new AelionError([
18
+ {
19
+ code,
20
+ severity: 'error',
21
+ message,
22
+ recoverable: true,
23
+ ...(cause === undefined ? {} : { cause }),
24
+ },
25
+ ]);
26
+ }
27
+ export class MemoryRangeReader {
28
+ id;
29
+ kind = 'memory';
30
+ #bytes;
31
+ constructor(id, bytes) {
32
+ this.id = id;
33
+ this.#bytes = bytes.slice();
34
+ }
35
+ size(signal) {
36
+ try {
37
+ throwIfAborted(signal, 'memory source size');
38
+ return Promise.resolve(this.#bytes.byteLength);
39
+ }
40
+ catch (error) {
41
+ return Promise.reject(asError(error));
42
+ }
43
+ }
44
+ read(range, signal) {
45
+ try {
46
+ throwIfAborted(signal, 'memory range read');
47
+ assertRange(range);
48
+ if (range.offset + range.length > this.#bytes.byteLength) {
49
+ throw new RangeError('Byte range exceeds source size');
50
+ }
51
+ return Promise.resolve({
52
+ bytes: this.#bytes.slice(range.offset, range.offset + range.length),
53
+ range,
54
+ totalSize: this.#bytes.byteLength,
55
+ source: 'memory',
56
+ });
57
+ }
58
+ catch (error) {
59
+ return Promise.reject(asError(error));
60
+ }
61
+ }
62
+ }
63
+ export class BlobRangeReader {
64
+ id;
65
+ kind = 'blob';
66
+ #blob;
67
+ constructor(id, blob) {
68
+ this.id = id;
69
+ this.#blob = blob;
70
+ }
71
+ size(signal) {
72
+ try {
73
+ throwIfAborted(signal, 'blob source size');
74
+ return Promise.resolve(this.#blob.size);
75
+ }
76
+ catch (error) {
77
+ return Promise.reject(asError(error));
78
+ }
79
+ }
80
+ async read(range, signal) {
81
+ throwIfAborted(signal, 'blob range read');
82
+ assertRange(range);
83
+ if (range.offset + range.length > this.#blob.size) {
84
+ throw new RangeError('Byte range exceeds source size');
85
+ }
86
+ const buffer = await this.#blob.slice(range.offset, range.offset + range.length).arrayBuffer();
87
+ throwIfAborted(signal, 'blob range read');
88
+ return {
89
+ bytes: new Uint8Array(buffer),
90
+ range,
91
+ totalSize: this.#blob.size,
92
+ source: 'blob',
93
+ };
94
+ }
95
+ }
96
+ export class FetchRangeReader {
97
+ id;
98
+ url;
99
+ kind = 'network';
100
+ #fetch;
101
+ #headers;
102
+ #size;
103
+ constructor(id, url, options = {}) {
104
+ this.id = id;
105
+ this.url = url;
106
+ this.#fetch = options.fetch ?? globalThis.fetch;
107
+ this.#headers = options.headers ?? {};
108
+ }
109
+ async size(signal) {
110
+ throwIfAborted(signal, 'network source size');
111
+ if (this.#size !== undefined)
112
+ return this.#size;
113
+ let response;
114
+ try {
115
+ response = await this.#fetch(this.url, {
116
+ method: 'HEAD',
117
+ headers: this.#headers,
118
+ ...(signal === undefined ? {} : { signal }),
119
+ });
120
+ }
121
+ catch (cause) {
122
+ throw mediaNetworkError('MEDIA_NETWORK_OR_CORS_FAILED', 'Media HEAD request failed because of network or CORS policy', cause);
123
+ }
124
+ if (!response.ok) {
125
+ throw new Error(`HEAD request failed with HTTP ${response.status}`);
126
+ }
127
+ const contentLength = response.headers.get('content-length');
128
+ if (contentLength === null)
129
+ return this.#probeSize(signal);
130
+ const size = Number(contentLength);
131
+ if (!Number.isSafeInteger(size) || size <= 0) {
132
+ throw new Error('Content-Length is missing or outside the safe integer range');
133
+ }
134
+ this.#size = size;
135
+ return size;
136
+ }
137
+ async read(range, signal) {
138
+ throwIfAborted(signal, 'network range read');
139
+ assertRange(range);
140
+ const end = range.offset + range.length - 1;
141
+ let response;
142
+ try {
143
+ response = await this.#fetch(this.url, {
144
+ headers: { ...this.#headers, Range: `bytes=${range.offset}-${end}` },
145
+ ...(signal === undefined ? {} : { signal }),
146
+ });
147
+ }
148
+ catch (cause) {
149
+ throw mediaNetworkError('MEDIA_NETWORK_OR_CORS_FAILED', 'Media Range request failed because of network or CORS policy', cause);
150
+ }
151
+ if (response.status !== 206) {
152
+ throw mediaNetworkError(response.status === 200 ? 'MEDIA_RANGE_UNSUPPORTED' : 'MEDIA_RANGE_REQUEST_FAILED', response.status === 200
153
+ ? 'Server ignored Range and returned the full resource'
154
+ : `Range request failed with HTTP ${response.status}`);
155
+ }
156
+ const contentRange = response.headers.get('content-range');
157
+ const parsed = contentRange?.match(/^bytes (\d+)-(\d+)\/(\d+)$/u);
158
+ if (parsed === undefined || parsed === null) {
159
+ throw new Error('Range response is missing a valid Content-Range header');
160
+ }
161
+ const actualStart = Number(parsed[1]);
162
+ const actualEnd = Number(parsed[2]);
163
+ const totalSize = Number(parsed[3]);
164
+ if (actualStart !== range.offset ||
165
+ actualEnd !== end ||
166
+ !Number.isSafeInteger(totalSize) ||
167
+ totalSize <= end) {
168
+ throw new Error('Range response does not match the requested byte interval');
169
+ }
170
+ const bytes = new Uint8Array(await response.arrayBuffer());
171
+ if (bytes.byteLength !== range.length) {
172
+ throw new Error('Range response body length does not match Content-Range');
173
+ }
174
+ this.#size = totalSize;
175
+ return { bytes, range, totalSize, source: 'network' };
176
+ }
177
+ async #probeSize(signal) {
178
+ let response;
179
+ try {
180
+ response = await this.#fetch(this.url, {
181
+ headers: { ...this.#headers, Range: 'bytes=0-0' },
182
+ ...(signal === undefined ? {} : { signal }),
183
+ });
184
+ }
185
+ catch (cause) {
186
+ throw mediaNetworkError('MEDIA_NETWORK_OR_CORS_FAILED', 'Media Range size probe failed because of network or CORS policy', cause);
187
+ }
188
+ if (response.status !== 206) {
189
+ throw mediaNetworkError('MEDIA_RANGE_UNSUPPORTED', 'Source size is unavailable and the server does not support byte ranges');
190
+ }
191
+ const contentRange = response.headers.get('content-range');
192
+ const parsed = contentRange?.match(/^bytes 0-0\/(\d+)$/u);
193
+ const size = parsed === undefined || parsed === null ? Number.NaN : Number(parsed[1]);
194
+ if (!Number.isSafeInteger(size) || size <= 0) {
195
+ throw new Error('Range size probe returned an invalid Content-Range');
196
+ }
197
+ this.#size = size;
198
+ return size;
199
+ }
200
+ }
@@ -0,0 +1,32 @@
1
+ import { type Disposable } from '@aelionsdk/core';
2
+ export interface MediaResourceBudget {
3
+ readonly decoderSlots: number;
4
+ readonly gpuBytes: number;
5
+ readonly cacheBytes: number;
6
+ }
7
+ export type MediaResourcePriority = 'export' | 'preview' | 'background';
8
+ export interface MediaResourceRequest extends MediaResourceBudget {
9
+ readonly ownerId: string;
10
+ readonly priority: MediaResourcePriority;
11
+ }
12
+ export interface MediaResourceGovernorSnapshot {
13
+ readonly budget: MediaResourceBudget;
14
+ readonly used: MediaResourceBudget;
15
+ readonly activeLeases: number;
16
+ readonly pendingRequests: number;
17
+ readonly disposed: boolean;
18
+ }
19
+ export interface MediaResourceLease extends Disposable {
20
+ readonly ownerId: string;
21
+ readonly allocation: MediaResourceBudget;
22
+ }
23
+ /** Shared page-level admission controller for decoder, GPU and cache allocations. */
24
+ export declare class PageMediaResourceGovernor implements Disposable {
25
+ #private;
26
+ constructor(budget: MediaResourceBudget, maxPending?: number);
27
+ get disposed(): boolean;
28
+ snapshot(): MediaResourceGovernorSnapshot;
29
+ acquire(request: MediaResourceRequest, signal?: AbortSignal): Promise<MediaResourceLease>;
30
+ dispose(): void;
31
+ }
32
+ //# sourceMappingURL=resource-governor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resource-governor.d.ts","sourceRoot":"","sources":["../src/resource-governor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElE,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,MAAM,qBAAqB,GAAG,QAAQ,GAAG,SAAS,GAAG,YAAY,CAAC;AAExE,MAAM,WAAW,oBAAqB,SAAQ,mBAAmB;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,qBAAqB,CAAC;CAC1C;AAED,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,MAAM,EAAE,mBAAmB,CAAC;IACrC,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IACnC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,kBAAmB,SAAQ,UAAU;IACpD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,mBAAmB,CAAC;CAC1C;AA6BD,qFAAqF;AACrF,qBAAa,yBAA0B,YAAW,UAAU;;gBASvC,MAAM,EAAE,mBAAmB,EAAE,UAAU,SAAM;IAShE,IAAW,QAAQ,IAAI,OAAO,CAE7B;IAEM,QAAQ,IAAI,6BAA6B;IAUzC,OAAO,CAAC,OAAO,EAAE,oBAAoB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAuCzF,OAAO,IAAI,IAAI;CAmEvB"}