@scalemule/nextjs 0.1.29 → 0.1.30

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/dist/index.js CHANGED
@@ -321,21 +321,93 @@ function buildClientContextHeaders(context) {
321
321
  if (context.userAgent) headers["X-Client-User-Agent"] = context.userAgent;
322
322
  if (context.deviceFingerprint) headers["X-Client-Device-Fingerprint"] = context.deviceFingerprint;
323
323
  if (context.referrer) headers["X-Client-Referrer"] = context.referrer;
324
+ if (context.anonymousId) headers["x-anonymous-id"] = context.anonymousId;
324
325
  return headers;
325
326
  }
327
+ var STORAGE_KEYS = {
328
+ SESSION: "scalemule_session",
329
+ USER_ID: "scalemule_user_id",
330
+ WORKSPACE_ID: "scalemule_workspace_id",
331
+ ANONYMOUS_ID: "scalemule_anonymous_id",
332
+ SESSION_POOL: "scalemule_session_pool",
333
+ ACTIVE_ACCOUNT: "scalemule_active_account",
334
+ KNOWN_ACCOUNTS: "scalemule_known_accounts",
335
+ OFFLINE_QUEUE: "scalemule_offline_queue"
336
+ };
337
+ var LEGACY_ANONYMOUS_ID_KEYS = ["sm_anonymous_id"];
338
+ function generateAnonymousId() {
339
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
340
+ return crypto.randomUUID();
341
+ }
342
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
343
+ const r = Math.random() * 16 | 0;
344
+ const v = c === "x" ? r : r & 3 | 8;
345
+ return v.toString(16);
346
+ });
347
+ }
348
+ var inFlight = /* @__PURE__ */ new WeakMap();
349
+ function ensureAnonymousId(storage) {
350
+ const existing = inFlight.get(storage);
351
+ if (existing) return existing;
352
+ const promise = (async () => {
353
+ try {
354
+ return await resolveAnonymousId(storage);
355
+ } finally {
356
+ inFlight.delete(storage);
357
+ }
358
+ })();
359
+ inFlight.set(storage, promise);
360
+ return promise;
361
+ }
362
+ async function resolveAnonymousId(storage) {
363
+ const canonical = await storage.getItem(STORAGE_KEYS.ANONYMOUS_ID);
364
+ if (canonical) {
365
+ for (const legacyKey of LEGACY_ANONYMOUS_ID_KEYS) {
366
+ const legacy = await storage.getItem(legacyKey);
367
+ if (legacy !== canonical) {
368
+ try {
369
+ await storage.setItem(legacyKey, canonical);
370
+ } catch {
371
+ }
372
+ }
373
+ }
374
+ return canonical;
375
+ }
376
+ for (const legacyKey of LEGACY_ANONYMOUS_ID_KEYS) {
377
+ const legacy = await storage.getItem(legacyKey);
378
+ if (legacy) {
379
+ try {
380
+ await storage.setItem(STORAGE_KEYS.ANONYMOUS_ID, legacy);
381
+ } catch {
382
+ }
383
+ return legacy;
384
+ }
385
+ }
386
+ const fresh = generateAnonymousId();
387
+ try {
388
+ await storage.setItem(STORAGE_KEYS.ANONYMOUS_ID, fresh);
389
+ } catch {
390
+ }
391
+ for (const legacyKey of LEGACY_ANONYMOUS_ID_KEYS) {
392
+ try {
393
+ await storage.setItem(legacyKey, fresh);
394
+ } catch {
395
+ }
396
+ }
397
+ return fresh;
398
+ }
326
399
  var SDK_VERSION = "0.0.1";
327
400
  var DEFAULT_TIMEOUT = 3e4;
328
401
  var DEFAULT_MAX_RETRIES = 2;
329
402
  var DEFAULT_BACKOFF_MS = 300;
330
403
  var MAX_BACKOFF_MS = 3e4;
331
- var SESSION_STORAGE_KEY = "scalemule_session";
332
- var USER_ID_STORAGE_KEY = "scalemule_user_id";
333
- var OFFLINE_QUEUE_KEY = "scalemule_offline_queue";
334
- var WORKSPACE_STORAGE_KEY = "scalemule_workspace_id";
335
- var ANONYMOUS_ID_STORAGE_KEY = "scalemule_anonymous_id";
336
- var SESSION_POOL_KEY = "scalemule_session_pool";
337
- var ACTIVE_ACCOUNT_KEY = "scalemule_active_account";
338
- var KNOWN_ACCOUNTS_KEY = "scalemule_known_accounts";
404
+ var SESSION_STORAGE_KEY = STORAGE_KEYS.SESSION;
405
+ var USER_ID_STORAGE_KEY = STORAGE_KEYS.USER_ID;
406
+ var OFFLINE_QUEUE_KEY = STORAGE_KEYS.OFFLINE_QUEUE;
407
+ var WORKSPACE_STORAGE_KEY = STORAGE_KEYS.WORKSPACE_ID;
408
+ var SESSION_POOL_KEY = STORAGE_KEYS.SESSION_POOL;
409
+ var ACTIVE_ACCOUNT_KEY = STORAGE_KEYS.ACTIVE_ACCOUNT;
410
+ var KNOWN_ACCOUNTS_KEY = STORAGE_KEYS.KNOWN_ACCOUNTS;
339
411
  var GATEWAY_URLS = {
340
412
  dev: "https://api-dev.scalemule.com",
341
413
  prod: "https://api.scalemule.com"
@@ -578,6 +650,7 @@ var ScaleMuleClient = class {
578
650
  this.offlineQueue = null;
579
651
  this.workspaceId = null;
580
652
  this.anonymousId = null;
653
+ this.anonymousIdPromise = null;
581
654
  this.sessionPool = /* @__PURE__ */ new Map();
582
655
  this.knownAccounts = /* @__PURE__ */ new Map();
583
656
  this.refreshPromise = null;
@@ -613,12 +686,7 @@ var ScaleMuleClient = class {
613
686
  if (userId) this.userId = userId;
614
687
  const wsId = await this.storage.getItem(WORKSPACE_STORAGE_KEY);
615
688
  if (wsId) this.workspaceId = wsId;
616
- let anonId = await this.storage.getItem(ANONYMOUS_ID_STORAGE_KEY);
617
- if (!anonId) {
618
- anonId = crypto.randomUUID();
619
- await this.storage.setItem(ANONYMOUS_ID_STORAGE_KEY, anonId);
620
- }
621
- this.anonymousId = anonId;
689
+ await this.ensureAnonymousId();
622
690
  if (this.multiSessionEnabled) {
623
691
  const poolJson = await this.storage.getItem(SESSION_POOL_KEY);
624
692
  if (poolJson) {
@@ -670,7 +738,7 @@ var ScaleMuleClient = class {
670
738
  "[ScaleMule] Initialized, session:",
671
739
  !!this.sessionToken,
672
740
  "anonymousId:",
673
- anonId,
741
+ this.anonymousId,
674
742
  "poolSize:",
675
743
  this.sessionPool.size,
676
744
  "knownAccounts:",
@@ -724,9 +792,38 @@ var ScaleMuleClient = class {
724
792
  isAuthenticated() {
725
793
  return this.sessionToken !== null;
726
794
  }
795
+ /**
796
+ * Sync accessor for the cached anonymous ID. Returns `null` until either
797
+ * `initialize()` has run or `ensureAnonymousId()` has been awaited at
798
+ * least once. Use `ensureAnonymousId()` if you need a guaranteed value.
799
+ */
727
800
  getAnonymousId() {
728
801
  return this.anonymousId;
729
802
  }
803
+ /**
804
+ * Lazy-mint or cache-then-return the anonymous ID.
805
+ *
806
+ * Always returns a usable string. Callers that need to attach the
807
+ * `x-anonymous-id` header before `initialize()` has completed (rare —
808
+ * usually only the very first unauthenticated request from a freshly
809
+ * constructed client) should `await` this rather than relying on
810
+ * `getAnonymousId()`.
811
+ *
812
+ * Concurrent callers receive the same in-flight promise — no risk of
813
+ * two simultaneous first requests minting two distinct IDs.
814
+ */
815
+ async ensureAnonymousId() {
816
+ if (this.anonymousId) return this.anonymousId;
817
+ if (!this.anonymousIdPromise) {
818
+ this.anonymousIdPromise = ensureAnonymousId(this.storage).then((id) => {
819
+ this.anonymousId = id;
820
+ return id;
821
+ }).finally(() => {
822
+ this.anonymousIdPromise = null;
823
+ });
824
+ }
825
+ return this.anonymousIdPromise;
826
+ }
730
827
  isMultiSessionEnabled() {
731
828
  return this.multiSessionEnabled;
732
829
  }
@@ -902,8 +999,16 @@ var ScaleMuleClient = class {
902
999
  if (this.workspaceId) {
903
1000
  headers["x-sm-workspace-id"] = this.workspaceId;
904
1001
  }
905
- if (!this.sessionToken && this.anonymousId) {
906
- headers["x-anonymous-id"] = this.anonymousId;
1002
+ if (!init.skipAuth && !this.sessionToken) {
1003
+ if (!this.anonymousId) {
1004
+ try {
1005
+ await this.ensureAnonymousId();
1006
+ } catch {
1007
+ }
1008
+ }
1009
+ if (this.anonymousId) {
1010
+ headers["x-anonymous-id"] = this.anonymousId;
1011
+ }
907
1012
  }
908
1013
  if (attempt > 0 && (method === "POST" || method === "PUT" || method === "PATCH")) {
909
1014
  if (!idempotencyKey) {
@@ -5706,6 +5811,15 @@ var PhotoService = class extends ServiceModule {
5706
5811
  async delete(id, options) {
5707
5812
  return this.del(`/${id}`, options);
5708
5813
  }
5814
+ async getPresets(options) {
5815
+ return this._get("/presets", options);
5816
+ }
5817
+ async getManifest(id, params, options) {
5818
+ return this._get(this.withQuery(`/${id}/manifest`, params), options);
5819
+ }
5820
+ async getPublicManifest(id, params, options) {
5821
+ return this._get(this.withQuery(`/public/${id}/manifest`, params), options);
5822
+ }
5709
5823
  /**
5710
5824
  * Build an absolute URL for the on-demand transform endpoint.
5711
5825
  *
@@ -5954,6 +6068,592 @@ var PhotoService = class extends ServiceModule {
5954
6068
  return this.get(id);
5955
6069
  }
5956
6070
  };
6071
+ var MEDIA_PRESETS = {
6072
+ hero: { widths: [400, 800, 1200, 1600, 2400] },
6073
+ inline: { widths: [400, 800, 1200] },
6074
+ thumbnail: { widths: [120, 240, 360], fit: "cover" },
6075
+ avatar: { widths: [64, 128, 256], fit: "cover" },
6076
+ logo: { widths: [120, 240, 480] }
6077
+ };
6078
+ var MediaService = class extends ServiceModule {
6079
+ constructor(client, storage, photo, video, audio) {
6080
+ super(client);
6081
+ this.storage = storage;
6082
+ this.photo = photo;
6083
+ this.video = video;
6084
+ this.audio = audio;
6085
+ this.basePath = "";
6086
+ }
6087
+ getPresets() {
6088
+ return MEDIA_PRESETS;
6089
+ }
6090
+ async upload(file, options, requestOptions) {
6091
+ const mimeType = file.type || "application/octet-stream";
6092
+ const kind = detectKind(mimeType);
6093
+ const visibility = resolveVisibility(options);
6094
+ const isPublic = visibility !== "private";
6095
+ const isAnonymous = visibility === "anonymous_visible";
6096
+ const gateOnPipeline = shouldGateOnPipeline(options?.policy);
6097
+ const emit = (phase, progress, fileId) => {
6098
+ options?.onProgress?.({ phase, progress, file_id: fileId });
6099
+ };
6100
+ const uploadOptions = {
6101
+ filename: options?.filename,
6102
+ metadata: options?.metadata,
6103
+ signal: options?.signal,
6104
+ onProgress: (progress) => emit("uploading", progress)
6105
+ };
6106
+ emit("presigning", 0);
6107
+ try {
6108
+ if (isAnonymous) {
6109
+ return await this.uploadAnonymous(file, mimeType, kind, options, requestOptions, uploadOptions, emit);
6110
+ }
6111
+ if (kind === "image" && !options?.skipPhotoRegister) {
6112
+ return await this.uploadImage(
6113
+ file,
6114
+ mimeType,
6115
+ visibility,
6116
+ gateOnPipeline,
6117
+ options,
6118
+ requestOptions,
6119
+ uploadOptions,
6120
+ emit
6121
+ );
6122
+ }
6123
+ if (kind === "video" && visibility === "private") {
6124
+ const result = await this.video.uploadViaStorage(file, uploadOptions, requestOptions);
6125
+ if (result.error || !result.data) {
6126
+ return { data: null, error: result.error };
6127
+ }
6128
+ if (gateOnPipeline) {
6129
+ emit("optimizing", 100, result.data.file_id);
6130
+ await result.data.hls_url_promise;
6131
+ }
6132
+ const info = await this.fetchInfoBestEffort(result.data.file_id, requestOptions);
6133
+ const asset2 = this.buildAsset(
6134
+ info ?? fallbackFileInfo(result.data.file_id, mimeType, visibility, result.data.original_view_url, null),
6135
+ {
6136
+ photoId: null,
6137
+ preset: options?.preset,
6138
+ customWidths: options?.customWidths
6139
+ }
6140
+ );
6141
+ emit("ready", 100, result.data.file_id);
6142
+ return {
6143
+ data: {
6144
+ file_id: result.data.file_id,
6145
+ photo_id: null,
6146
+ original_view_url: result.data.original_view_url,
6147
+ optimized_url_promise: Promise.resolve(null),
6148
+ hls_url_promise: result.data.hls_url_promise,
6149
+ mime_type: mimeType,
6150
+ is_public: false,
6151
+ visibility,
6152
+ cdn_url: null,
6153
+ asset: asset2
6154
+ },
6155
+ error: null
6156
+ };
6157
+ }
6158
+ if (kind === "audio" && visibility === "private") {
6159
+ const result = await this.audio.uploadViaStorage(file, uploadOptions, requestOptions);
6160
+ if (result.error || !result.data) {
6161
+ return { data: null, error: result.error };
6162
+ }
6163
+ const info = await this.fetchInfoBestEffort(result.data.file_id, requestOptions);
6164
+ const asset2 = this.buildAsset(
6165
+ info ?? fallbackFileInfo(result.data.file_id, mimeType, visibility, result.data.original_view_url, null),
6166
+ {
6167
+ photoId: null,
6168
+ preset: options?.preset,
6169
+ customWidths: options?.customWidths
6170
+ }
6171
+ );
6172
+ emit("ready", 100, result.data.file_id);
6173
+ return {
6174
+ data: {
6175
+ file_id: result.data.file_id,
6176
+ photo_id: null,
6177
+ original_view_url: result.data.original_view_url,
6178
+ optimized_url_promise: Promise.resolve(null),
6179
+ hls_url_promise: Promise.resolve(null),
6180
+ mime_type: mimeType,
6181
+ is_public: false,
6182
+ visibility,
6183
+ cdn_url: null,
6184
+ asset: asset2
6185
+ },
6186
+ error: null
6187
+ };
6188
+ }
6189
+ const storageResult = isPublic ? await this.storage.upload(file, {
6190
+ ...uploadOptions,
6191
+ visibility: "app_public",
6192
+ skipCompression: true
6193
+ }) : await this.storage.uploadPrivate(file, uploadOptions);
6194
+ if (storageResult.error || !storageResult.data) {
6195
+ return { data: null, error: storageResult.error };
6196
+ }
6197
+ const fileInfo = storageResult.data;
6198
+ const asset = this.buildAsset(fileInfo, {
6199
+ photoId: null,
6200
+ preset: options?.preset,
6201
+ customWidths: options?.customWidths
6202
+ });
6203
+ emit("ready", 100, fileInfo.id);
6204
+ return {
6205
+ data: {
6206
+ file_id: fileInfo.id,
6207
+ photo_id: null,
6208
+ original_view_url: fileInfo.url ?? null,
6209
+ optimized_url_promise: Promise.resolve(null),
6210
+ hls_url_promise: Promise.resolve(null),
6211
+ mime_type: mimeType,
6212
+ is_public: fileInfo.is_public ?? isPublic,
6213
+ visibility: resolveFileVisibility(fileInfo, visibility),
6214
+ cdn_url: fileInfo.cdn_url ?? null,
6215
+ asset
6216
+ },
6217
+ error: null
6218
+ };
6219
+ } catch (error) {
6220
+ return {
6221
+ data: null,
6222
+ error: normalizeThrownError(error)
6223
+ };
6224
+ }
6225
+ }
6226
+ async get(fileId, options, requestOptions) {
6227
+ const result = await this.storage.getInfo(fileId, requestOptions);
6228
+ if (result.error || !result.data) {
6229
+ return { data: null, error: result.error };
6230
+ }
6231
+ return {
6232
+ data: this.buildAsset(result.data, {
6233
+ photoId: null,
6234
+ preset: options?.preset,
6235
+ customWidths: options?.customWidths
6236
+ }),
6237
+ error: null
6238
+ };
6239
+ }
6240
+ async list(params, requestOptions) {
6241
+ const result = await this.storage.list(params, requestOptions);
6242
+ return {
6243
+ data: result.data.map(
6244
+ (file) => this.buildAsset(file, {
6245
+ photoId: null,
6246
+ preset: params?.preset,
6247
+ customWidths: params?.customWidths
6248
+ })
6249
+ ),
6250
+ metadata: result.metadata,
6251
+ error: result.error
6252
+ };
6253
+ }
6254
+ async delete(fileId, options) {
6255
+ try {
6256
+ const [, storageResult] = await Promise.all([
6257
+ this.photo.delete(fileId, options).catch(() => ({ data: null, error: null })),
6258
+ this.storage.delete(fileId, options)
6259
+ ]);
6260
+ if (storageResult.error && storageResult.error.status !== 404) {
6261
+ return { data: null, error: storageResult.error };
6262
+ }
6263
+ return { data: { deleted: true }, error: null };
6264
+ } catch (error) {
6265
+ return { data: null, error: normalizeThrownError(error) };
6266
+ }
6267
+ }
6268
+ async releaseQuarantine(fileId, reason = "Released via @scalemule/sdk media.releaseQuarantine()", options) {
6269
+ const response = await this.client.post(
6270
+ `/v1/storage/admin/files/${fileId}/scan-override`,
6271
+ { scan_status: "clean", reason },
6272
+ options
6273
+ );
6274
+ if (response.error) {
6275
+ return { data: null, error: response.error };
6276
+ }
6277
+ return this.get(fileId, void 0, options);
6278
+ }
6279
+ async getManifest(fileId, options, requestOptions) {
6280
+ const file = await this.storage.getInfo(fileId, requestOptions);
6281
+ if (file.error || !file.data) {
6282
+ return { data: null, error: file.error };
6283
+ }
6284
+ const manifest = await this.fetchManifest(file.data, options, requestOptions);
6285
+ return { data: manifest, error: null };
6286
+ }
6287
+ buildAsset(file, options, manifest) {
6288
+ const visibility = resolveFileVisibility(file);
6289
+ const photoId = options?.photoId ?? null;
6290
+ const originalUrl = visibility === "anonymous_visible" ? file.cdn_url ?? file.url ?? null : file.url ?? file.cdn_url ?? null;
6291
+ return {
6292
+ file_id: file.id,
6293
+ photo_id: photoId,
6294
+ filename: file.filename,
6295
+ content_type: file.content_type,
6296
+ size_bytes: file.size_bytes,
6297
+ kind: detectKind(file.content_type),
6298
+ visibility,
6299
+ is_public: file.is_public ?? visibility !== "private",
6300
+ scan_status: file.scan_status,
6301
+ created_at: file.created_at,
6302
+ original_url: originalUrl,
6303
+ cdn_url: file.cdn_url ?? null,
6304
+ manifest: manifest ?? this.buildManifest(file, options)
6305
+ };
6306
+ }
6307
+ buildManifest(file, options) {
6308
+ const kind = detectKind(file.content_type);
6309
+ const visibility = resolveFileVisibility(file);
6310
+ const originalUrl = visibility === "anonymous_visible" ? file.cdn_url ?? file.url ?? null : file.url ?? file.cdn_url ?? null;
6311
+ if (!originalUrl) {
6312
+ return null;
6313
+ }
6314
+ if (kind !== "image" || file.content_type === "image/svg+xml") {
6315
+ return {
6316
+ file_id: file.id,
6317
+ content_type: file.content_type,
6318
+ preset: "original",
6319
+ variants: { original: originalUrl },
6320
+ srcset: null,
6321
+ default: originalUrl
6322
+ };
6323
+ }
6324
+ const preset = options?.preset ?? "inline";
6325
+ const spec = resolvePresetSpec(preset, options?.customWidths);
6326
+ const variants = Object.fromEntries(
6327
+ spec.widths.map((width) => [
6328
+ String(width),
6329
+ visibility === "anonymous_visible" ? this.photo.getPublicTransformUrl(file.id, transformOptions(width, spec.fit)) : this.photo.getTransformUrl(file.id, transformOptions(width, spec.fit))
6330
+ ])
6331
+ );
6332
+ const defaultWidth = pickDefaultWidth(spec.widths);
6333
+ const defaultUrl = variants[String(defaultWidth)] ?? Object.values(variants)[0] ?? originalUrl;
6334
+ return {
6335
+ file_id: file.id,
6336
+ content_type: file.content_type,
6337
+ preset,
6338
+ variants,
6339
+ srcset: spec.widths.map((width) => `${variants[String(width)]} ${width}w`).join(", "),
6340
+ default: defaultUrl
6341
+ };
6342
+ }
6343
+ async uploadAnonymous(file, mimeType, kind, options, requestOptions, uploadOptions, emit) {
6344
+ const result = await this.storage.uploadAnonymous(file, uploadOptions);
6345
+ if (result.error || !result.data) {
6346
+ return { data: null, error: result.error };
6347
+ }
6348
+ emit("scanning", 100, result.data.id);
6349
+ const ready = await this.waitForAnonymousReady(result.data.id, options?.signal, emit);
6350
+ if (ready.error) {
6351
+ return { data: null, error: ready.error };
6352
+ }
6353
+ const fileInfo = await this.fetchInfoBestEffort(result.data.id, requestOptions) ?? {
6354
+ ...result.data,
6355
+ cdn_url: ready.cdnUrl ?? result.data.cdn_url,
6356
+ scan_status: ready.scanStatus
6357
+ };
6358
+ let manifest = this.buildManifest(fileInfo, {
6359
+ preset: options?.preset,
6360
+ customWidths: options?.customWidths
6361
+ });
6362
+ if (kind === "image" && fileInfo.content_type !== "image/svg+xml" && options?.preset !== "custom") {
6363
+ emit("optimizing", 100, result.data.id);
6364
+ const persistedManifest = await this.waitForAnonymousManifest(fileInfo.id, options, requestOptions, emit);
6365
+ if (persistedManifest.error) {
6366
+ return { data: null, error: persistedManifest.error };
6367
+ }
6368
+ manifest = persistedManifest.manifest ?? manifest;
6369
+ }
6370
+ const asset = this.buildAsset(
6371
+ fileInfo,
6372
+ {
6373
+ photoId: null,
6374
+ preset: options?.preset,
6375
+ customWidths: options?.customWidths
6376
+ },
6377
+ manifest
6378
+ );
6379
+ emit("ready", 100, result.data.id);
6380
+ return {
6381
+ data: {
6382
+ file_id: result.data.id,
6383
+ photo_id: null,
6384
+ original_view_url: fileInfo.cdn_url ?? null,
6385
+ optimized_url_promise: Promise.resolve(manifest?.default ?? null),
6386
+ hls_url_promise: Promise.resolve(null),
6387
+ mime_type: mimeType,
6388
+ is_public: true,
6389
+ visibility: "anonymous_visible",
6390
+ cdn_url: fileInfo.cdn_url ?? null,
6391
+ asset
6392
+ },
6393
+ error: null
6394
+ };
6395
+ }
6396
+ async uploadImage(file, mimeType, visibility, gateOnPipeline, options, requestOptions, uploadOptions, emit) {
6397
+ if (visibility === "private") {
6398
+ const result = await this.photo.uploadViaStorage(file, uploadOptions, requestOptions);
6399
+ if (result.error || !result.data) {
6400
+ return { data: null, error: result.error };
6401
+ }
6402
+ if (gateOnPipeline) {
6403
+ emit("optimizing", 100, result.data.file_id);
6404
+ await result.data.optimized_url_promise;
6405
+ }
6406
+ const info2 = await this.fetchInfoBestEffort(result.data.file_id, requestOptions);
6407
+ const asset2 = this.buildAsset(
6408
+ info2 ?? fallbackFileInfo(result.data.file_id, mimeType, visibility, result.data.original_view_url, null),
6409
+ {
6410
+ photoId: result.data.photo_id,
6411
+ preset: options?.preset,
6412
+ customWidths: options?.customWidths
6413
+ }
6414
+ );
6415
+ emit("ready", 100, result.data.file_id);
6416
+ return {
6417
+ data: {
6418
+ file_id: result.data.file_id,
6419
+ photo_id: result.data.photo_id,
6420
+ original_view_url: result.data.original_view_url,
6421
+ optimized_url_promise: result.data.optimized_url_promise,
6422
+ hls_url_promise: Promise.resolve(null),
6423
+ mime_type: mimeType,
6424
+ is_public: false,
6425
+ visibility,
6426
+ cdn_url: null,
6427
+ asset: asset2
6428
+ },
6429
+ error: null
6430
+ };
6431
+ }
6432
+ const storageResult = await this.storage.upload(file, {
6433
+ ...uploadOptions,
6434
+ visibility: "app_public",
6435
+ skipCompression: true
6436
+ });
6437
+ if (storageResult.error || !storageResult.data) {
6438
+ return { data: null, error: storageResult.error };
6439
+ }
6440
+ const originalViewUrl = storageResult.data.url ?? null;
6441
+ const registerResult = await this.photo.register({ fileId: storageResult.data.id }, requestOptions);
6442
+ const photoId = registerResult.error || !registerResult.data ? null : registerResult.data.id;
6443
+ const optimizedUrlPromise = photoId ? this.pollPhotoOptimizationComplete(photoId, requestOptions) : Promise.resolve(null);
6444
+ if (gateOnPipeline) {
6445
+ emit("optimizing", 100, storageResult.data.id);
6446
+ await optimizedUrlPromise;
6447
+ }
6448
+ const info = await this.fetchInfoBestEffort(storageResult.data.id, requestOptions) ?? storageResult.data;
6449
+ const asset = this.buildAsset(info, {
6450
+ photoId,
6451
+ preset: options?.preset,
6452
+ customWidths: options?.customWidths
6453
+ });
6454
+ emit("ready", 100, storageResult.data.id);
6455
+ return {
6456
+ data: {
6457
+ file_id: storageResult.data.id,
6458
+ photo_id: photoId,
6459
+ original_view_url: originalViewUrl,
6460
+ optimized_url_promise: optimizedUrlPromise,
6461
+ hls_url_promise: Promise.resolve(null),
6462
+ mime_type: mimeType,
6463
+ is_public: true,
6464
+ visibility,
6465
+ cdn_url: info.cdn_url ?? null,
6466
+ asset
6467
+ },
6468
+ error: null
6469
+ };
6470
+ }
6471
+ async waitForAnonymousReady(fileId, signal, emit) {
6472
+ const delays = [500, 1e3, 1500, 2e3, 2500, 3e3];
6473
+ for (let attempt = 0; attempt < 24; attempt++) {
6474
+ if (signal?.aborted) {
6475
+ return {
6476
+ cdnUrl: null,
6477
+ scanStatus: void 0,
6478
+ error: { code: "aborted", message: "Upload aborted", status: 0 }
6479
+ };
6480
+ }
6481
+ const status = await this.storage.getFileStatus(fileId);
6482
+ if (status.error || !status.data) {
6483
+ await sleep4(delays[Math.min(attempt, delays.length - 1)] ?? 3e3);
6484
+ continue;
6485
+ }
6486
+ const scanStatus = status.data.scan.status;
6487
+ if (status.data.urls.cdn_url) {
6488
+ return { cdnUrl: status.data.urls.cdn_url, scanStatus, error: null };
6489
+ }
6490
+ if (scanStatus === "threat" || scanStatus === "quarantined" || scanStatus === "error") {
6491
+ return {
6492
+ cdnUrl: null,
6493
+ scanStatus,
6494
+ error: scanError(scanStatus)
6495
+ };
6496
+ }
6497
+ emit("scanning", Math.min(99, 10 + attempt * 4), fileId);
6498
+ await sleep4(delays[Math.min(attempt, delays.length - 1)] ?? 3e3);
6499
+ }
6500
+ return {
6501
+ cdnUrl: null,
6502
+ scanStatus: "pending",
6503
+ error: {
6504
+ code: "file_scanning",
6505
+ message: "File scan did not complete in time. Re-fetch the file status and publish once cdn_url appears.",
6506
+ status: 202
6507
+ }
6508
+ };
6509
+ }
6510
+ async fetchInfoBestEffort(fileId, requestOptions) {
6511
+ const result = await this.storage.getInfo(fileId, requestOptions);
6512
+ return result.error || !result.data ? null : result.data;
6513
+ }
6514
+ async fetchManifest(file, options, requestOptions) {
6515
+ const kind = detectKind(file.content_type);
6516
+ const visibility = resolveFileVisibility(file);
6517
+ if (kind !== "image" || file.content_type === "image/svg+xml") {
6518
+ return this.buildManifest(file, options);
6519
+ }
6520
+ if (visibility !== "anonymous_visible" || options?.preset === "custom") {
6521
+ return this.buildManifest(file, options);
6522
+ }
6523
+ const persisted = await this.photo.getPublicManifest(
6524
+ file.id,
6525
+ { preset: options?.preset ?? "inline" },
6526
+ requestOptions
6527
+ );
6528
+ if (persisted.error || !persisted.data) {
6529
+ return this.buildManifest(file, options);
6530
+ }
6531
+ return persisted.data;
6532
+ }
6533
+ async pollPhotoOptimizationComplete(photoId, requestOptions) {
6534
+ for (let attempt = 0; attempt < 40; attempt++) {
6535
+ const result = await this.photo.get(photoId, requestOptions);
6536
+ const status = result.data?.optimization_status;
6537
+ if (status === "completed") {
6538
+ return this.photo.getTransformUrl(photoId, { width: 1200 });
6539
+ }
6540
+ await sleep4(250);
6541
+ }
6542
+ return null;
6543
+ }
6544
+ async waitForAnonymousManifest(fileId, options, requestOptions, emit) {
6545
+ const preset = options?.preset ?? "inline";
6546
+ const delays = [500, 1e3, 1500, 2e3, 2500, 3e3];
6547
+ for (let attempt = 0; attempt < 30; attempt++) {
6548
+ if (options?.signal?.aborted) {
6549
+ return {
6550
+ manifest: null,
6551
+ error: { code: "aborted", message: "Upload aborted", status: 0 }
6552
+ };
6553
+ }
6554
+ const manifest = await this.photo.getPublicManifest(fileId, { preset }, requestOptions);
6555
+ if (!manifest.error && manifest.data && manifest.data.ready) {
6556
+ return { manifest: manifest.data, error: null };
6557
+ }
6558
+ emit("optimizing", Math.min(99, 15 + attempt * 3), fileId);
6559
+ await sleep4(delays[Math.min(attempt, delays.length - 1)] ?? 3e3);
6560
+ }
6561
+ return {
6562
+ manifest: null,
6563
+ error: {
6564
+ code: "manifest_pending",
6565
+ message: "Photo variants are still being generated. Re-fetch the public manifest once ready flips true.",
6566
+ status: 202
6567
+ }
6568
+ };
6569
+ }
6570
+ };
6571
+ function detectKind(contentType) {
6572
+ if (contentType.startsWith("image/")) return "image";
6573
+ if (contentType.startsWith("video/")) return "video";
6574
+ if (contentType.startsWith("audio/")) return "audio";
6575
+ if (contentType.includes("pdf") || contentType.includes("text/") || contentType.includes("word") || contentType.includes("sheet") || contentType.includes("presentation")) {
6576
+ return "document";
6577
+ }
6578
+ return "other";
6579
+ }
6580
+ function resolveVisibility(options) {
6581
+ if (options?.visibility) return options.visibility;
6582
+ if (options?.isPublic === true) return "app_public";
6583
+ return "private";
6584
+ }
6585
+ function resolveFileVisibility(file, fallback = "private") {
6586
+ if (file.visibility) return file.visibility;
6587
+ if (file.is_public === true) return "app_public";
6588
+ return fallback;
6589
+ }
6590
+ function shouldGateOnPipeline(policy) {
6591
+ return policy === "safe_public" || policy === "moderated" || policy === "compliance";
6592
+ }
6593
+ function resolvePresetSpec(preset, customWidths) {
6594
+ if (preset === "custom") {
6595
+ const widths = Array.from(
6596
+ new Set((customWidths ?? []).filter((value) => Number.isFinite(value) && value > 0))
6597
+ ).sort((a, b) => a - b);
6598
+ return { widths: widths.length > 0 ? widths : MEDIA_PRESETS.inline.widths };
6599
+ }
6600
+ return MEDIA_PRESETS[preset];
6601
+ }
6602
+ function transformOptions(width, fit) {
6603
+ return fit === "cover" ? { width, height: width, fit: "cover" } : { width, fit: fit ?? "contain" };
6604
+ }
6605
+ function pickDefaultWidth(widths) {
6606
+ if (widths.includes(1200)) return 1200;
6607
+ return widths[Math.floor(widths.length / 2)] ?? widths[0] ?? 0;
6608
+ }
6609
+ function fallbackFileInfo(fileId, contentType, visibility, originalUrl, cdnUrl) {
6610
+ return {
6611
+ id: fileId,
6612
+ filename: fileId,
6613
+ content_type: contentType,
6614
+ size_bytes: 0,
6615
+ is_public: visibility !== "private",
6616
+ visibility,
6617
+ cdn_url: cdnUrl ?? void 0,
6618
+ url: originalUrl ?? void 0,
6619
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
6620
+ };
6621
+ }
6622
+ function normalizeThrownError(error) {
6623
+ if (typeof error === "object" && error && "code" in error && "message" in error) {
6624
+ return error;
6625
+ }
6626
+ return {
6627
+ code: "upload_error",
6628
+ message: error instanceof Error ? error.message : "Media operation failed",
6629
+ status: 500
6630
+ };
6631
+ }
6632
+ function scanError(status) {
6633
+ switch (status) {
6634
+ case "threat":
6635
+ return {
6636
+ code: "file_threat",
6637
+ message: "File was flagged as malicious during scanning.",
6638
+ status: 409
6639
+ };
6640
+ case "quarantined":
6641
+ return {
6642
+ code: "file_quarantined",
6643
+ message: "File was quarantined during scanning.",
6644
+ status: 409
6645
+ };
6646
+ default:
6647
+ return {
6648
+ code: "upload_error",
6649
+ message: "File scan failed.",
6650
+ status: 500
6651
+ };
6652
+ }
6653
+ }
6654
+ function sleep4(ms) {
6655
+ return new Promise((resolve) => setTimeout(resolve, ms));
6656
+ }
5957
6657
  var AudioService = class extends ServiceModule {
5958
6658
  /**
5959
6659
  * @param storage Required for {@link uploadViaStorage}. Wired up by the
@@ -6867,6 +7567,7 @@ var ScaleMule = class {
6867
7567
  this.functions = new FunctionsService(this._client);
6868
7568
  this.photo = new PhotoService(this._client, this.storage);
6869
7569
  this.audio = new AudioService(this._client, this.storage);
7570
+ this.media = new MediaService(this._client, this.storage, this.photo, this.video, this.audio);
6870
7571
  this.tts = new TtsService(this._client);
6871
7572
  this.flagContent = new FlagContentService(this._client);
6872
7573
  this.creatorMaker = new CreatorMakerService(this._client);
@@ -7017,9 +7718,9 @@ var GATEWAY_URLS2 = {
7017
7718
  dev: "https://api-dev.scalemule.com",
7018
7719
  prod: "https://api.scalemule.com"
7019
7720
  };
7020
- var SESSION_STORAGE_KEY2 = "scalemule_session";
7021
- var USER_ID_STORAGE_KEY2 = "scalemule_user_id";
7022
- var WORKSPACE_STORAGE_KEY2 = "scalemule_workspace_id";
7721
+ var SESSION_STORAGE_KEY2 = STORAGE_KEYS.SESSION;
7722
+ var USER_ID_STORAGE_KEY2 = STORAGE_KEYS.USER_ID;
7723
+ var WORKSPACE_STORAGE_KEY2 = STORAGE_KEYS.WORKSPACE_ID;
7023
7724
  var RETRYABLE_STATUS_CODES3 = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
7024
7725
  function sleep3(ms) {
7025
7726
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -7279,6 +7980,15 @@ var ScaleMuleClient2 = class {
7279
7980
  this.sessionGate = null;
7280
7981
  this.resolveSessionGate = null;
7281
7982
  this.workspaceId = null;
7983
+ // Anonymous visitor ID for identity linking. See `@scalemule/sdk/anonymous-id`
7984
+ // for the contract. The base SDK and this client share the same canonical
7985
+ // localStorage key so a visitor has exactly one ID across all packages.
7986
+ this.anonymousId = null;
7987
+ // Per-client single-flight cache for the lazy mint. Concurrent first-use
7988
+ // callers receive the same promise — without this, two simultaneous
7989
+ // unauthenticated requests on a fresh visitor could mint different IDs.
7990
+ // The shared helper also single-flights at the storage-adapter level.
7991
+ this.anonymousIdPromise = null;
7282
7992
  this.refreshPromise = null;
7283
7993
  this.apiKey = config.apiKey;
7284
7994
  this.applicationId = config.applicationId || null;
@@ -7415,6 +8125,10 @@ var ScaleMuleClient2 = class {
7415
8125
  if (userId) this.userId = userId;
7416
8126
  const wsId = await this.storage.getItem(WORKSPACE_STORAGE_KEY2);
7417
8127
  if (wsId) this.workspaceId = wsId;
8128
+ try {
8129
+ await this.ensureAnonymousId();
8130
+ } catch {
8131
+ }
7418
8132
  if (token) {
7419
8133
  this.resolveSessionPending();
7420
8134
  }
@@ -7422,6 +8136,32 @@ var ScaleMuleClient2 = class {
7422
8136
  console.log("[ScaleMule] Initialized with session:", !!token);
7423
8137
  }
7424
8138
  }
8139
+ /**
8140
+ * Sync accessor for the cached anonymous ID. Returns `null` until either
8141
+ * `initialize()` has run or `ensureAnonymousId()` has been awaited at
8142
+ * least once. Use `ensureAnonymousId()` if you need a guaranteed value.
8143
+ */
8144
+ getAnonymousId() {
8145
+ return this.anonymousId;
8146
+ }
8147
+ /**
8148
+ * Lazy-mint or cache-then-return the anonymous ID via the shared
8149
+ * `@scalemule/sdk` helper. Concurrent callers receive the same in-flight
8150
+ * promise, so a burst of simultaneous first-use requests all see the
8151
+ * same minted value.
8152
+ */
8153
+ async ensureAnonymousId() {
8154
+ if (this.anonymousId) return this.anonymousId;
8155
+ if (!this.anonymousIdPromise) {
8156
+ this.anonymousIdPromise = ensureAnonymousId(this.storage).then((id) => {
8157
+ this.anonymousId = id;
8158
+ return id;
8159
+ }).finally(() => {
8160
+ this.anonymousIdPromise = null;
8161
+ });
8162
+ }
8163
+ return this.anonymousIdPromise;
8164
+ }
7425
8165
  /**
7426
8166
  * Set session after login
7427
8167
  */
@@ -7496,6 +8236,9 @@ var ScaleMuleClient2 = class {
7496
8236
  if (this.workspaceId) {
7497
8237
  headers.set("x-sm-workspace-id", this.workspaceId);
7498
8238
  }
8239
+ if (!options?.skipAuth && !this.sessionToken && this.anonymousId) {
8240
+ headers.set("x-anonymous-id", this.anonymousId);
8241
+ }
7499
8242
  if (!headers.has("Content-Type") && options?.body && typeof options.body === "string") {
7500
8243
  headers.set("Content-Type", "application/json");
7501
8244
  }
@@ -7508,6 +8251,12 @@ var ScaleMuleClient2 = class {
7508
8251
  if (this.sessionGate) {
7509
8252
  await this.sessionGate;
7510
8253
  }
8254
+ if (!options.skipAuth && !this.sessionToken && !this.anonymousId) {
8255
+ try {
8256
+ await this.ensureAnonymousId();
8257
+ } catch {
8258
+ }
8259
+ }
7511
8260
  const url = `${this.gatewayUrl}${path}`;
7512
8261
  const headers = this.buildHeaders(options);
7513
8262
  const maxRetries = options.skipRetry ? 0 : options.retries ?? 2;
@@ -8242,6 +8991,19 @@ function getCookie(name) {
8242
8991
  const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
8243
8992
  return match ? decodeURIComponent(match[1]) : void 0;
8244
8993
  }
8994
+ var browserStorage = typeof window !== "undefined" && typeof window.localStorage !== "undefined" ? {
8995
+ getItem: (key) => window.localStorage.getItem(key),
8996
+ setItem: (key, value) => window.localStorage.setItem(key, value),
8997
+ removeItem: (key) => window.localStorage.removeItem(key)
8998
+ } : null;
8999
+ async function getProxyAnonymousId() {
9000
+ if (!browserStorage) return void 0;
9001
+ try {
9002
+ return await ensureAnonymousId(browserStorage);
9003
+ } catch {
9004
+ return void 0;
9005
+ }
9006
+ }
8245
9007
  async function proxyFetch(proxyUrl, path, options = {}) {
8246
9008
  const method = options.method || "POST";
8247
9009
  const headers = {};
@@ -8254,6 +9016,10 @@ async function proxyFetch(proxyUrl, path, options = {}) {
8254
9016
  headers["x-csrf-token"] = csrfToken;
8255
9017
  }
8256
9018
  }
9019
+ const anonymousId = await getProxyAnonymousId();
9020
+ if (anonymousId) {
9021
+ headers["x-anonymous-id"] = anonymousId;
9022
+ }
8257
9023
  const response = await fetch(`${proxyUrl}/${path}`, {
8258
9024
  method,
8259
9025
  headers,
@@ -9725,6 +10491,442 @@ function useTtsJob(jobId, options) {
9725
10491
  }, [jobId, options?.enabled, options?.pollIntervalMs, refresh]);
9726
10492
  return { job, loading, error, refresh };
9727
10493
  }
10494
+ var SPEEDS = [1, 1.25, 1.5, 2];
10495
+ var EXPIRY_REFRESH_WINDOW_MS = 3e4;
10496
+ function formatTime(seconds) {
10497
+ if (!Number.isFinite(seconds) || seconds <= 0) return "0:00";
10498
+ const wholeSeconds = Math.floor(seconds);
10499
+ const minutes = Math.floor(wholeSeconds / 60);
10500
+ const remainder = wholeSeconds % 60;
10501
+ return `${minutes}:${remainder.toString().padStart(2, "0")}`;
10502
+ }
10503
+ function getNextSpeed(currentSpeed) {
10504
+ const currentIndex = SPEEDS.findIndex((speed) => speed === currentSpeed);
10505
+ if (currentIndex === -1) return SPEEDS[0];
10506
+ return SPEEDS[(currentIndex + 1) % SPEEDS.length];
10507
+ }
10508
+ var styles = {
10509
+ root: {
10510
+ borderRadius: "1.75rem",
10511
+ border: "1px solid rgba(226, 232, 240, 0.8)",
10512
+ background: "linear-gradient(180deg, #f8fbff 0%, #edf4ff 100%)",
10513
+ padding: "1rem",
10514
+ color: "#0f172b",
10515
+ boxShadow: "0 22px 70px rgba(4, 12, 24, 0.28)"
10516
+ },
10517
+ layout: {
10518
+ display: "flex",
10519
+ flexWrap: "wrap",
10520
+ alignItems: "center",
10521
+ gap: "1rem"
10522
+ },
10523
+ playButton: {
10524
+ display: "flex",
10525
+ height: "4.5rem",
10526
+ width: "4.5rem",
10527
+ flexShrink: 0,
10528
+ alignItems: "center",
10529
+ justifyContent: "center",
10530
+ borderRadius: "9999px",
10531
+ border: "none",
10532
+ backgroundColor: "#020618",
10533
+ color: "#ffffff",
10534
+ boxShadow: "0 10px 28px rgba(15, 23, 42, 0.25)",
10535
+ cursor: "pointer"
10536
+ },
10537
+ playIcon: { display: "block", width: "1.75rem", height: "1.75rem", fill: "currentColor" },
10538
+ playIconOffset: { marginLeft: "0.25rem" },
10539
+ content: { minWidth: "16rem", flex: "1 1 18rem" },
10540
+ metaRow: {
10541
+ display: "flex",
10542
+ alignItems: "center",
10543
+ justifyContent: "space-between",
10544
+ gap: "0.75rem",
10545
+ fontSize: "0.8rem",
10546
+ fontWeight: 500,
10547
+ color: "#62748e"
10548
+ },
10549
+ metaRight: { display: "flex", alignItems: "center", gap: "0.75rem" },
10550
+ badge: {
10551
+ borderRadius: "9999px",
10552
+ backgroundColor: "#dbeafe",
10553
+ padding: "0.25rem 0.75rem",
10554
+ fontSize: "0.65rem",
10555
+ fontWeight: 600,
10556
+ letterSpacing: "0.24em",
10557
+ textTransform: "uppercase",
10558
+ color: "#1447e6"
10559
+ },
10560
+ scrubber: {
10561
+ position: "relative",
10562
+ marginTop: "1rem",
10563
+ height: "3rem",
10564
+ cursor: "pointer",
10565
+ overflow: "hidden",
10566
+ borderRadius: "1rem",
10567
+ backgroundColor: "rgba(226, 232, 240, 0.9)",
10568
+ paddingInline: "0.25rem"
10569
+ },
10570
+ waveform: {
10571
+ pointerEvents: "none",
10572
+ position: "absolute",
10573
+ inset: 0,
10574
+ height: "100%",
10575
+ width: "100%"
10576
+ },
10577
+ fallbackTrack: { position: "absolute", inset: 0, backgroundColor: "rgba(226, 232, 240, 0.9)" },
10578
+ fallbackProgress: {
10579
+ position: "absolute",
10580
+ insetBlock: 0,
10581
+ left: 0,
10582
+ backgroundColor: "#155dfc"
10583
+ },
10584
+ chipsRow: {
10585
+ marginTop: "1rem",
10586
+ display: "flex",
10587
+ flexWrap: "wrap",
10588
+ alignItems: "center",
10589
+ gap: "0.5rem",
10590
+ fontSize: "0.72rem",
10591
+ fontWeight: 500,
10592
+ color: "#62748e"
10593
+ },
10594
+ chipStrong: {
10595
+ borderRadius: "9999px",
10596
+ border: "1px solid #e2e8f0",
10597
+ backgroundColor: "rgba(255, 255, 255, 0.85)",
10598
+ padding: "0.25rem 0.75rem",
10599
+ color: "#334155"
10600
+ },
10601
+ chipMuted: {
10602
+ borderRadius: "9999px",
10603
+ border: "1px solid #e2e8f0",
10604
+ backgroundColor: "rgba(255, 255, 255, 0.65)",
10605
+ padding: "0.25rem 0.75rem"
10606
+ },
10607
+ controls: {
10608
+ display: "flex",
10609
+ flexShrink: 0,
10610
+ alignItems: "center",
10611
+ gap: "0.5rem",
10612
+ alignSelf: "flex-start"
10613
+ },
10614
+ secondaryButton: {
10615
+ borderRadius: "9999px",
10616
+ border: "1px solid #cad5e2",
10617
+ backgroundColor: "rgba(255, 255, 255, 0.9)",
10618
+ padding: "0.5rem 1rem",
10619
+ fontSize: "1rem",
10620
+ fontWeight: 600,
10621
+ color: "#334155",
10622
+ boxShadow: "0 1px 3px rgba(15, 23, 42, 0.08)",
10623
+ cursor: "pointer"
10624
+ },
10625
+ refreshButton: {
10626
+ borderRadius: "9999px",
10627
+ border: "1px solid #cad5e2",
10628
+ backgroundColor: "rgba(255, 255, 255, 0.75)",
10629
+ padding: "0.5rem 1rem",
10630
+ fontSize: "0.875rem",
10631
+ fontWeight: 600,
10632
+ color: "#334155",
10633
+ cursor: "pointer"
10634
+ },
10635
+ disabled: { cursor: "not-allowed", opacity: 0.6 }
10636
+ };
10637
+ function NarrationPlayer({
10638
+ audio,
10639
+ className,
10640
+ providerLabel = "ScaleMule",
10641
+ narrationLabel = "AI-Narrated",
10642
+ refreshing = false,
10643
+ onRefresh,
10644
+ showRefreshButton = false,
10645
+ onPlaybackError
10646
+ }) {
10647
+ const audioRef = React.useRef(null);
10648
+ const scrubRef = React.useRef(null);
10649
+ const pendingResumeRef = React.useRef(false);
10650
+ const pendingResumeTimeRef = React.useRef(null);
10651
+ const recoveredUrlRef = React.useRef(null);
10652
+ const [playing, setPlaying] = React.useState(false);
10653
+ const [currentTime, setCurrentTime] = React.useState(0);
10654
+ const [duration, setDuration] = React.useState(
10655
+ audio.duration_ms ? audio.duration_ms / 1e3 : 0
10656
+ );
10657
+ const [speed, setSpeed] = React.useState(1.5);
10658
+ const peaks = React.useMemo(() => {
10659
+ if (!Array.isArray(audio.waveform_peaks)) return [];
10660
+ const sampled = audio.waveform_peaks.filter((_, index) => index % 3 === 0).slice(0, 220);
10661
+ return sampled.length > 0 ? sampled : audio.waveform_peaks.slice(0, 220);
10662
+ }, [audio.waveform_peaks]);
10663
+ React.useEffect(() => {
10664
+ const element = audioRef.current;
10665
+ if (!element) return;
10666
+ recoveredUrlRef.current = null;
10667
+ const resumeTime = pendingResumeTimeRef.current;
10668
+ setCurrentTime(resumeTime ?? 0);
10669
+ setPlaying(false);
10670
+ element.pause();
10671
+ try {
10672
+ element.currentTime = resumeTime ?? 0;
10673
+ } catch {
10674
+ element.currentTime = 0;
10675
+ }
10676
+ if (pendingResumeRef.current) {
10677
+ pendingResumeRef.current = false;
10678
+ pendingResumeTimeRef.current = null;
10679
+ element.play().catch(() => {
10680
+ setPlaying(false);
10681
+ onPlaybackError?.();
10682
+ });
10683
+ return;
10684
+ }
10685
+ pendingResumeTimeRef.current = null;
10686
+ }, [audio.url]);
10687
+ React.useEffect(() => {
10688
+ const element = audioRef.current;
10689
+ if (!element) return;
10690
+ element.playbackRate = speed;
10691
+ element.preservesPitch = true;
10692
+ }, [speed]);
10693
+ function isUrlExpiringSoon() {
10694
+ if (!audio.expires_at) return false;
10695
+ const expiresAtMs = Date.parse(audio.expires_at);
10696
+ if (!Number.isFinite(expiresAtMs)) return false;
10697
+ return expiresAtMs - Date.now() <= EXPIRY_REFRESH_WINDOW_MS;
10698
+ }
10699
+ function seekToTime(nextTime) {
10700
+ const element = audioRef.current;
10701
+ if (!element || !duration) return;
10702
+ const clampedTime = Math.max(0, Math.min(duration, nextTime));
10703
+ element.currentTime = clampedTime;
10704
+ setCurrentTime(clampedTime);
10705
+ }
10706
+ async function refreshAndResume() {
10707
+ if (!onRefresh || refreshing) return false;
10708
+ if (recoveredUrlRef.current === (audio.url ?? null)) return false;
10709
+ recoveredUrlRef.current = audio.url ?? null;
10710
+ pendingResumeRef.current = true;
10711
+ pendingResumeTimeRef.current = currentTime;
10712
+ try {
10713
+ await Promise.resolve(onRefresh());
10714
+ return true;
10715
+ } catch {
10716
+ recoveredUrlRef.current = null;
10717
+ pendingResumeRef.current = false;
10718
+ pendingResumeTimeRef.current = null;
10719
+ onPlaybackError?.();
10720
+ return false;
10721
+ }
10722
+ }
10723
+ function togglePlayback() {
10724
+ const element = audioRef.current;
10725
+ if (!element) return;
10726
+ if (element.paused) {
10727
+ if (refreshing) {
10728
+ pendingResumeRef.current = true;
10729
+ pendingResumeTimeRef.current = currentTime;
10730
+ return;
10731
+ }
10732
+ if (onRefresh && isUrlExpiringSoon()) {
10733
+ void refreshAndResume();
10734
+ return;
10735
+ }
10736
+ element.play().catch(() => {
10737
+ setPlaying(false);
10738
+ onPlaybackError?.();
10739
+ });
10740
+ return;
10741
+ }
10742
+ element.pause();
10743
+ }
10744
+ function seekTo(clientX) {
10745
+ const scrubber = scrubRef.current;
10746
+ if (!scrubber || !duration) return;
10747
+ const bounds = scrubber.getBoundingClientRect();
10748
+ const ratio = Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width));
10749
+ seekToTime(ratio * duration);
10750
+ }
10751
+ function handleScrubberKeyDown(event) {
10752
+ if (!duration) return;
10753
+ const step = Math.max(5, duration / 20);
10754
+ switch (event.key) {
10755
+ case "ArrowLeft":
10756
+ case "ArrowDown":
10757
+ event.preventDefault();
10758
+ seekToTime(currentTime - step);
10759
+ break;
10760
+ case "ArrowRight":
10761
+ case "ArrowUp":
10762
+ event.preventDefault();
10763
+ seekToTime(currentTime + step);
10764
+ break;
10765
+ case "Home":
10766
+ event.preventDefault();
10767
+ seekToTime(0);
10768
+ break;
10769
+ case "End":
10770
+ event.preventDefault();
10771
+ seekToTime(duration);
10772
+ break;
10773
+ }
10774
+ }
10775
+ function handleManualRefresh() {
10776
+ if (!onRefresh || refreshing) return;
10777
+ Promise.resolve(onRefresh()).catch(() => {
10778
+ onPlaybackError?.();
10779
+ });
10780
+ }
10781
+ const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
10782
+ const expiresLabel = audio.expires_at ? new Date(audio.expires_at).toLocaleTimeString([], {
10783
+ hour: "numeric",
10784
+ minute: "2-digit"
10785
+ }) : null;
10786
+ if (!audio.url) return null;
10787
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className, style: styles.root, children: [
10788
+ /* @__PURE__ */ jsxRuntime.jsx(
10789
+ "audio",
10790
+ {
10791
+ ref: audioRef,
10792
+ src: audio.url ?? void 0,
10793
+ preload: "metadata",
10794
+ onLoadedMetadata: (event) => {
10795
+ setDuration(event.currentTarget.duration || 0);
10796
+ },
10797
+ onTimeUpdate: (event) => {
10798
+ setCurrentTime(event.currentTarget.currentTime);
10799
+ },
10800
+ onPlay: () => setPlaying(true),
10801
+ onPause: () => setPlaying(false),
10802
+ onEnded: () => setPlaying(false),
10803
+ onError: () => {
10804
+ if (onRefresh) {
10805
+ void refreshAndResume().then((didRefresh) => {
10806
+ if (!didRefresh) onPlaybackError?.();
10807
+ });
10808
+ return;
10809
+ }
10810
+ onPlaybackError?.();
10811
+ }
10812
+ }
10813
+ ),
10814
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.layout, children: [
10815
+ /* @__PURE__ */ jsxRuntime.jsx(
10816
+ "button",
10817
+ {
10818
+ type: "button",
10819
+ onClick: togglePlayback,
10820
+ "aria-label": playing ? "Pause narration" : "Play narration",
10821
+ style: styles.playButton,
10822
+ children: playing ? /* @__PURE__ */ jsxRuntime.jsxs("svg", { viewBox: "0 0 24 24", style: styles.playIcon, "aria-hidden": "true", children: [
10823
+ /* @__PURE__ */ jsxRuntime.jsx("rect", { x: "6", y: "5", width: "4", height: "14", rx: "1.2" }),
10824
+ /* @__PURE__ */ jsxRuntime.jsx("rect", { x: "14", y: "5", width: "4", height: "14", rx: "1.2" })
10825
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx(
10826
+ "svg",
10827
+ {
10828
+ viewBox: "0 0 24 24",
10829
+ style: { ...styles.playIcon, ...styles.playIconOffset },
10830
+ "aria-hidden": "true",
10831
+ children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M7 5.2c0-1.02 1.12-1.65 2-.98l10.2 7.78a1.24 1.24 0 0 1 0 1.98L9 21.76c-.88.67-2-.04-2-1.08V5.2Z" })
10832
+ }
10833
+ )
10834
+ }
10835
+ ),
10836
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.content, children: [
10837
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.metaRow, children: [
10838
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: formatTime(currentTime / speed) }),
10839
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.metaRight, children: [
10840
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.badge, children: narrationLabel }),
10841
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: formatTime(duration / speed) })
10842
+ ] })
10843
+ ] }),
10844
+ /* @__PURE__ */ jsxRuntime.jsx(
10845
+ "div",
10846
+ {
10847
+ ref: scrubRef,
10848
+ role: "slider",
10849
+ "aria-label": "Narration progress",
10850
+ "aria-valuemin": 0,
10851
+ "aria-valuemax": Math.max(1, Math.floor(duration)),
10852
+ "aria-valuenow": Math.floor(currentTime),
10853
+ "aria-valuetext": `${formatTime(currentTime / speed)} of ${formatTime(duration / speed)}`,
10854
+ tabIndex: 0,
10855
+ onClick: (event) => seekTo(event.clientX),
10856
+ onKeyDown: handleScrubberKeyDown,
10857
+ style: styles.scrubber,
10858
+ children: peaks.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(
10859
+ "svg",
10860
+ {
10861
+ viewBox: `0 0 ${peaks.length} 40`,
10862
+ preserveAspectRatio: "none",
10863
+ style: styles.waveform,
10864
+ children: peaks.map((peak, index) => {
10865
+ const height = Math.max(2, Math.round(Math.abs(peak) * 34));
10866
+ const y = (40 - height) / 2;
10867
+ const filled = index / peaks.length < progress;
10868
+ return /* @__PURE__ */ jsxRuntime.jsx(
10869
+ "rect",
10870
+ {
10871
+ x: index,
10872
+ y,
10873
+ width: 0.82,
10874
+ height,
10875
+ rx: 0.4,
10876
+ fill: filled ? "#155dfc" : "#90c5ff"
10877
+ },
10878
+ `${audio.id}-${index}`
10879
+ );
10880
+ })
10881
+ }
10882
+ ) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
10883
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles.fallbackTrack }),
10884
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { ...styles.fallbackProgress, width: `${progress * 100}%` } })
10885
+ ] })
10886
+ }
10887
+ ),
10888
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.chipsRow, children: [
10889
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles.chipStrong, children: [
10890
+ "Provider: ",
10891
+ providerLabel
10892
+ ] }),
10893
+ duration > 0 && /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles.chipMuted, children: [
10894
+ "Length ",
10895
+ formatTime(duration / speed)
10896
+ ] }),
10897
+ expiresLabel && /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles.chipMuted, children: [
10898
+ "Expires ",
10899
+ expiresLabel
10900
+ ] })
10901
+ ] })
10902
+ ] }),
10903
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.controls, children: [
10904
+ /* @__PURE__ */ jsxRuntime.jsxs(
10905
+ "button",
10906
+ {
10907
+ type: "button",
10908
+ onClick: () => setSpeed((currentSpeed) => getNextSpeed(currentSpeed)),
10909
+ style: styles.secondaryButton,
10910
+ children: [
10911
+ speed,
10912
+ "\xD7"
10913
+ ]
10914
+ }
10915
+ ),
10916
+ onRefresh && showRefreshButton && /* @__PURE__ */ jsxRuntime.jsx(
10917
+ "button",
10918
+ {
10919
+ type: "button",
10920
+ onClick: handleManualRefresh,
10921
+ disabled: refreshing,
10922
+ style: refreshing ? { ...styles.refreshButton, ...styles.disabled } : styles.refreshButton,
10923
+ children: refreshing ? "Refreshing\u2026" : "Refresh"
10924
+ }
10925
+ )
10926
+ ] })
10927
+ ] })
10928
+ ] });
10929
+ }
9728
10930
  function useMedia() {
9729
10931
  const { media: rawMedia, mediaPolicy: providerDefaultPolicy } = useScaleMule();
9730
10932
  const media = rawMedia;
@@ -10304,16 +11506,14 @@ function setStorageItem(storage, key, value) {
10304
11506
  } catch {
10305
11507
  }
10306
11508
  }
10307
- function getOrCreateIds(sessionStorageKey, anonymousStorageKey) {
11509
+ function getOrCreateSessionId(sessionStorageKey) {
10308
11510
  if (typeof window === "undefined") {
10309
11511
  return {
10310
11512
  sessionId: null,
10311
- anonymousId: null,
10312
11513
  sessionStart: Date.now()
10313
11514
  };
10314
11515
  }
10315
11516
  const storage = typeof sessionStorage !== "undefined" ? sessionStorage : void 0;
10316
- const localStorage_ = typeof localStorage !== "undefined" ? localStorage : void 0;
10317
11517
  let sessionId = getStorageItem(storage, sessionStorageKey);
10318
11518
  let sessionStartStr = getStorageItem(storage, SESSION_START_KEY);
10319
11519
  let sessionStart;
@@ -10325,12 +11525,7 @@ function getOrCreateIds(sessionStorageKey, anonymousStorageKey) {
10325
11525
  } else {
10326
11526
  sessionStart = parseInt(sessionStartStr, 10);
10327
11527
  }
10328
- let anonymousId = getStorageItem(localStorage_, anonymousStorageKey);
10329
- if (!anonymousId) {
10330
- anonymousId = generateUUID();
10331
- setStorageItem(localStorage_, anonymousStorageKey, anonymousId);
10332
- }
10333
- return { sessionId, anonymousId, sessionStart };
11528
+ return { sessionId, sessionStart };
10334
11529
  }
10335
11530
  function parseUtmParams() {
10336
11531
  if (typeof window === "undefined") return null;
@@ -10441,7 +11636,11 @@ function useAnalytics(options = {}) {
10441
11636
  autoCapturUtmParams,
10442
11637
  autoGenerateSessionId = true,
10443
11638
  sessionStorageKey = "sm_session_id",
10444
- anonymousStorageKey = "sm_anonymous_id",
11639
+ // anonymousStorageKey is intentionally ignored — see UseAnalyticsOptions.
11640
+ // Anonymous ID now lives on the ScaleMule client under the canonical
11641
+ // `scalemule_anonymous_id` key (PR consolidating the contract). Reading
11642
+ // an override here would re-fragment a visitor's identity between
11643
+ // analytics events and auth request headers.
10445
11644
  useV2 = true,
10446
11645
  eventDedupMs = DEFAULT_EVENT_DEDUP_MS
10447
11646
  } = options;
@@ -10467,14 +11666,13 @@ function useAnalytics(options = {}) {
10467
11666
  idsReadyRef.current = true;
10468
11667
  return;
10469
11668
  }
10470
- const ids = getOrCreateIds(sessionStorageKey, anonymousStorageKey);
11669
+ const ids = getOrCreateSessionId(sessionStorageKey);
10471
11670
  sessionIdRef.current = ids.sessionId;
10472
- anonymousIdRef.current = ids.anonymousId;
10473
11671
  sessionStartRef.current = ids.sessionStart;
10474
- idsReadyRef.current = true;
10475
11672
  setSessionId(ids.sessionId);
10476
- setAnonymousId(ids.anonymousId);
10477
- if (eventQueue.current.length > 0) {
11673
+ let cancelled = false;
11674
+ const flushQueuedEvents = () => {
11675
+ if (eventQueue.current.length === 0) return;
10478
11676
  const queue = eventQueue.current;
10479
11677
  eventQueue.current = [];
10480
11678
  setTimeout(() => {
@@ -10482,8 +11680,22 @@ function useAnalytics(options = {}) {
10482
11680
  sendEventRef.current?.(event);
10483
11681
  }
10484
11682
  }, 0);
10485
- }
10486
- }, [autoGenerateSessionId, sessionStorageKey, anonymousStorageKey]);
11683
+ };
11684
+ client.ensureAnonymousId().then((anonId) => {
11685
+ if (cancelled) return;
11686
+ anonymousIdRef.current = anonId;
11687
+ setAnonymousId(anonId);
11688
+ idsReadyRef.current = true;
11689
+ flushQueuedEvents();
11690
+ }).catch(() => {
11691
+ if (cancelled) return;
11692
+ idsReadyRef.current = true;
11693
+ flushQueuedEvents();
11694
+ });
11695
+ return () => {
11696
+ cancelled = true;
11697
+ };
11698
+ }, [autoGenerateSessionId, sessionStorageKey, client]);
10487
11699
  React.useEffect(() => {
10488
11700
  if (typeof window === "undefined") return;
10489
11701
  if (shouldAutoCaptureUtmParams) {
@@ -11214,7 +12426,7 @@ function useVote({
11214
12426
  const [state, setState] = React.useState(initialState ?? ZERO);
11215
12427
  const [isLoading, setIsLoading] = React.useState(!initialState && enabled);
11216
12428
  const [error, setError] = React.useState(null);
11217
- const inFlight = React.useRef(null);
12429
+ const inFlight2 = React.useRef(null);
11218
12430
  const path = `/v1/social/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}/vote`;
11219
12431
  const refetch = React.useCallback(async () => {
11220
12432
  if (!enabled) return;
@@ -11249,10 +12461,10 @@ function useVote({
11249
12461
  setError(e);
11250
12462
  }
11251
12463
  };
11252
- const p = inFlight.current ? inFlight.current.then(send) : send();
11253
- inFlight.current = p;
12464
+ const p = inFlight2.current ? inFlight2.current.then(send) : send();
12465
+ inFlight2.current = p;
11254
12466
  await p;
11255
- if (inFlight.current === p) inFlight.current = null;
12467
+ if (inFlight2.current === p) inFlight2.current = null;
11256
12468
  },
11257
12469
  [client, path, state]
11258
12470
  );
@@ -12064,6 +13276,7 @@ Object.defineProperty(exports, "createMoneyClient", {
12064
13276
  get: function () { return money.createMoneyClient; }
12065
13277
  });
12066
13278
  exports.FeedbackWidget = FeedbackWidget;
13279
+ exports.NarrationPlayer = NarrationPlayer;
12067
13280
  exports.ScaleMuleApiError = ScaleMuleApiError;
12068
13281
  exports.ScaleMuleClient = ScaleMuleClient2;
12069
13282
  exports.ScaleMuleMedia = ScaleMuleMedia;