@assetlib/sdk-core 0.5.3-preview.1 → 0.6.0-preview.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.
package/README.md CHANGED
@@ -202,6 +202,27 @@ Limits: UTF-8 JSON up to 512 KiB, dimensions 1–2048, at most 4,194,304 pixels,
202
202
 
203
203
  Verified cached animation works offline. Only the current descriptor may trigger a download; if it fails, retained historical releases may supply an already cached compatible animation. Historical bytes are reverified and never downloaded. If the current release removes animation or its placement, resolution returns `source: 'poster'` immediately instead of reviving an old animation. First-use offline and unsupported/failed animations also return that poster outcome.
204
204
 
205
+ ## Short looping video
206
+
207
+ A placement can carry a short, muted, looping MP4 clip beside its required still poster. `resolveVideo` is explicit; ordinary `resolve(ref)` never downloads a clip. This has been checked only by Node unit tests; it has not been verified on a device, simulator or emulator.
208
+
209
+ ```ts
210
+ const clip = await client.resolveVideo(AppAssets.Cards.gold, { signal });
211
+ if (clip.source === 'poster') {
212
+ // Show the poster: ordinary client.resolve(ref), then the bundled fallback.
213
+ } else {
214
+ // clip.uri (file-backed adapters) or clip.bytes: a verified MP4 for the app's player.
215
+ }
216
+ ```
217
+
218
+ Success returns `{ source: 'remote' | 'cache', sequence, message, sha256, assetId, mime: 'video/mp4', width, height, durationMs, frameRate, bytes?, uri? }`; anything else returns `{ source: 'poster', sequence: null, message }`. Only the base placement's clip is used, never a variant cell or state member.
219
+
220
+ - **Signed descriptor.** The optional top-level `videoSchemaVersion: 1` enables `video` on base placements: `format: 'mp4'`, `profile: 'h264-loop-v1'`, `mime: 'video/mp4'`, lowercase SHA-256, `bytes` 1–4,194,304 (`SDK_LIMITS.videoBytes`), even integer `width` and `height` of 16–1280 with at most 921,600 pixels and exactly the placement's aspect ratio, integer `durationMs` of 1,000–10,000, a finite `frameRate` of 1–30, and the exact same-origin URL `/api/delivery/{orgId}/{appId}/assets/{assetId}/videos/{sha256}` with no credentials, query or fragment. Any other `videoSchemaVersion` (including `null`), `video` without the marker, `video` together with `animation`, or `video` on a state member, variant cell or catalog page image rejects the release. Clients without video support ignore both fields and keep the poster.
221
+ - **Checks before use.** Length and SHA-256 must match, then a bounded header check: `ftyp` first, `moov` before the first `mdat`, no `moof` or `mvex`, one track whose one sample entry is `avc1` at the signed width and height. No frame is decoded; the platform player still has to decode the clip. Cached clips are rehashed and rechecked on every read.
222
+ - **Releases.** Only the current release downloads. If it has no clip for the placement, the result is the poster. If its clip can't be used (download failure or timeout, a failed check, or a missing cached file offline), verified cached clips of the same placement from older retained releases are tried newest first, without downloading, then the poster.
223
+ - **Deadline.** Clips have their own deadline, `videoTimeoutMs` (default 30,000 ms, 20–30,000), separate from the image `timeoutMs`. Clip downloads share the client's concurrent transfer limit (default 4) with images. `signal` cancels.
224
+ - **Storage.** Clips are cached under their SHA-256 through the storage adapter with `StorageMime` `'video/mp4'`, inside the same 50 MiB / 100 entry budget. An adapter that implements `getAssetFileUri(cacheKey, mime)` declares that its player opens files: `resolveVideo` then returns `uri` instead of `bytes`, and returns the poster under the `memory` and `none` policies without downloading. Otherwise `bytes` are returned under every policy.
225
+
205
226
  ## Verification and operating limits
206
227
 
207
228
  - Ed25519 signatures are checked against an independently pinned key from the configured single key or set using strict RFC 8032 verification. An envelope cannot add or replace a pinned key.
@@ -209,7 +230,7 @@ Verified cached animation works offline. Only the current descriptor may trigger
209
230
  - Public config: at most 4096 UTF-8 JSON bytes, 1–16 distinct exact-PEM pins, and 256 UTF-8 bytes per Ed25519 SPKI PEM. These limits are exported as `SDK_LIMITS.configBytes`, `pinnedKeys`, and `publicKeyBytes`.
210
231
  - HTTPS is required. `{ allowInsecureLoopback: true }` permits HTTP only for `localhost`, `127.0.0.1`, or `::1`; it does not permit arbitrary LAN hosts.
211
232
  - Delivery is credentialless. Redirects are rejected. A streaming Fetch implementation is required so chunked responses can be bounded. The Expo adapter uses `expo/fetch` on native.
212
- - Default deadline: 8 seconds per request; configurable from 20 milliseconds to 30 seconds. Maximum manifest response: 256 KiB. Maximum image: 8 MiB. Maximum placements: 100.
233
+ - Default deadline: 8 seconds per request; configurable from 20 milliseconds to 30 seconds. Clips use `videoTimeoutMs` instead (default 30 seconds, same range). Maximum manifest response: 256 KiB. Maximum image: 8 MiB. Maximum clip: 4 MiB. Maximum placements: 100.
213
234
  - Persistent state keeps the highest accepted sequence and eight signed releases, up to 3 MiB. A lower sequence or different payload reusing a sequence is rejected. Legitimate server rollback must publish a new, higher sequence.
214
235
  - A failed current image resolves to a compatible cached image from the retained release history, then to the app's bundled fallback. Older releases never cause a new download. Eviction or more than eight accepted releases can remove a previous fallback.
215
236
  - Cache bytes are rehashed before use. The supplied adapters bound image storage to 50 MiB and 100 entries per configuration. Persistence is required before activating a new release. Corrupt replay state fails closed; it is not automatically discarded.
@@ -224,7 +245,7 @@ npm test
224
245
  npm pack
225
246
  ```
226
247
 
227
- Tests include the shared cross-platform contract corpus (`test/fixtures/shared`, byte-identical with the Swift and Kotlin SDKs' copies: 115 signed manifest cases, 43 public config cases exercised as strings and objects, 18 appearance/arm resolution cases, 10 tintable icon rendering cases, staging configuration, stateful replay, byte-failure and rendition selection cases), separate JavaScript key-set fixtures for a second trusted signer and an untrusted signer, catalog subset requests, Lottie profile rejection/metadata verification, animation cache/offline/rollback/removal behavior, selection, PNG/SVG bytes and offline fallback, plus real Node Ed25519 signatures and bounded mock delivery streams. They cover signature and byte tampering, independent pins, replay across client restarts, corrupt storage, offline cache fallback, contract mismatch, URL boundaries, deadlines, and offline code generation. The npm pack contains built JavaScript and declarations; it does not require TypeScript compilation when installed.
248
+ Tests include the shared cross-platform contract corpus (`test/fixtures/shared`, byte-identical with the Swift and Kotlin SDKs' copies: 142 signed manifest cases, 43 public config cases exercised as strings and objects, 18 appearance/arm resolution cases, 10 tintable icon rendering cases, 2 video resolution and 2 video byte-failure cases, staging configuration, stateful replay, byte-failure and rendition selection cases), separate JavaScript key-set fixtures for a second trusted signer and an untrusted signer, catalog subset requests, Lottie profile rejection/metadata verification, animation cache/offline/rollback/removal behavior, clip header checks, current-only clip downloads, cache-only clip history and the clip deadline, selection, PNG/SVG bytes and offline fallback, plus real Node Ed25519 signatures and bounded mock delivery streams. They cover signature and byte tampering, independent pins, replay across client restarts, corrupt storage, offline cache fallback, contract mismatch, URL boundaries, deadlines, and offline code generation. The npm pack contains built JavaScript and declarations; it does not require TypeScript compilation when installed.
228
249
 
229
250
  ## State sets, catalog pages, and cache policies
230
251
 
package/dist/delivery.js CHANGED
@@ -12,6 +12,9 @@ export function validateRendering(value) {
12
12
  export function validateDescriptor(value, width, height, config, renditionSchemaVersion) {
13
13
  if (!record(value) || typeof value.assetId !== 'string' || !assetIdPattern.test(value.assetId) || typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256) || value.mime !== 'image/webp' || !integer(value.bytes, 1, 8 * 1024 * 1024) || typeof value.url !== 'string')
14
14
  throw new Error('Invalid image descriptor.');
15
+ // Clips belong to base placements only: never to state members, variant cells or catalog page images.
16
+ if ('video' in value)
17
+ throw new Error('Video is allowed only on a base placement.');
15
18
  const url = new URL(value.url, config.manifestUrl);
16
19
  if (url.origin !== new URL(config.manifestUrl).origin || url.username || url.password || url.search || url.hash || url.pathname !== `/api/delivery/${config.orgId}/${config.appId}/assets/${value.assetId}`)
17
20
  throw new Error('Image descriptor URL is outside the configured app.');
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AssetlibConfig, AssetRef, AssetPage, AssetPageOptions, AssetPagePayload, DynamicAssetRef, FallbackReason, StateSetRef, ResolvedStateSet, ClientOptions, ClientStatus, ManifestPayload, RefreshResult, ResolvedAsset, ResolvedAnimation, ResolveOptions, SignedManifest } from './types.js';
1
+ import type { AssetlibConfig, AssetRef, AssetPage, AssetPageOptions, AssetPagePayload, DynamicAssetRef, FallbackReason, StateSetRef, ResolvedStateSet, ClientOptions, ClientStatus, ManifestPayload, RefreshResult, ResolvedAsset, ResolvedAnimation, ResolvedVideo, ResolveOptions, ResolveVideoOptions, SignedManifest } from './types.js';
2
2
  export type * from './types.js';
3
3
  export { selectAssetCandidates } from './renditions.js';
4
4
  export { validateLottie, validateLottieMetadata, LOTTIE_LIMITS } from './lottie.js';
@@ -16,6 +16,7 @@ export declare const SDK_LIMITS: Readonly<{
16
16
  stateBytes: number;
17
17
  cacheBytes: number;
18
18
  cacheEntries: 100;
19
+ videoBytes: number;
19
20
  }>;
20
21
  export declare const hashBytes: (bytes: Uint8Array) => string;
21
22
  export declare function parsePublicConfig(input: unknown, options?: {
@@ -49,6 +50,7 @@ export declare class AssetClient {
49
50
  private reporter?;
50
51
  private queue;
51
52
  private timeoutMs;
53
+ private videoTimeoutMs;
52
54
  private decisionTimeoutMs;
53
55
  private fetcher;
54
56
  private formats;
@@ -88,6 +90,8 @@ export declare class AssetClient {
88
90
  resolveAsset(ref: DynamicAssetRef, options?: ResolveOptions): Promise<ResolvedAsset>;
89
91
  /** Explicit opt-in. Image resolution never downloads an animation. */
90
92
  resolveAnimation(ref: AssetRef, options?: ResolveOptions): Promise<ResolvedAnimation>;
93
+ /** Explicit opt-in. Image resolution never downloads a clip. Uses the base placement only. */
94
+ resolveVideo(ref: AssetRef, options?: ResolveVideoOptions): Promise<ResolvedVideo>;
91
95
  private validBytes;
92
96
  }
93
97
  export declare function createAssetClient(config: AssetlibConfig, options: ClientOptions): AssetClient;
package/dist/index.js CHANGED
@@ -6,12 +6,13 @@ import { ObservationReporter } from './telemetry.js';
6
6
  import { selectAssetCandidates, supportedFormats, targetPixels, validRenditionHeader, validateRenditions } from './renditions.js';
7
7
  export { selectAssetCandidates } from './renditions.js';
8
8
  import { validateAnimationExtension, verifiedAnimationData } from './animations.js';
9
+ import { checkMp4Header, validateVideoExtension, VIDEO_BYTES } from './video.js';
9
10
  export { validateLottie, validateLottieMetadata, LOTTIE_LIMITS } from './lottie.js';
10
11
  export { selectCatalogReferences } from './catalog.js';
11
12
  import { assetIdPattern, catalogForSequence, validateAssetPage, validateDeliveryExtensions, validateRendering, validStateRef } from './delivery.js';
12
13
  import { validateAccessibility } from './accessibility.js';
13
14
  export { resolveAccessibilityDescription, validateAccessibility } from './accessibility.js';
14
- export const SDK_LIMITS = Object.freeze({ configBytes: 4096, pinnedKeys: 16, publicKeyBytes: 256, manifestBytes: 256 * 1024, assetBytes: 8 * 1024 * 1024, slots: 100, retainedReleases: 8, stateBytes: 3 * 1024 * 1024, cacheBytes: 50 * 1024 * 1024, cacheEntries: 100 });
15
+ export const SDK_LIMITS = Object.freeze({ configBytes: 4096, pinnedKeys: 16, publicKeyBytes: 256, manifestBytes: 256 * 1024, assetBytes: 8 * 1024 * 1024, slots: 100, retainedReleases: 8, stateBytes: 3 * 1024 * 1024, cacheBytes: 50 * 1024 * 1024, cacheEntries: 100, videoBytes: VIDEO_BYTES });
15
16
  const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
16
17
  const hashPattern = /^[a-f0-9]{64}$/;
17
18
  const record = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
@@ -120,6 +121,7 @@ export function verifySignedManifest(input, config) {
120
121
  return fail('Asset URL is outside the configured app.');
121
122
  validateRenditions(payload, value, config);
122
123
  validateAnimationExtension(payload, value, config);
124
+ validateVideoExtension(payload, value, config);
123
125
  if ('accessibility' in value)
124
126
  validateAccessibility(value.accessibility);
125
127
  validateRendering(value);
@@ -255,6 +257,7 @@ export class AssetClient {
255
257
  reporter;
256
258
  queue = Promise.resolve();
257
259
  timeoutMs;
260
+ videoTimeoutMs;
258
261
  decisionTimeoutMs;
259
262
  fetcher;
260
263
  formats;
@@ -275,6 +278,9 @@ export class AssetClient {
275
278
  this.timeoutMs = options.timeoutMs ?? 8000;
276
279
  if (!integer(this.timeoutMs, 20, 30000))
277
280
  fail('timeoutMs must be between 20 and 30000.');
281
+ this.videoTimeoutMs = options.videoTimeoutMs ?? 30000;
282
+ if (!integer(this.videoTimeoutMs, 20, 30000))
283
+ fail('videoTimeoutMs must be between 20 and 30000.');
278
284
  this.decisionTimeoutMs = options.decisionTimeoutMs === undefined ? 1500 : options.decisionTimeoutMs;
279
285
  if (!integer(this.decisionTimeoutMs, 100, 10000))
280
286
  fail('decisionTimeoutMs must be between 100 and 10000.');
@@ -680,6 +686,70 @@ export class AssetClient {
680
686
  }
681
687
  return { source: 'poster', sequence: null, message: `Use the still poster. ${message}` };
682
688
  }
689
+ /** Explicit opt-in. Image resolution never downloads a clip. Uses the base placement only. */
690
+ async resolveVideo(ref, options = {}) {
691
+ if (!validKey(ref.key) || !integer(ref.width, 1, 8192) || !integer(ref.height, 1, 8192))
692
+ throw new Error('Invalid generated asset reference.');
693
+ const policy = cachePolicy(options.cachePolicy ?? this.policy);
694
+ throwIfAborted(options.signal);
695
+ const history = await this.snapshot();
696
+ throwIfAborted(options.signal);
697
+ const storage = this.options.storage;
698
+ const files = typeof storage.getAssetFileUri === 'function';
699
+ const poster = (message) => ({ source: 'poster', sequence: null, message: `Use the still poster. ${message}` });
700
+ if (files && policy !== 'disk')
701
+ return poster('This platform plays clips from local files, which needs the disk cache policy.');
702
+ let message = this.storageFailure ?? 'No compatible published clip is available.';
703
+ for (let index = 0; index < history.length; index++) {
704
+ const { payload } = history[index];
705
+ const slot = payload.slots.find(item => item.key === ref.key && item.width === ref.width && item.height === ref.height);
706
+ const video = slot?.video;
707
+ // Removing a clip is intentional publication state, not a failed download.
708
+ if (index === 0 && !video)
709
+ return { source: 'poster', sequence: null, message: 'The current release has no compatible clip. Use the still poster.' };
710
+ if (!slot || !video)
711
+ continue;
712
+ const verify = (bytes) => {
713
+ if (bytes.byteLength !== video.bytes || hashBytes(bytes) !== video.sha256)
714
+ throw new Error('Clip bytes do not match the signed manifest.');
715
+ checkMp4Header(bytes, video.width, video.height);
716
+ };
717
+ const result = async (source, bytes) => {
718
+ const uri = files ? await storage.getAssetFileUri(video.sha256, video.mime) : null;
719
+ if (files && typeof uri !== 'string')
720
+ throw new Error('The verified clip is not available as a local file.');
721
+ return { source, sequence: payload.sequence, message: index ? `Using verified clip from release ${payload.sequence}. ${message}` : source === 'cache' ? 'Verified clip loaded from the local cache.' : 'Downloaded clip; signature, hash and MP4 header verified.',
722
+ sha256: video.sha256, assetId: slot.assetId, mime: 'video/mp4', width: video.width, height: video.height, durationMs: video.durationMs, frameRate: video.frameRate, ...(files ? { uri: uri } : { bytes }) };
723
+ };
724
+ try {
725
+ const cached = await this.cached(video.sha256, policy);
726
+ throwIfAborted(options.signal);
727
+ if (cached) {
728
+ try {
729
+ verify(cached);
730
+ return await result('cache', cached);
731
+ }
732
+ catch (error) {
733
+ message = error instanceof Error ? error.message : 'Cached clip is invalid.';
734
+ }
735
+ }
736
+ // Older releases may be used only when already cached and reverified.
737
+ if (index !== 0)
738
+ continue;
739
+ const bytes = await this.withTransfer(() => fetchBounded(this.fetcher, new URL(video.url, this.config.manifestUrl).href, video.bytes, this.videoTimeoutMs, video.mime, options.signal, policy !== 'disk'), options.signal);
740
+ throwIfAborted(options.signal);
741
+ verify(bytes);
742
+ await this.retain(video.sha256, bytes, video.mime, policy);
743
+ throwIfAborted(options.signal);
744
+ return await result('remote', bytes);
745
+ }
746
+ catch (error) {
747
+ throwIfAborted(options.signal);
748
+ message = error instanceof Error ? error.message : 'Clip could not be loaded.';
749
+ }
750
+ }
751
+ return poster(message);
752
+ }
683
753
  validBytes(bytes, candidate) {
684
754
  return bytes.byteLength === candidate.bytes && bytes.byteLength <= SDK_LIMITS.assetBytes && hashBytes(bytes) === candidate.sha256 && validRenditionHeader(bytes, candidate);
685
755
  }
package/dist/telemetry.js CHANGED
@@ -28,7 +28,7 @@ export class ObservationReporter {
28
28
  this.fetcher = fetcher;
29
29
  this.interval = boundedInteger(options.flushIntervalMs, 60_000, 2_147_483_647);
30
30
  this.maxBatch = boundedInteger(options.maxBatch, 200, 200);
31
- const sdk = options.sdk ?? { name: 'sdk-core', version: '0.5.3-preview.1' };
31
+ const sdk = options.sdk ?? { name: 'sdk-core', version: '0.6.0-preview.1' };
32
32
  const build = options.build ?? { platform: 'web', appVersion: 'unknown', buildNumber: 'unknown' };
33
33
  if (!version(sdk.name) || !version(sdk.version) || !['ios', 'android', 'web', 'expo'].includes(build.platform) || !version(build.appVersion) || !version(build.buildNumber) || (options.installId !== undefined && !validId(options.installId)))
34
34
  throw new Error('Invalid observation configuration.');
package/dist/types.d.ts CHANGED
@@ -55,8 +55,8 @@ export type AssetStatus = {
55
55
  assetId?: string;
56
56
  };
57
57
  export type AssetMime = 'image/webp' | 'image/png' | 'image/svg+xml';
58
- /** Cache transport MIME types; animation is never an image format candidate. */
59
- export type StorageMime = AssetMime | 'application/json';
58
+ /** Cache transport MIME types; animation and video are never image format candidates. */
59
+ export type StorageMime = AssetMime | 'application/json' | 'video/mp4';
60
60
  export type ResolvedAsset = AssetStatus & {
61
61
  bytes?: Uint8Array;
62
62
  mime?: AssetMime;
@@ -102,6 +102,42 @@ export type ResolvedAnimation = {
102
102
  inPoint: number;
103
103
  outPoint: number;
104
104
  };
105
+ export type VideoDescriptor = {
106
+ format: 'mp4';
107
+ profile: 'h264-loop-v1';
108
+ mime: 'video/mp4';
109
+ sha256: string;
110
+ url: string;
111
+ bytes: number;
112
+ width: number;
113
+ height: number;
114
+ durationMs: number;
115
+ frameRate: number;
116
+ };
117
+ export type ResolvedVideo = {
118
+ source: 'poster';
119
+ sequence: null;
120
+ message: string;
121
+ } | {
122
+ source: 'cache' | 'remote';
123
+ sequence: number;
124
+ message: string;
125
+ sha256: string;
126
+ assetId: string;
127
+ mime: 'video/mp4';
128
+ width: number;
129
+ height: number;
130
+ durationMs: number;
131
+ frameRate: number;
132
+ /** Verified clip bytes, when the storage adapter has no `getAssetFileUri`. */
133
+ bytes?: Uint8Array;
134
+ /** Local file URI of the verified cached clip, from a storage adapter with `getAssetFileUri`. */
135
+ uri?: string;
136
+ };
137
+ export type ResolveVideoOptions = {
138
+ cachePolicy?: CachePolicy;
139
+ signal?: AbortSignal;
140
+ };
105
141
  export type ResolveOptions = {
106
142
  pixelWidth?: number;
107
143
  pixelHeight?: number;
@@ -168,6 +204,7 @@ export type ManifestSlot = AssetDescriptor & {
168
204
  width: number;
169
205
  height: number;
170
206
  animation?: AnimationDescriptor;
207
+ video?: VideoDescriptor;
171
208
  states?: Record<string, AssetDescriptor>;
172
209
  defaultState?: string;
173
210
  variants?: AssetVariants;
@@ -195,6 +232,7 @@ export type ManifestPayload = {
195
232
  schemaVersion: 1;
196
233
  renditionSchemaVersion?: 1;
197
234
  animationSchemaVersion?: 1;
235
+ videoSchemaVersion?: 1;
198
236
  stateSchemaVersion?: 1;
199
237
  catalogSchemaVersion?: 1;
200
238
  variantSchemaVersion?: 1;
@@ -220,6 +258,9 @@ export type AssetStorage = {
220
258
  /** Opaque 64-hex identity; arm/appearance-scoped keys are not the content SHA-256. */
221
259
  getAsset(cacheKey: string): Promise<Uint8Array | null>;
222
260
  putAsset(cacheKey: string, bytes: Uint8Array, mime?: StorageMime): Promise<void>;
261
+ /** Optional: the local file URI of a cached entry, or null. An adapter with this method plays clips from
262
+ * files, so `resolveVideo` returns `uri` instead of bytes and needs the `disk` cache policy. */
263
+ getAssetFileUri?(cacheKey: string, mime: StorageMime): Promise<string | null>;
223
264
  };
224
265
  export type FallbackReason = 'offline' | 'verification' | 'decode' | 'missing' | 'other';
225
266
  export type TelemetryBuild = {
@@ -242,7 +283,8 @@ export type ClientOptions = {
242
283
  storage: AssetStorage;
243
284
  fetch?: typeof globalThis.fetch;
244
285
  allowInsecureLoopback?: boolean;
245
- timeoutMs?: number; /** Decision callback deadline in milliseconds: 100-10000, default 1500. */
286
+ timeoutMs?: number; /** Clip download deadline in milliseconds: 20-30000, default 30000. */
287
+ videoTimeoutMs?: number; /** Decision callback deadline in milliseconds: 100-10000, default 1500. */
246
288
  decisionTimeoutMs?: number;
247
289
  cachePolicy?: CachePolicy;
248
290
  maxConcurrentDownloads?: number;
@@ -0,0 +1,9 @@
1
+ import type { AssetlibConfig } from './types.js';
2
+ /** Largest signed clip, `SDK_LIMITS.videoBytes`. */
3
+ export declare const VIDEO_BYTES: number;
4
+ /** Optional `videoSchemaVersion: 1` and a `video` descriptor on a base placement (envelope `h264-loop-v1`). */
5
+ export declare function validateVideoExtension(payload: Record<string, unknown>, slot: Record<string, unknown>, config: AssetlibConfig): void;
6
+ /** Bounded structural check, run only after byte count and SHA-256 matched: `ftyp` first, `moov` before
7
+ * the first `mdat`, not fragmented, and one track whose one sample entry is `avc1` at the signed size.
8
+ * It decodes no frames; the platform player still has to decode the clip. */
9
+ export declare function checkMp4Header(bytes: Uint8Array, width: number, height: number): void;
package/dist/video.js ADDED
@@ -0,0 +1,96 @@
1
+ /** Largest signed clip, `SDK_LIMITS.videoBytes`. */
2
+ export const VIDEO_BYTES = 4 * 1024 * 1024;
3
+ const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
4
+ const record = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
5
+ const integer = (value, min, max) => Number.isSafeInteger(value) && Number(value) >= min && Number(value) <= max;
6
+ const dimension = (value) => integer(value, 16, 1280) && value % 2 === 0;
7
+ /** Optional `videoSchemaVersion: 1` and a `video` descriptor on a base placement (envelope `h264-loop-v1`). */
8
+ export function validateVideoExtension(payload, slot, config) {
9
+ if (own(payload, 'videoSchemaVersion') && payload.videoSchemaVersion !== 1)
10
+ throw new Error('Unsupported video schema.');
11
+ if (!own(slot, 'video'))
12
+ return;
13
+ const v = slot.video;
14
+ if (payload.videoSchemaVersion !== 1 || !record(v) || v.format !== 'mp4' || v.profile !== 'h264-loop-v1' || v.mime !== 'video/mp4' || typeof v.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(v.sha256) || typeof v.url !== 'string' || !integer(v.bytes, 1, VIDEO_BYTES))
15
+ throw new Error('Invalid signed video descriptor.');
16
+ if (!dimension(v.width) || !dimension(v.height) || v.width * v.height > 921600)
17
+ throw new Error('Video dimensions must be even, 16–1280, and at most 921,600 pixels.');
18
+ if (!integer(v.durationMs, 1000, 10000) || typeof v.frameRate !== 'number' || !Number.isFinite(v.frameRate) || v.frameRate < 1 || v.frameRate > 30)
19
+ throw new Error('Video timing must be 1,000–10,000 ms at 1–30 fps.');
20
+ if (own(slot, 'animation'))
21
+ throw new Error('A placement cannot carry both an animation and a video.');
22
+ if (v.width * Number(slot.height) !== v.height * Number(slot.width))
23
+ throw new Error('Video aspect ratio does not exactly match the placement.');
24
+ const url = new URL(v.url, config.manifestUrl);
25
+ if (url.origin !== new URL(config.manifestUrl).origin || url.username || url.password || url.search || url.hash || url.pathname !== `/api/delivery/${config.orgId}/${config.appId}/assets/${slot.assetId}/videos/${v.sha256}`)
26
+ throw new Error('Video URL is outside the configured app.');
27
+ }
28
+ const headerFailure = (message) => { throw new Error(`Clip header check failed: ${message}`); };
29
+ function children(bytes, view, start, end, budget) {
30
+ const result = [];
31
+ for (let offset = start; offset < end;) {
32
+ if (--budget.boxes < 0)
33
+ headerFailure('too many boxes.');
34
+ if (end - offset < 8)
35
+ headerFailure('truncated box header.');
36
+ let size = view.getUint32(offset), header = 8;
37
+ if (size === 1) {
38
+ if (end - offset < 16)
39
+ headerFailure('truncated box header.');
40
+ size = view.getUint32(offset + 8) * 2 ** 32 + view.getUint32(offset + 12);
41
+ header = 16;
42
+ }
43
+ else if (size === 0)
44
+ size = end - offset;
45
+ if (size < header || size > end - offset)
46
+ headerFailure('a box overruns its parent.');
47
+ result.push({ type: String.fromCharCode(...bytes.subarray(offset + 4, offset + 8)), body: offset + header, end: offset + size });
48
+ offset += size;
49
+ }
50
+ return result;
51
+ }
52
+ /** Bounded structural check, run only after byte count and SHA-256 matched: `ftyp` first, `moov` before
53
+ * the first `mdat`, not fragmented, and one track whose one sample entry is `avc1` at the signed size.
54
+ * It decodes no frames; the platform player still has to decode the clip. */
55
+ export function checkMp4Header(bytes, width, height) {
56
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
57
+ if (bytes.byteLength < 8 || String.fromCharCode(...bytes.subarray(4, 8)) !== 'ftyp')
58
+ headerFailure('the file must start with ftyp.');
59
+ const budget = { boxes: 1024 };
60
+ const top = children(bytes, view, 0, bytes.byteLength, budget);
61
+ let moov;
62
+ let mdat = false;
63
+ for (const box of top) {
64
+ if (box.type === 'moof')
65
+ headerFailure('fragmented MP4 is not supported.');
66
+ if (box.type === 'moov') {
67
+ if (moov)
68
+ headerFailure('more than one moov.');
69
+ moov = box;
70
+ }
71
+ if (box.type === 'mdat') {
72
+ if (!moov)
73
+ headerFailure('moov must come before mdat.');
74
+ mdat = true;
75
+ }
76
+ }
77
+ if (!moov || !mdat)
78
+ headerFailure('moov and mdat are required.');
79
+ const only = (parent, type) => {
80
+ const found = children(bytes, view, parent.body, parent.end, budget).filter(box => box.type === type);
81
+ return found.length === 1 ? found[0] : headerFailure(`expected exactly one ${type}.`);
82
+ };
83
+ if (children(bytes, view, moov.body, moov.end, budget).some(box => box.type === 'mvex'))
84
+ headerFailure('fragmented MP4 is not supported.');
85
+ const stsd = only(only(only(only(only(moov, 'trak'), 'mdia'), 'minf'), 'stbl'), 'stsd');
86
+ if (stsd.end - stsd.body < 8 || view.getUint32(stsd.body + 4) !== 1)
87
+ headerFailure('expected exactly one sample entry.');
88
+ const entries = children(bytes, view, stsd.body + 8, stsd.end, budget);
89
+ if (entries.length !== 1 || entries[0].type !== 'avc1')
90
+ headerFailure('the sample entry must be avc1.');
91
+ // VisualSampleEntry: 8 bytes SampleEntry, 16 bytes predefined/reserved, then 16-bit width and height.
92
+ if (entries[0].end - entries[0].body < 28)
93
+ headerFailure('truncated avc1 sample entry.');
94
+ if (view.getUint16(entries[0].body + 24) !== width || view.getUint16(entries[0].body + 26) !== height)
95
+ headerFailure('avc1 dimensions do not match the signed descriptor.');
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assetlib/sdk-core",
3
- "version": "0.5.3-preview.1",
3
+ "version": "0.6.0-preview.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",