@scalemule/nextjs 0.1.29 → 0.1.31

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.mjs CHANGED
@@ -2,7 +2,7 @@ import * as React from 'react';
2
2
  import { createContext, useState, useEffect, useMemo, useCallback, useContext, useRef } from 'react';
3
3
  import { createMoneyClient } from '@scalemule/money';
4
4
  export { MoneyClient, createMoneyClient } from '@scalemule/money';
5
- import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
5
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
6
6
 
7
7
  var __defProp = Object.defineProperty;
8
8
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -301,21 +301,93 @@ function buildClientContextHeaders(context) {
301
301
  if (context.userAgent) headers["X-Client-User-Agent"] = context.userAgent;
302
302
  if (context.deviceFingerprint) headers["X-Client-Device-Fingerprint"] = context.deviceFingerprint;
303
303
  if (context.referrer) headers["X-Client-Referrer"] = context.referrer;
304
+ if (context.anonymousId) headers["x-anonymous-id"] = context.anonymousId;
304
305
  return headers;
305
306
  }
307
+ var STORAGE_KEYS = {
308
+ SESSION: "scalemule_session",
309
+ USER_ID: "scalemule_user_id",
310
+ WORKSPACE_ID: "scalemule_workspace_id",
311
+ ANONYMOUS_ID: "scalemule_anonymous_id",
312
+ SESSION_POOL: "scalemule_session_pool",
313
+ ACTIVE_ACCOUNT: "scalemule_active_account",
314
+ KNOWN_ACCOUNTS: "scalemule_known_accounts",
315
+ OFFLINE_QUEUE: "scalemule_offline_queue"
316
+ };
317
+ var LEGACY_ANONYMOUS_ID_KEYS = ["sm_anonymous_id"];
318
+ function generateAnonymousId() {
319
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
320
+ return crypto.randomUUID();
321
+ }
322
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
323
+ const r = Math.random() * 16 | 0;
324
+ const v = c === "x" ? r : r & 3 | 8;
325
+ return v.toString(16);
326
+ });
327
+ }
328
+ var inFlight = /* @__PURE__ */ new WeakMap();
329
+ function ensureAnonymousId(storage) {
330
+ const existing = inFlight.get(storage);
331
+ if (existing) return existing;
332
+ const promise = (async () => {
333
+ try {
334
+ return await resolveAnonymousId(storage);
335
+ } finally {
336
+ inFlight.delete(storage);
337
+ }
338
+ })();
339
+ inFlight.set(storage, promise);
340
+ return promise;
341
+ }
342
+ async function resolveAnonymousId(storage) {
343
+ const canonical = await storage.getItem(STORAGE_KEYS.ANONYMOUS_ID);
344
+ if (canonical) {
345
+ for (const legacyKey of LEGACY_ANONYMOUS_ID_KEYS) {
346
+ const legacy = await storage.getItem(legacyKey);
347
+ if (legacy !== canonical) {
348
+ try {
349
+ await storage.setItem(legacyKey, canonical);
350
+ } catch {
351
+ }
352
+ }
353
+ }
354
+ return canonical;
355
+ }
356
+ for (const legacyKey of LEGACY_ANONYMOUS_ID_KEYS) {
357
+ const legacy = await storage.getItem(legacyKey);
358
+ if (legacy) {
359
+ try {
360
+ await storage.setItem(STORAGE_KEYS.ANONYMOUS_ID, legacy);
361
+ } catch {
362
+ }
363
+ return legacy;
364
+ }
365
+ }
366
+ const fresh = generateAnonymousId();
367
+ try {
368
+ await storage.setItem(STORAGE_KEYS.ANONYMOUS_ID, fresh);
369
+ } catch {
370
+ }
371
+ for (const legacyKey of LEGACY_ANONYMOUS_ID_KEYS) {
372
+ try {
373
+ await storage.setItem(legacyKey, fresh);
374
+ } catch {
375
+ }
376
+ }
377
+ return fresh;
378
+ }
306
379
  var SDK_VERSION = "0.0.1";
307
380
  var DEFAULT_TIMEOUT = 3e4;
308
381
  var DEFAULT_MAX_RETRIES = 2;
309
382
  var DEFAULT_BACKOFF_MS = 300;
310
383
  var MAX_BACKOFF_MS = 3e4;
311
- var SESSION_STORAGE_KEY = "scalemule_session";
312
- var USER_ID_STORAGE_KEY = "scalemule_user_id";
313
- var OFFLINE_QUEUE_KEY = "scalemule_offline_queue";
314
- var WORKSPACE_STORAGE_KEY = "scalemule_workspace_id";
315
- var ANONYMOUS_ID_STORAGE_KEY = "scalemule_anonymous_id";
316
- var SESSION_POOL_KEY = "scalemule_session_pool";
317
- var ACTIVE_ACCOUNT_KEY = "scalemule_active_account";
318
- var KNOWN_ACCOUNTS_KEY = "scalemule_known_accounts";
384
+ var SESSION_STORAGE_KEY = STORAGE_KEYS.SESSION;
385
+ var USER_ID_STORAGE_KEY = STORAGE_KEYS.USER_ID;
386
+ var OFFLINE_QUEUE_KEY = STORAGE_KEYS.OFFLINE_QUEUE;
387
+ var WORKSPACE_STORAGE_KEY = STORAGE_KEYS.WORKSPACE_ID;
388
+ var SESSION_POOL_KEY = STORAGE_KEYS.SESSION_POOL;
389
+ var ACTIVE_ACCOUNT_KEY = STORAGE_KEYS.ACTIVE_ACCOUNT;
390
+ var KNOWN_ACCOUNTS_KEY = STORAGE_KEYS.KNOWN_ACCOUNTS;
319
391
  var GATEWAY_URLS = {
320
392
  dev: "https://api-dev.scalemule.com",
321
393
  prod: "https://api.scalemule.com"
@@ -558,6 +630,7 @@ var ScaleMuleClient = class {
558
630
  this.offlineQueue = null;
559
631
  this.workspaceId = null;
560
632
  this.anonymousId = null;
633
+ this.anonymousIdPromise = null;
561
634
  this.sessionPool = /* @__PURE__ */ new Map();
562
635
  this.knownAccounts = /* @__PURE__ */ new Map();
563
636
  this.refreshPromise = null;
@@ -593,12 +666,7 @@ var ScaleMuleClient = class {
593
666
  if (userId) this.userId = userId;
594
667
  const wsId = await this.storage.getItem(WORKSPACE_STORAGE_KEY);
595
668
  if (wsId) this.workspaceId = wsId;
596
- let anonId = await this.storage.getItem(ANONYMOUS_ID_STORAGE_KEY);
597
- if (!anonId) {
598
- anonId = crypto.randomUUID();
599
- await this.storage.setItem(ANONYMOUS_ID_STORAGE_KEY, anonId);
600
- }
601
- this.anonymousId = anonId;
669
+ await this.ensureAnonymousId();
602
670
  if (this.multiSessionEnabled) {
603
671
  const poolJson = await this.storage.getItem(SESSION_POOL_KEY);
604
672
  if (poolJson) {
@@ -650,7 +718,7 @@ var ScaleMuleClient = class {
650
718
  "[ScaleMule] Initialized, session:",
651
719
  !!this.sessionToken,
652
720
  "anonymousId:",
653
- anonId,
721
+ this.anonymousId,
654
722
  "poolSize:",
655
723
  this.sessionPool.size,
656
724
  "knownAccounts:",
@@ -704,9 +772,38 @@ var ScaleMuleClient = class {
704
772
  isAuthenticated() {
705
773
  return this.sessionToken !== null;
706
774
  }
775
+ /**
776
+ * Sync accessor for the cached anonymous ID. Returns `null` until either
777
+ * `initialize()` has run or `ensureAnonymousId()` has been awaited at
778
+ * least once. Use `ensureAnonymousId()` if you need a guaranteed value.
779
+ */
707
780
  getAnonymousId() {
708
781
  return this.anonymousId;
709
782
  }
783
+ /**
784
+ * Lazy-mint or cache-then-return the anonymous ID.
785
+ *
786
+ * Always returns a usable string. Callers that need to attach the
787
+ * `x-anonymous-id` header before `initialize()` has completed (rare —
788
+ * usually only the very first unauthenticated request from a freshly
789
+ * constructed client) should `await` this rather than relying on
790
+ * `getAnonymousId()`.
791
+ *
792
+ * Concurrent callers receive the same in-flight promise — no risk of
793
+ * two simultaneous first requests minting two distinct IDs.
794
+ */
795
+ async ensureAnonymousId() {
796
+ if (this.anonymousId) return this.anonymousId;
797
+ if (!this.anonymousIdPromise) {
798
+ this.anonymousIdPromise = ensureAnonymousId(this.storage).then((id) => {
799
+ this.anonymousId = id;
800
+ return id;
801
+ }).finally(() => {
802
+ this.anonymousIdPromise = null;
803
+ });
804
+ }
805
+ return this.anonymousIdPromise;
806
+ }
710
807
  isMultiSessionEnabled() {
711
808
  return this.multiSessionEnabled;
712
809
  }
@@ -882,8 +979,16 @@ var ScaleMuleClient = class {
882
979
  if (this.workspaceId) {
883
980
  headers["x-sm-workspace-id"] = this.workspaceId;
884
981
  }
885
- if (!this.sessionToken && this.anonymousId) {
886
- headers["x-anonymous-id"] = this.anonymousId;
982
+ if (!init.skipAuth && !this.sessionToken) {
983
+ if (!this.anonymousId) {
984
+ try {
985
+ await this.ensureAnonymousId();
986
+ } catch {
987
+ }
988
+ }
989
+ if (this.anonymousId) {
990
+ headers["x-anonymous-id"] = this.anonymousId;
991
+ }
887
992
  }
888
993
  if (attempt > 0 && (method === "POST" || method === "PUT" || method === "PATCH")) {
889
994
  if (!idempotencyKey) {
@@ -5686,6 +5791,15 @@ var PhotoService = class extends ServiceModule {
5686
5791
  async delete(id, options) {
5687
5792
  return this.del(`/${id}`, options);
5688
5793
  }
5794
+ async getPresets(options) {
5795
+ return this._get("/presets", options);
5796
+ }
5797
+ async getManifest(id, params, options) {
5798
+ return this._get(this.withQuery(`/${id}/manifest`, params), options);
5799
+ }
5800
+ async getPublicManifest(id, params, options) {
5801
+ return this._get(this.withQuery(`/public/${id}/manifest`, params), options);
5802
+ }
5689
5803
  /**
5690
5804
  * Build an absolute URL for the on-demand transform endpoint.
5691
5805
  *
@@ -5934,6 +6048,592 @@ var PhotoService = class extends ServiceModule {
5934
6048
  return this.get(id);
5935
6049
  }
5936
6050
  };
6051
+ var MEDIA_PRESETS = {
6052
+ hero: { widths: [400, 800, 1200, 1600, 2400] },
6053
+ inline: { widths: [400, 800, 1200] },
6054
+ thumbnail: { widths: [120, 240, 360], fit: "cover" },
6055
+ avatar: { widths: [64, 128, 256], fit: "cover" },
6056
+ logo: { widths: [120, 240, 480] }
6057
+ };
6058
+ var MediaService = class extends ServiceModule {
6059
+ constructor(client, storage, photo, video, audio) {
6060
+ super(client);
6061
+ this.storage = storage;
6062
+ this.photo = photo;
6063
+ this.video = video;
6064
+ this.audio = audio;
6065
+ this.basePath = "";
6066
+ }
6067
+ getPresets() {
6068
+ return MEDIA_PRESETS;
6069
+ }
6070
+ async upload(file, options, requestOptions) {
6071
+ const mimeType = file.type || "application/octet-stream";
6072
+ const kind = detectKind(mimeType);
6073
+ const visibility = resolveVisibility(options);
6074
+ const isPublic = visibility !== "private";
6075
+ const isAnonymous = visibility === "anonymous_visible";
6076
+ const gateOnPipeline = shouldGateOnPipeline(options?.policy);
6077
+ const emit = (phase, progress, fileId) => {
6078
+ options?.onProgress?.({ phase, progress, file_id: fileId });
6079
+ };
6080
+ const uploadOptions = {
6081
+ filename: options?.filename,
6082
+ metadata: options?.metadata,
6083
+ signal: options?.signal,
6084
+ onProgress: (progress) => emit("uploading", progress)
6085
+ };
6086
+ emit("presigning", 0);
6087
+ try {
6088
+ if (isAnonymous) {
6089
+ return await this.uploadAnonymous(file, mimeType, kind, options, requestOptions, uploadOptions, emit);
6090
+ }
6091
+ if (kind === "image" && !options?.skipPhotoRegister) {
6092
+ return await this.uploadImage(
6093
+ file,
6094
+ mimeType,
6095
+ visibility,
6096
+ gateOnPipeline,
6097
+ options,
6098
+ requestOptions,
6099
+ uploadOptions,
6100
+ emit
6101
+ );
6102
+ }
6103
+ if (kind === "video" && visibility === "private") {
6104
+ const result = await this.video.uploadViaStorage(file, uploadOptions, requestOptions);
6105
+ if (result.error || !result.data) {
6106
+ return { data: null, error: result.error };
6107
+ }
6108
+ if (gateOnPipeline) {
6109
+ emit("optimizing", 100, result.data.file_id);
6110
+ await result.data.hls_url_promise;
6111
+ }
6112
+ const info = await this.fetchInfoBestEffort(result.data.file_id, requestOptions);
6113
+ const asset2 = this.buildAsset(
6114
+ info ?? fallbackFileInfo(result.data.file_id, mimeType, visibility, result.data.original_view_url, null),
6115
+ {
6116
+ photoId: null,
6117
+ preset: options?.preset,
6118
+ customWidths: options?.customWidths
6119
+ }
6120
+ );
6121
+ emit("ready", 100, result.data.file_id);
6122
+ return {
6123
+ data: {
6124
+ file_id: result.data.file_id,
6125
+ photo_id: null,
6126
+ original_view_url: result.data.original_view_url,
6127
+ optimized_url_promise: Promise.resolve(null),
6128
+ hls_url_promise: result.data.hls_url_promise,
6129
+ mime_type: mimeType,
6130
+ is_public: false,
6131
+ visibility,
6132
+ cdn_url: null,
6133
+ asset: asset2
6134
+ },
6135
+ error: null
6136
+ };
6137
+ }
6138
+ if (kind === "audio" && visibility === "private") {
6139
+ const result = await this.audio.uploadViaStorage(file, uploadOptions, requestOptions);
6140
+ if (result.error || !result.data) {
6141
+ return { data: null, error: result.error };
6142
+ }
6143
+ const info = await this.fetchInfoBestEffort(result.data.file_id, requestOptions);
6144
+ const asset2 = this.buildAsset(
6145
+ info ?? fallbackFileInfo(result.data.file_id, mimeType, visibility, result.data.original_view_url, null),
6146
+ {
6147
+ photoId: null,
6148
+ preset: options?.preset,
6149
+ customWidths: options?.customWidths
6150
+ }
6151
+ );
6152
+ emit("ready", 100, result.data.file_id);
6153
+ return {
6154
+ data: {
6155
+ file_id: result.data.file_id,
6156
+ photo_id: null,
6157
+ original_view_url: result.data.original_view_url,
6158
+ optimized_url_promise: Promise.resolve(null),
6159
+ hls_url_promise: Promise.resolve(null),
6160
+ mime_type: mimeType,
6161
+ is_public: false,
6162
+ visibility,
6163
+ cdn_url: null,
6164
+ asset: asset2
6165
+ },
6166
+ error: null
6167
+ };
6168
+ }
6169
+ const storageResult = isPublic ? await this.storage.upload(file, {
6170
+ ...uploadOptions,
6171
+ visibility: "app_public",
6172
+ skipCompression: true
6173
+ }) : await this.storage.uploadPrivate(file, uploadOptions);
6174
+ if (storageResult.error || !storageResult.data) {
6175
+ return { data: null, error: storageResult.error };
6176
+ }
6177
+ const fileInfo = storageResult.data;
6178
+ const asset = this.buildAsset(fileInfo, {
6179
+ photoId: null,
6180
+ preset: options?.preset,
6181
+ customWidths: options?.customWidths
6182
+ });
6183
+ emit("ready", 100, fileInfo.id);
6184
+ return {
6185
+ data: {
6186
+ file_id: fileInfo.id,
6187
+ photo_id: null,
6188
+ original_view_url: fileInfo.url ?? null,
6189
+ optimized_url_promise: Promise.resolve(null),
6190
+ hls_url_promise: Promise.resolve(null),
6191
+ mime_type: mimeType,
6192
+ is_public: fileInfo.is_public ?? isPublic,
6193
+ visibility: resolveFileVisibility(fileInfo, visibility),
6194
+ cdn_url: fileInfo.cdn_url ?? null,
6195
+ asset
6196
+ },
6197
+ error: null
6198
+ };
6199
+ } catch (error) {
6200
+ return {
6201
+ data: null,
6202
+ error: normalizeThrownError(error)
6203
+ };
6204
+ }
6205
+ }
6206
+ async get(fileId, options, requestOptions) {
6207
+ const result = await this.storage.getInfo(fileId, requestOptions);
6208
+ if (result.error || !result.data) {
6209
+ return { data: null, error: result.error };
6210
+ }
6211
+ return {
6212
+ data: this.buildAsset(result.data, {
6213
+ photoId: null,
6214
+ preset: options?.preset,
6215
+ customWidths: options?.customWidths
6216
+ }),
6217
+ error: null
6218
+ };
6219
+ }
6220
+ async list(params, requestOptions) {
6221
+ const result = await this.storage.list(params, requestOptions);
6222
+ return {
6223
+ data: result.data.map(
6224
+ (file) => this.buildAsset(file, {
6225
+ photoId: null,
6226
+ preset: params?.preset,
6227
+ customWidths: params?.customWidths
6228
+ })
6229
+ ),
6230
+ metadata: result.metadata,
6231
+ error: result.error
6232
+ };
6233
+ }
6234
+ async delete(fileId, options) {
6235
+ try {
6236
+ const [, storageResult] = await Promise.all([
6237
+ this.photo.delete(fileId, options).catch(() => ({ data: null, error: null })),
6238
+ this.storage.delete(fileId, options)
6239
+ ]);
6240
+ if (storageResult.error && storageResult.error.status !== 404) {
6241
+ return { data: null, error: storageResult.error };
6242
+ }
6243
+ return { data: { deleted: true }, error: null };
6244
+ } catch (error) {
6245
+ return { data: null, error: normalizeThrownError(error) };
6246
+ }
6247
+ }
6248
+ async releaseQuarantine(fileId, reason = "Released via @scalemule/sdk media.releaseQuarantine()", options) {
6249
+ const response = await this.client.post(
6250
+ `/v1/storage/admin/files/${fileId}/scan-override`,
6251
+ { scan_status: "clean", reason },
6252
+ options
6253
+ );
6254
+ if (response.error) {
6255
+ return { data: null, error: response.error };
6256
+ }
6257
+ return this.get(fileId, void 0, options);
6258
+ }
6259
+ async getManifest(fileId, options, requestOptions) {
6260
+ const file = await this.storage.getInfo(fileId, requestOptions);
6261
+ if (file.error || !file.data) {
6262
+ return { data: null, error: file.error };
6263
+ }
6264
+ const manifest = await this.fetchManifest(file.data, options, requestOptions);
6265
+ return { data: manifest, error: null };
6266
+ }
6267
+ buildAsset(file, options, manifest) {
6268
+ const visibility = resolveFileVisibility(file);
6269
+ const photoId = options?.photoId ?? null;
6270
+ const originalUrl = visibility === "anonymous_visible" ? file.cdn_url ?? file.url ?? null : file.url ?? file.cdn_url ?? null;
6271
+ return {
6272
+ file_id: file.id,
6273
+ photo_id: photoId,
6274
+ filename: file.filename,
6275
+ content_type: file.content_type,
6276
+ size_bytes: file.size_bytes,
6277
+ kind: detectKind(file.content_type),
6278
+ visibility,
6279
+ is_public: file.is_public ?? visibility !== "private",
6280
+ scan_status: file.scan_status,
6281
+ created_at: file.created_at,
6282
+ original_url: originalUrl,
6283
+ cdn_url: file.cdn_url ?? null,
6284
+ manifest: manifest ?? this.buildManifest(file, options)
6285
+ };
6286
+ }
6287
+ buildManifest(file, options) {
6288
+ const kind = detectKind(file.content_type);
6289
+ const visibility = resolveFileVisibility(file);
6290
+ const originalUrl = visibility === "anonymous_visible" ? file.cdn_url ?? file.url ?? null : file.url ?? file.cdn_url ?? null;
6291
+ if (!originalUrl) {
6292
+ return null;
6293
+ }
6294
+ if (kind !== "image" || file.content_type === "image/svg+xml") {
6295
+ return {
6296
+ file_id: file.id,
6297
+ content_type: file.content_type,
6298
+ preset: "original",
6299
+ variants: { original: originalUrl },
6300
+ srcset: null,
6301
+ default: originalUrl
6302
+ };
6303
+ }
6304
+ const preset = options?.preset ?? "inline";
6305
+ const spec = resolvePresetSpec(preset, options?.customWidths);
6306
+ const variants = Object.fromEntries(
6307
+ spec.widths.map((width) => [
6308
+ String(width),
6309
+ visibility === "anonymous_visible" ? this.photo.getPublicTransformUrl(file.id, transformOptions(width, spec.fit)) : this.photo.getTransformUrl(file.id, transformOptions(width, spec.fit))
6310
+ ])
6311
+ );
6312
+ const defaultWidth = pickDefaultWidth(spec.widths);
6313
+ const defaultUrl = variants[String(defaultWidth)] ?? Object.values(variants)[0] ?? originalUrl;
6314
+ return {
6315
+ file_id: file.id,
6316
+ content_type: file.content_type,
6317
+ preset,
6318
+ variants,
6319
+ srcset: spec.widths.map((width) => `${variants[String(width)]} ${width}w`).join(", "),
6320
+ default: defaultUrl
6321
+ };
6322
+ }
6323
+ async uploadAnonymous(file, mimeType, kind, options, requestOptions, uploadOptions, emit) {
6324
+ const result = await this.storage.uploadAnonymous(file, uploadOptions);
6325
+ if (result.error || !result.data) {
6326
+ return { data: null, error: result.error };
6327
+ }
6328
+ emit("scanning", 100, result.data.id);
6329
+ const ready = await this.waitForAnonymousReady(result.data.id, options?.signal, emit);
6330
+ if (ready.error) {
6331
+ return { data: null, error: ready.error };
6332
+ }
6333
+ const fileInfo = await this.fetchInfoBestEffort(result.data.id, requestOptions) ?? {
6334
+ ...result.data,
6335
+ cdn_url: ready.cdnUrl ?? result.data.cdn_url,
6336
+ scan_status: ready.scanStatus
6337
+ };
6338
+ let manifest = this.buildManifest(fileInfo, {
6339
+ preset: options?.preset,
6340
+ customWidths: options?.customWidths
6341
+ });
6342
+ if (kind === "image" && fileInfo.content_type !== "image/svg+xml" && options?.preset !== "custom") {
6343
+ emit("optimizing", 100, result.data.id);
6344
+ const persistedManifest = await this.waitForAnonymousManifest(fileInfo.id, options, requestOptions, emit);
6345
+ if (persistedManifest.error) {
6346
+ return { data: null, error: persistedManifest.error };
6347
+ }
6348
+ manifest = persistedManifest.manifest ?? manifest;
6349
+ }
6350
+ const asset = this.buildAsset(
6351
+ fileInfo,
6352
+ {
6353
+ photoId: null,
6354
+ preset: options?.preset,
6355
+ customWidths: options?.customWidths
6356
+ },
6357
+ manifest
6358
+ );
6359
+ emit("ready", 100, result.data.id);
6360
+ return {
6361
+ data: {
6362
+ file_id: result.data.id,
6363
+ photo_id: null,
6364
+ original_view_url: fileInfo.cdn_url ?? null,
6365
+ optimized_url_promise: Promise.resolve(manifest?.default ?? null),
6366
+ hls_url_promise: Promise.resolve(null),
6367
+ mime_type: mimeType,
6368
+ is_public: true,
6369
+ visibility: "anonymous_visible",
6370
+ cdn_url: fileInfo.cdn_url ?? null,
6371
+ asset
6372
+ },
6373
+ error: null
6374
+ };
6375
+ }
6376
+ async uploadImage(file, mimeType, visibility, gateOnPipeline, options, requestOptions, uploadOptions, emit) {
6377
+ if (visibility === "private") {
6378
+ const result = await this.photo.uploadViaStorage(file, uploadOptions, requestOptions);
6379
+ if (result.error || !result.data) {
6380
+ return { data: null, error: result.error };
6381
+ }
6382
+ if (gateOnPipeline) {
6383
+ emit("optimizing", 100, result.data.file_id);
6384
+ await result.data.optimized_url_promise;
6385
+ }
6386
+ const info2 = await this.fetchInfoBestEffort(result.data.file_id, requestOptions);
6387
+ const asset2 = this.buildAsset(
6388
+ info2 ?? fallbackFileInfo(result.data.file_id, mimeType, visibility, result.data.original_view_url, null),
6389
+ {
6390
+ photoId: result.data.photo_id,
6391
+ preset: options?.preset,
6392
+ customWidths: options?.customWidths
6393
+ }
6394
+ );
6395
+ emit("ready", 100, result.data.file_id);
6396
+ return {
6397
+ data: {
6398
+ file_id: result.data.file_id,
6399
+ photo_id: result.data.photo_id,
6400
+ original_view_url: result.data.original_view_url,
6401
+ optimized_url_promise: result.data.optimized_url_promise,
6402
+ hls_url_promise: Promise.resolve(null),
6403
+ mime_type: mimeType,
6404
+ is_public: false,
6405
+ visibility,
6406
+ cdn_url: null,
6407
+ asset: asset2
6408
+ },
6409
+ error: null
6410
+ };
6411
+ }
6412
+ const storageResult = await this.storage.upload(file, {
6413
+ ...uploadOptions,
6414
+ visibility: "app_public",
6415
+ skipCompression: true
6416
+ });
6417
+ if (storageResult.error || !storageResult.data) {
6418
+ return { data: null, error: storageResult.error };
6419
+ }
6420
+ const originalViewUrl = storageResult.data.url ?? null;
6421
+ const registerResult = await this.photo.register({ fileId: storageResult.data.id }, requestOptions);
6422
+ const photoId = registerResult.error || !registerResult.data ? null : registerResult.data.id;
6423
+ const optimizedUrlPromise = photoId ? this.pollPhotoOptimizationComplete(photoId, requestOptions) : Promise.resolve(null);
6424
+ if (gateOnPipeline) {
6425
+ emit("optimizing", 100, storageResult.data.id);
6426
+ await optimizedUrlPromise;
6427
+ }
6428
+ const info = await this.fetchInfoBestEffort(storageResult.data.id, requestOptions) ?? storageResult.data;
6429
+ const asset = this.buildAsset(info, {
6430
+ photoId,
6431
+ preset: options?.preset,
6432
+ customWidths: options?.customWidths
6433
+ });
6434
+ emit("ready", 100, storageResult.data.id);
6435
+ return {
6436
+ data: {
6437
+ file_id: storageResult.data.id,
6438
+ photo_id: photoId,
6439
+ original_view_url: originalViewUrl,
6440
+ optimized_url_promise: optimizedUrlPromise,
6441
+ hls_url_promise: Promise.resolve(null),
6442
+ mime_type: mimeType,
6443
+ is_public: true,
6444
+ visibility,
6445
+ cdn_url: info.cdn_url ?? null,
6446
+ asset
6447
+ },
6448
+ error: null
6449
+ };
6450
+ }
6451
+ async waitForAnonymousReady(fileId, signal, emit) {
6452
+ const delays = [500, 1e3, 1500, 2e3, 2500, 3e3];
6453
+ for (let attempt = 0; attempt < 24; attempt++) {
6454
+ if (signal?.aborted) {
6455
+ return {
6456
+ cdnUrl: null,
6457
+ scanStatus: void 0,
6458
+ error: { code: "aborted", message: "Upload aborted", status: 0 }
6459
+ };
6460
+ }
6461
+ const status = await this.storage.getFileStatus(fileId);
6462
+ if (status.error || !status.data) {
6463
+ await sleep4(delays[Math.min(attempt, delays.length - 1)] ?? 3e3);
6464
+ continue;
6465
+ }
6466
+ const scanStatus = status.data.scan.status;
6467
+ if (status.data.urls.cdn_url) {
6468
+ return { cdnUrl: status.data.urls.cdn_url, scanStatus, error: null };
6469
+ }
6470
+ if (scanStatus === "threat" || scanStatus === "quarantined" || scanStatus === "error") {
6471
+ return {
6472
+ cdnUrl: null,
6473
+ scanStatus,
6474
+ error: scanError(scanStatus)
6475
+ };
6476
+ }
6477
+ emit("scanning", Math.min(99, 10 + attempt * 4), fileId);
6478
+ await sleep4(delays[Math.min(attempt, delays.length - 1)] ?? 3e3);
6479
+ }
6480
+ return {
6481
+ cdnUrl: null,
6482
+ scanStatus: "pending",
6483
+ error: {
6484
+ code: "file_scanning",
6485
+ message: "File scan did not complete in time. Re-fetch the file status and publish once cdn_url appears.",
6486
+ status: 202
6487
+ }
6488
+ };
6489
+ }
6490
+ async fetchInfoBestEffort(fileId, requestOptions) {
6491
+ const result = await this.storage.getInfo(fileId, requestOptions);
6492
+ return result.error || !result.data ? null : result.data;
6493
+ }
6494
+ async fetchManifest(file, options, requestOptions) {
6495
+ const kind = detectKind(file.content_type);
6496
+ const visibility = resolveFileVisibility(file);
6497
+ if (kind !== "image" || file.content_type === "image/svg+xml") {
6498
+ return this.buildManifest(file, options);
6499
+ }
6500
+ if (visibility !== "anonymous_visible" || options?.preset === "custom") {
6501
+ return this.buildManifest(file, options);
6502
+ }
6503
+ const persisted = await this.photo.getPublicManifest(
6504
+ file.id,
6505
+ { preset: options?.preset ?? "inline" },
6506
+ requestOptions
6507
+ );
6508
+ if (persisted.error || !persisted.data) {
6509
+ return this.buildManifest(file, options);
6510
+ }
6511
+ return persisted.data;
6512
+ }
6513
+ async pollPhotoOptimizationComplete(photoId, requestOptions) {
6514
+ for (let attempt = 0; attempt < 40; attempt++) {
6515
+ const result = await this.photo.get(photoId, requestOptions);
6516
+ const status = result.data?.optimization_status;
6517
+ if (status === "completed") {
6518
+ return this.photo.getTransformUrl(photoId, { width: 1200 });
6519
+ }
6520
+ await sleep4(250);
6521
+ }
6522
+ return null;
6523
+ }
6524
+ async waitForAnonymousManifest(fileId, options, requestOptions, emit) {
6525
+ const preset = options?.preset ?? "inline";
6526
+ const delays = [500, 1e3, 1500, 2e3, 2500, 3e3];
6527
+ for (let attempt = 0; attempt < 30; attempt++) {
6528
+ if (options?.signal?.aborted) {
6529
+ return {
6530
+ manifest: null,
6531
+ error: { code: "aborted", message: "Upload aborted", status: 0 }
6532
+ };
6533
+ }
6534
+ const manifest = await this.photo.getPublicManifest(fileId, { preset }, requestOptions);
6535
+ if (!manifest.error && manifest.data && manifest.data.ready) {
6536
+ return { manifest: manifest.data, error: null };
6537
+ }
6538
+ emit("optimizing", Math.min(99, 15 + attempt * 3), fileId);
6539
+ await sleep4(delays[Math.min(attempt, delays.length - 1)] ?? 3e3);
6540
+ }
6541
+ return {
6542
+ manifest: null,
6543
+ error: {
6544
+ code: "manifest_pending",
6545
+ message: "Photo variants are still being generated. Re-fetch the public manifest once ready flips true.",
6546
+ status: 202
6547
+ }
6548
+ };
6549
+ }
6550
+ };
6551
+ function detectKind(contentType) {
6552
+ if (contentType.startsWith("image/")) return "image";
6553
+ if (contentType.startsWith("video/")) return "video";
6554
+ if (contentType.startsWith("audio/")) return "audio";
6555
+ if (contentType.includes("pdf") || contentType.includes("text/") || contentType.includes("word") || contentType.includes("sheet") || contentType.includes("presentation")) {
6556
+ return "document";
6557
+ }
6558
+ return "other";
6559
+ }
6560
+ function resolveVisibility(options) {
6561
+ if (options?.visibility) return options.visibility;
6562
+ if (options?.isPublic === true) return "app_public";
6563
+ return "private";
6564
+ }
6565
+ function resolveFileVisibility(file, fallback = "private") {
6566
+ if (file.visibility) return file.visibility;
6567
+ if (file.is_public === true) return "app_public";
6568
+ return fallback;
6569
+ }
6570
+ function shouldGateOnPipeline(policy) {
6571
+ return policy === "safe_public" || policy === "moderated" || policy === "compliance";
6572
+ }
6573
+ function resolvePresetSpec(preset, customWidths) {
6574
+ if (preset === "custom") {
6575
+ const widths = Array.from(
6576
+ new Set((customWidths ?? []).filter((value) => Number.isFinite(value) && value > 0))
6577
+ ).sort((a, b) => a - b);
6578
+ return { widths: widths.length > 0 ? widths : MEDIA_PRESETS.inline.widths };
6579
+ }
6580
+ return MEDIA_PRESETS[preset];
6581
+ }
6582
+ function transformOptions(width, fit) {
6583
+ return fit === "cover" ? { width, height: width, fit: "cover" } : { width, fit: fit ?? "contain" };
6584
+ }
6585
+ function pickDefaultWidth(widths) {
6586
+ if (widths.includes(1200)) return 1200;
6587
+ return widths[Math.floor(widths.length / 2)] ?? widths[0] ?? 0;
6588
+ }
6589
+ function fallbackFileInfo(fileId, contentType, visibility, originalUrl, cdnUrl) {
6590
+ return {
6591
+ id: fileId,
6592
+ filename: fileId,
6593
+ content_type: contentType,
6594
+ size_bytes: 0,
6595
+ is_public: visibility !== "private",
6596
+ visibility,
6597
+ cdn_url: cdnUrl ?? void 0,
6598
+ url: originalUrl ?? void 0,
6599
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
6600
+ };
6601
+ }
6602
+ function normalizeThrownError(error) {
6603
+ if (typeof error === "object" && error && "code" in error && "message" in error) {
6604
+ return error;
6605
+ }
6606
+ return {
6607
+ code: "upload_error",
6608
+ message: error instanceof Error ? error.message : "Media operation failed",
6609
+ status: 500
6610
+ };
6611
+ }
6612
+ function scanError(status) {
6613
+ switch (status) {
6614
+ case "threat":
6615
+ return {
6616
+ code: "file_threat",
6617
+ message: "File was flagged as malicious during scanning.",
6618
+ status: 409
6619
+ };
6620
+ case "quarantined":
6621
+ return {
6622
+ code: "file_quarantined",
6623
+ message: "File was quarantined during scanning.",
6624
+ status: 409
6625
+ };
6626
+ default:
6627
+ return {
6628
+ code: "upload_error",
6629
+ message: "File scan failed.",
6630
+ status: 500
6631
+ };
6632
+ }
6633
+ }
6634
+ function sleep4(ms) {
6635
+ return new Promise((resolve) => setTimeout(resolve, ms));
6636
+ }
5937
6637
  var AudioService = class extends ServiceModule {
5938
6638
  /**
5939
6639
  * @param storage Required for {@link uploadViaStorage}. Wired up by the
@@ -6847,6 +7547,7 @@ var ScaleMule = class {
6847
7547
  this.functions = new FunctionsService(this._client);
6848
7548
  this.photo = new PhotoService(this._client, this.storage);
6849
7549
  this.audio = new AudioService(this._client, this.storage);
7550
+ this.media = new MediaService(this._client, this.storage, this.photo, this.video, this.audio);
6850
7551
  this.tts = new TtsService(this._client);
6851
7552
  this.flagContent = new FlagContentService(this._client);
6852
7553
  this.creatorMaker = new CreatorMakerService(this._client);
@@ -6997,9 +7698,9 @@ var GATEWAY_URLS2 = {
6997
7698
  dev: "https://api-dev.scalemule.com",
6998
7699
  prod: "https://api.scalemule.com"
6999
7700
  };
7000
- var SESSION_STORAGE_KEY2 = "scalemule_session";
7001
- var USER_ID_STORAGE_KEY2 = "scalemule_user_id";
7002
- var WORKSPACE_STORAGE_KEY2 = "scalemule_workspace_id";
7701
+ var SESSION_STORAGE_KEY2 = STORAGE_KEYS.SESSION;
7702
+ var USER_ID_STORAGE_KEY2 = STORAGE_KEYS.USER_ID;
7703
+ var WORKSPACE_STORAGE_KEY2 = STORAGE_KEYS.WORKSPACE_ID;
7003
7704
  var RETRYABLE_STATUS_CODES3 = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
7004
7705
  function sleep3(ms) {
7005
7706
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -7259,6 +7960,15 @@ var ScaleMuleClient2 = class {
7259
7960
  this.sessionGate = null;
7260
7961
  this.resolveSessionGate = null;
7261
7962
  this.workspaceId = null;
7963
+ // Anonymous visitor ID for identity linking. See `@scalemule/sdk/anonymous-id`
7964
+ // for the contract. The base SDK and this client share the same canonical
7965
+ // localStorage key so a visitor has exactly one ID across all packages.
7966
+ this.anonymousId = null;
7967
+ // Per-client single-flight cache for the lazy mint. Concurrent first-use
7968
+ // callers receive the same promise — without this, two simultaneous
7969
+ // unauthenticated requests on a fresh visitor could mint different IDs.
7970
+ // The shared helper also single-flights at the storage-adapter level.
7971
+ this.anonymousIdPromise = null;
7262
7972
  this.refreshPromise = null;
7263
7973
  this.apiKey = config.apiKey;
7264
7974
  this.applicationId = config.applicationId || null;
@@ -7395,6 +8105,10 @@ var ScaleMuleClient2 = class {
7395
8105
  if (userId) this.userId = userId;
7396
8106
  const wsId = await this.storage.getItem(WORKSPACE_STORAGE_KEY2);
7397
8107
  if (wsId) this.workspaceId = wsId;
8108
+ try {
8109
+ await this.ensureAnonymousId();
8110
+ } catch {
8111
+ }
7398
8112
  if (token) {
7399
8113
  this.resolveSessionPending();
7400
8114
  }
@@ -7402,6 +8116,32 @@ var ScaleMuleClient2 = class {
7402
8116
  console.log("[ScaleMule] Initialized with session:", !!token);
7403
8117
  }
7404
8118
  }
8119
+ /**
8120
+ * Sync accessor for the cached anonymous ID. Returns `null` until either
8121
+ * `initialize()` has run or `ensureAnonymousId()` has been awaited at
8122
+ * least once. Use `ensureAnonymousId()` if you need a guaranteed value.
8123
+ */
8124
+ getAnonymousId() {
8125
+ return this.anonymousId;
8126
+ }
8127
+ /**
8128
+ * Lazy-mint or cache-then-return the anonymous ID via the shared
8129
+ * `@scalemule/sdk` helper. Concurrent callers receive the same in-flight
8130
+ * promise, so a burst of simultaneous first-use requests all see the
8131
+ * same minted value.
8132
+ */
8133
+ async ensureAnonymousId() {
8134
+ if (this.anonymousId) return this.anonymousId;
8135
+ if (!this.anonymousIdPromise) {
8136
+ this.anonymousIdPromise = ensureAnonymousId(this.storage).then((id) => {
8137
+ this.anonymousId = id;
8138
+ return id;
8139
+ }).finally(() => {
8140
+ this.anonymousIdPromise = null;
8141
+ });
8142
+ }
8143
+ return this.anonymousIdPromise;
8144
+ }
7405
8145
  /**
7406
8146
  * Set session after login
7407
8147
  */
@@ -7476,6 +8216,9 @@ var ScaleMuleClient2 = class {
7476
8216
  if (this.workspaceId) {
7477
8217
  headers.set("x-sm-workspace-id", this.workspaceId);
7478
8218
  }
8219
+ if (!options?.skipAuth && !this.sessionToken && this.anonymousId) {
8220
+ headers.set("x-anonymous-id", this.anonymousId);
8221
+ }
7479
8222
  if (!headers.has("Content-Type") && options?.body && typeof options.body === "string") {
7480
8223
  headers.set("Content-Type", "application/json");
7481
8224
  }
@@ -7488,6 +8231,12 @@ var ScaleMuleClient2 = class {
7488
8231
  if (this.sessionGate) {
7489
8232
  await this.sessionGate;
7490
8233
  }
8234
+ if (!options.skipAuth && !this.sessionToken && !this.anonymousId) {
8235
+ try {
8236
+ await this.ensureAnonymousId();
8237
+ } catch {
8238
+ }
8239
+ }
7491
8240
  const url = `${this.gatewayUrl}${path}`;
7492
8241
  const headers = this.buildHeaders(options);
7493
8242
  const maxRetries = options.skipRetry ? 0 : options.retries ?? 2;
@@ -8155,13 +8904,14 @@ function ScaleMuleProvider({
8155
8904
  analyticsProxyUrl,
8156
8905
  authProxyUrl,
8157
8906
  publishableKey,
8907
+ apiKey,
8158
8908
  gatewayUrl: resolvedGatewayUrl,
8159
8909
  environment: environment || void 0,
8160
8910
  enableAccountSwitcher,
8161
8911
  accountSwitcherPrivacy,
8162
8912
  bootstrapFlags
8163
8913
  }),
8164
- [client, money, baseClient, user, handleSetUser, initializing, error, analyticsProxyUrl, authProxyUrl, publishableKey, resolvedGatewayUrl, environment, enableAccountSwitcher, accountSwitcherPrivacy, bootstrapFlags, effectiveMediaPolicy]
8914
+ [client, money, baseClient, user, handleSetUser, initializing, error, analyticsProxyUrl, authProxyUrl, publishableKey, apiKey, resolvedGatewayUrl, environment, enableAccountSwitcher, accountSwitcherPrivacy, bootstrapFlags, effectiveMediaPolicy]
8165
8915
  );
8166
8916
  return /* @__PURE__ */ jsx(ScaleMuleContext.Provider, { value, children });
8167
8917
  }
@@ -8222,6 +8972,19 @@ function getCookie(name) {
8222
8972
  const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
8223
8973
  return match ? decodeURIComponent(match[1]) : void 0;
8224
8974
  }
8975
+ var browserStorage = typeof window !== "undefined" && typeof window.localStorage !== "undefined" ? {
8976
+ getItem: (key) => window.localStorage.getItem(key),
8977
+ setItem: (key, value) => window.localStorage.setItem(key, value),
8978
+ removeItem: (key) => window.localStorage.removeItem(key)
8979
+ } : null;
8980
+ async function getProxyAnonymousId() {
8981
+ if (!browserStorage) return void 0;
8982
+ try {
8983
+ return await ensureAnonymousId(browserStorage);
8984
+ } catch {
8985
+ return void 0;
8986
+ }
8987
+ }
8225
8988
  async function proxyFetch(proxyUrl, path, options = {}) {
8226
8989
  const method = options.method || "POST";
8227
8990
  const headers = {};
@@ -8234,6 +8997,10 @@ async function proxyFetch(proxyUrl, path, options = {}) {
8234
8997
  headers["x-csrf-token"] = csrfToken;
8235
8998
  }
8236
8999
  }
9000
+ const anonymousId = await getProxyAnonymousId();
9001
+ if (anonymousId) {
9002
+ headers["x-anonymous-id"] = anonymousId;
9003
+ }
8237
9004
  const response = await fetch(`${proxyUrl}/${path}`, {
8238
9005
  method,
8239
9006
  headers,
@@ -9705,6 +10472,442 @@ function useTtsJob(jobId, options) {
9705
10472
  }, [jobId, options?.enabled, options?.pollIntervalMs, refresh]);
9706
10473
  return { job, loading, error, refresh };
9707
10474
  }
10475
+ var SPEEDS = [1, 1.25, 1.5, 2];
10476
+ var EXPIRY_REFRESH_WINDOW_MS = 3e4;
10477
+ function formatTime(seconds) {
10478
+ if (!Number.isFinite(seconds) || seconds <= 0) return "0:00";
10479
+ const wholeSeconds = Math.floor(seconds);
10480
+ const minutes = Math.floor(wholeSeconds / 60);
10481
+ const remainder = wholeSeconds % 60;
10482
+ return `${minutes}:${remainder.toString().padStart(2, "0")}`;
10483
+ }
10484
+ function getNextSpeed(currentSpeed) {
10485
+ const currentIndex = SPEEDS.findIndex((speed) => speed === currentSpeed);
10486
+ if (currentIndex === -1) return SPEEDS[0];
10487
+ return SPEEDS[(currentIndex + 1) % SPEEDS.length];
10488
+ }
10489
+ var styles = {
10490
+ root: {
10491
+ borderRadius: "1.75rem",
10492
+ border: "1px solid rgba(226, 232, 240, 0.8)",
10493
+ background: "linear-gradient(180deg, #f8fbff 0%, #edf4ff 100%)",
10494
+ padding: "1rem",
10495
+ color: "#0f172b",
10496
+ boxShadow: "0 22px 70px rgba(4, 12, 24, 0.28)"
10497
+ },
10498
+ layout: {
10499
+ display: "flex",
10500
+ flexWrap: "wrap",
10501
+ alignItems: "center",
10502
+ gap: "1rem"
10503
+ },
10504
+ playButton: {
10505
+ display: "flex",
10506
+ height: "4.5rem",
10507
+ width: "4.5rem",
10508
+ flexShrink: 0,
10509
+ alignItems: "center",
10510
+ justifyContent: "center",
10511
+ borderRadius: "9999px",
10512
+ border: "none",
10513
+ backgroundColor: "#020618",
10514
+ color: "#ffffff",
10515
+ boxShadow: "0 10px 28px rgba(15, 23, 42, 0.25)",
10516
+ cursor: "pointer"
10517
+ },
10518
+ playIcon: { display: "block", width: "1.75rem", height: "1.75rem", fill: "currentColor" },
10519
+ playIconOffset: { marginLeft: "0.25rem" },
10520
+ content: { minWidth: "16rem", flex: "1 1 18rem" },
10521
+ metaRow: {
10522
+ display: "flex",
10523
+ alignItems: "center",
10524
+ justifyContent: "space-between",
10525
+ gap: "0.75rem",
10526
+ fontSize: "0.8rem",
10527
+ fontWeight: 500,
10528
+ color: "#62748e"
10529
+ },
10530
+ metaRight: { display: "flex", alignItems: "center", gap: "0.75rem" },
10531
+ badge: {
10532
+ borderRadius: "9999px",
10533
+ backgroundColor: "#dbeafe",
10534
+ padding: "0.25rem 0.75rem",
10535
+ fontSize: "0.65rem",
10536
+ fontWeight: 600,
10537
+ letterSpacing: "0.24em",
10538
+ textTransform: "uppercase",
10539
+ color: "#1447e6"
10540
+ },
10541
+ scrubber: {
10542
+ position: "relative",
10543
+ marginTop: "1rem",
10544
+ height: "3rem",
10545
+ cursor: "pointer",
10546
+ overflow: "hidden",
10547
+ borderRadius: "1rem",
10548
+ backgroundColor: "rgba(226, 232, 240, 0.9)",
10549
+ paddingInline: "0.25rem"
10550
+ },
10551
+ waveform: {
10552
+ pointerEvents: "none",
10553
+ position: "absolute",
10554
+ inset: 0,
10555
+ height: "100%",
10556
+ width: "100%"
10557
+ },
10558
+ fallbackTrack: { position: "absolute", inset: 0, backgroundColor: "rgba(226, 232, 240, 0.9)" },
10559
+ fallbackProgress: {
10560
+ position: "absolute",
10561
+ insetBlock: 0,
10562
+ left: 0,
10563
+ backgroundColor: "#155dfc"
10564
+ },
10565
+ chipsRow: {
10566
+ marginTop: "1rem",
10567
+ display: "flex",
10568
+ flexWrap: "wrap",
10569
+ alignItems: "center",
10570
+ gap: "0.5rem",
10571
+ fontSize: "0.72rem",
10572
+ fontWeight: 500,
10573
+ color: "#62748e"
10574
+ },
10575
+ chipStrong: {
10576
+ borderRadius: "9999px",
10577
+ border: "1px solid #e2e8f0",
10578
+ backgroundColor: "rgba(255, 255, 255, 0.85)",
10579
+ padding: "0.25rem 0.75rem",
10580
+ color: "#334155"
10581
+ },
10582
+ chipMuted: {
10583
+ borderRadius: "9999px",
10584
+ border: "1px solid #e2e8f0",
10585
+ backgroundColor: "rgba(255, 255, 255, 0.65)",
10586
+ padding: "0.25rem 0.75rem"
10587
+ },
10588
+ controls: {
10589
+ display: "flex",
10590
+ flexShrink: 0,
10591
+ alignItems: "center",
10592
+ gap: "0.5rem",
10593
+ alignSelf: "flex-start"
10594
+ },
10595
+ secondaryButton: {
10596
+ borderRadius: "9999px",
10597
+ border: "1px solid #cad5e2",
10598
+ backgroundColor: "rgba(255, 255, 255, 0.9)",
10599
+ padding: "0.5rem 1rem",
10600
+ fontSize: "1rem",
10601
+ fontWeight: 600,
10602
+ color: "#334155",
10603
+ boxShadow: "0 1px 3px rgba(15, 23, 42, 0.08)",
10604
+ cursor: "pointer"
10605
+ },
10606
+ refreshButton: {
10607
+ borderRadius: "9999px",
10608
+ border: "1px solid #cad5e2",
10609
+ backgroundColor: "rgba(255, 255, 255, 0.75)",
10610
+ padding: "0.5rem 1rem",
10611
+ fontSize: "0.875rem",
10612
+ fontWeight: 600,
10613
+ color: "#334155",
10614
+ cursor: "pointer"
10615
+ },
10616
+ disabled: { cursor: "not-allowed", opacity: 0.6 }
10617
+ };
10618
+ function NarrationPlayer({
10619
+ audio,
10620
+ className,
10621
+ providerLabel = "ScaleMule",
10622
+ narrationLabel = "AI-Narrated",
10623
+ refreshing = false,
10624
+ onRefresh,
10625
+ showRefreshButton = false,
10626
+ onPlaybackError
10627
+ }) {
10628
+ const audioRef = useRef(null);
10629
+ const scrubRef = useRef(null);
10630
+ const pendingResumeRef = useRef(false);
10631
+ const pendingResumeTimeRef = useRef(null);
10632
+ const recoveredUrlRef = useRef(null);
10633
+ const [playing, setPlaying] = useState(false);
10634
+ const [currentTime, setCurrentTime] = useState(0);
10635
+ const [duration, setDuration] = useState(
10636
+ audio.duration_ms ? audio.duration_ms / 1e3 : 0
10637
+ );
10638
+ const [speed, setSpeed] = useState(1.5);
10639
+ const peaks = useMemo(() => {
10640
+ if (!Array.isArray(audio.waveform_peaks)) return [];
10641
+ const sampled = audio.waveform_peaks.filter((_, index) => index % 3 === 0).slice(0, 220);
10642
+ return sampled.length > 0 ? sampled : audio.waveform_peaks.slice(0, 220);
10643
+ }, [audio.waveform_peaks]);
10644
+ useEffect(() => {
10645
+ const element = audioRef.current;
10646
+ if (!element) return;
10647
+ recoveredUrlRef.current = null;
10648
+ const resumeTime = pendingResumeTimeRef.current;
10649
+ setCurrentTime(resumeTime ?? 0);
10650
+ setPlaying(false);
10651
+ element.pause();
10652
+ try {
10653
+ element.currentTime = resumeTime ?? 0;
10654
+ } catch {
10655
+ element.currentTime = 0;
10656
+ }
10657
+ if (pendingResumeRef.current) {
10658
+ pendingResumeRef.current = false;
10659
+ pendingResumeTimeRef.current = null;
10660
+ element.play().catch(() => {
10661
+ setPlaying(false);
10662
+ onPlaybackError?.();
10663
+ });
10664
+ return;
10665
+ }
10666
+ pendingResumeTimeRef.current = null;
10667
+ }, [audio.url]);
10668
+ useEffect(() => {
10669
+ const element = audioRef.current;
10670
+ if (!element) return;
10671
+ element.playbackRate = speed;
10672
+ element.preservesPitch = true;
10673
+ }, [speed]);
10674
+ function isUrlExpiringSoon() {
10675
+ if (!audio.expires_at) return false;
10676
+ const expiresAtMs = Date.parse(audio.expires_at);
10677
+ if (!Number.isFinite(expiresAtMs)) return false;
10678
+ return expiresAtMs - Date.now() <= EXPIRY_REFRESH_WINDOW_MS;
10679
+ }
10680
+ function seekToTime(nextTime) {
10681
+ const element = audioRef.current;
10682
+ if (!element || !duration) return;
10683
+ const clampedTime = Math.max(0, Math.min(duration, nextTime));
10684
+ element.currentTime = clampedTime;
10685
+ setCurrentTime(clampedTime);
10686
+ }
10687
+ async function refreshAndResume() {
10688
+ if (!onRefresh || refreshing) return false;
10689
+ if (recoveredUrlRef.current === (audio.url ?? null)) return false;
10690
+ recoveredUrlRef.current = audio.url ?? null;
10691
+ pendingResumeRef.current = true;
10692
+ pendingResumeTimeRef.current = currentTime;
10693
+ try {
10694
+ await Promise.resolve(onRefresh());
10695
+ return true;
10696
+ } catch {
10697
+ recoveredUrlRef.current = null;
10698
+ pendingResumeRef.current = false;
10699
+ pendingResumeTimeRef.current = null;
10700
+ onPlaybackError?.();
10701
+ return false;
10702
+ }
10703
+ }
10704
+ function togglePlayback() {
10705
+ const element = audioRef.current;
10706
+ if (!element) return;
10707
+ if (element.paused) {
10708
+ if (refreshing) {
10709
+ pendingResumeRef.current = true;
10710
+ pendingResumeTimeRef.current = currentTime;
10711
+ return;
10712
+ }
10713
+ if (onRefresh && isUrlExpiringSoon()) {
10714
+ void refreshAndResume();
10715
+ return;
10716
+ }
10717
+ element.play().catch(() => {
10718
+ setPlaying(false);
10719
+ onPlaybackError?.();
10720
+ });
10721
+ return;
10722
+ }
10723
+ element.pause();
10724
+ }
10725
+ function seekTo(clientX) {
10726
+ const scrubber = scrubRef.current;
10727
+ if (!scrubber || !duration) return;
10728
+ const bounds = scrubber.getBoundingClientRect();
10729
+ const ratio = Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width));
10730
+ seekToTime(ratio * duration);
10731
+ }
10732
+ function handleScrubberKeyDown(event) {
10733
+ if (!duration) return;
10734
+ const step = Math.max(5, duration / 20);
10735
+ switch (event.key) {
10736
+ case "ArrowLeft":
10737
+ case "ArrowDown":
10738
+ event.preventDefault();
10739
+ seekToTime(currentTime - step);
10740
+ break;
10741
+ case "ArrowRight":
10742
+ case "ArrowUp":
10743
+ event.preventDefault();
10744
+ seekToTime(currentTime + step);
10745
+ break;
10746
+ case "Home":
10747
+ event.preventDefault();
10748
+ seekToTime(0);
10749
+ break;
10750
+ case "End":
10751
+ event.preventDefault();
10752
+ seekToTime(duration);
10753
+ break;
10754
+ }
10755
+ }
10756
+ function handleManualRefresh() {
10757
+ if (!onRefresh || refreshing) return;
10758
+ Promise.resolve(onRefresh()).catch(() => {
10759
+ onPlaybackError?.();
10760
+ });
10761
+ }
10762
+ const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
10763
+ const expiresLabel = audio.expires_at ? new Date(audio.expires_at).toLocaleTimeString([], {
10764
+ hour: "numeric",
10765
+ minute: "2-digit"
10766
+ }) : null;
10767
+ if (!audio.url) return null;
10768
+ return /* @__PURE__ */ jsxs("div", { className, style: styles.root, children: [
10769
+ /* @__PURE__ */ jsx(
10770
+ "audio",
10771
+ {
10772
+ ref: audioRef,
10773
+ src: audio.url ?? void 0,
10774
+ preload: "metadata",
10775
+ onLoadedMetadata: (event) => {
10776
+ setDuration(event.currentTarget.duration || 0);
10777
+ },
10778
+ onTimeUpdate: (event) => {
10779
+ setCurrentTime(event.currentTarget.currentTime);
10780
+ },
10781
+ onPlay: () => setPlaying(true),
10782
+ onPause: () => setPlaying(false),
10783
+ onEnded: () => setPlaying(false),
10784
+ onError: () => {
10785
+ if (onRefresh) {
10786
+ void refreshAndResume().then((didRefresh) => {
10787
+ if (!didRefresh) onPlaybackError?.();
10788
+ });
10789
+ return;
10790
+ }
10791
+ onPlaybackError?.();
10792
+ }
10793
+ }
10794
+ ),
10795
+ /* @__PURE__ */ jsxs("div", { style: styles.layout, children: [
10796
+ /* @__PURE__ */ jsx(
10797
+ "button",
10798
+ {
10799
+ type: "button",
10800
+ onClick: togglePlayback,
10801
+ "aria-label": playing ? "Pause narration" : "Play narration",
10802
+ style: styles.playButton,
10803
+ children: playing ? /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", style: styles.playIcon, "aria-hidden": "true", children: [
10804
+ /* @__PURE__ */ jsx("rect", { x: "6", y: "5", width: "4", height: "14", rx: "1.2" }),
10805
+ /* @__PURE__ */ jsx("rect", { x: "14", y: "5", width: "4", height: "14", rx: "1.2" })
10806
+ ] }) : /* @__PURE__ */ jsx(
10807
+ "svg",
10808
+ {
10809
+ viewBox: "0 0 24 24",
10810
+ style: { ...styles.playIcon, ...styles.playIconOffset },
10811
+ "aria-hidden": "true",
10812
+ children: /* @__PURE__ */ 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" })
10813
+ }
10814
+ )
10815
+ }
10816
+ ),
10817
+ /* @__PURE__ */ jsxs("div", { style: styles.content, children: [
10818
+ /* @__PURE__ */ jsxs("div", { style: styles.metaRow, children: [
10819
+ /* @__PURE__ */ jsx("span", { children: formatTime(currentTime / speed) }),
10820
+ /* @__PURE__ */ jsxs("div", { style: styles.metaRight, children: [
10821
+ /* @__PURE__ */ jsx("span", { style: styles.badge, children: narrationLabel }),
10822
+ /* @__PURE__ */ jsx("span", { children: formatTime(duration / speed) })
10823
+ ] })
10824
+ ] }),
10825
+ /* @__PURE__ */ jsx(
10826
+ "div",
10827
+ {
10828
+ ref: scrubRef,
10829
+ role: "slider",
10830
+ "aria-label": "Narration progress",
10831
+ "aria-valuemin": 0,
10832
+ "aria-valuemax": Math.max(1, Math.floor(duration)),
10833
+ "aria-valuenow": Math.floor(currentTime),
10834
+ "aria-valuetext": `${formatTime(currentTime / speed)} of ${formatTime(duration / speed)}`,
10835
+ tabIndex: 0,
10836
+ onClick: (event) => seekTo(event.clientX),
10837
+ onKeyDown: handleScrubberKeyDown,
10838
+ style: styles.scrubber,
10839
+ children: peaks.length > 0 ? /* @__PURE__ */ jsx(
10840
+ "svg",
10841
+ {
10842
+ viewBox: `0 0 ${peaks.length} 40`,
10843
+ preserveAspectRatio: "none",
10844
+ style: styles.waveform,
10845
+ children: peaks.map((peak, index) => {
10846
+ const height = Math.max(2, Math.round(Math.abs(peak) * 34));
10847
+ const y = (40 - height) / 2;
10848
+ const filled = index / peaks.length < progress;
10849
+ return /* @__PURE__ */ jsx(
10850
+ "rect",
10851
+ {
10852
+ x: index,
10853
+ y,
10854
+ width: 0.82,
10855
+ height,
10856
+ rx: 0.4,
10857
+ fill: filled ? "#155dfc" : "#90c5ff"
10858
+ },
10859
+ `${audio.id}-${index}`
10860
+ );
10861
+ })
10862
+ }
10863
+ ) : /* @__PURE__ */ jsxs(Fragment, { children: [
10864
+ /* @__PURE__ */ jsx("div", { style: styles.fallbackTrack }),
10865
+ /* @__PURE__ */ jsx("div", { style: { ...styles.fallbackProgress, width: `${progress * 100}%` } })
10866
+ ] })
10867
+ }
10868
+ ),
10869
+ /* @__PURE__ */ jsxs("div", { style: styles.chipsRow, children: [
10870
+ /* @__PURE__ */ jsxs("span", { style: styles.chipStrong, children: [
10871
+ "Provider: ",
10872
+ providerLabel
10873
+ ] }),
10874
+ duration > 0 && /* @__PURE__ */ jsxs("span", { style: styles.chipMuted, children: [
10875
+ "Length ",
10876
+ formatTime(duration / speed)
10877
+ ] }),
10878
+ expiresLabel && /* @__PURE__ */ jsxs("span", { style: styles.chipMuted, children: [
10879
+ "Expires ",
10880
+ expiresLabel
10881
+ ] })
10882
+ ] })
10883
+ ] }),
10884
+ /* @__PURE__ */ jsxs("div", { style: styles.controls, children: [
10885
+ /* @__PURE__ */ jsxs(
10886
+ "button",
10887
+ {
10888
+ type: "button",
10889
+ onClick: () => setSpeed((currentSpeed) => getNextSpeed(currentSpeed)),
10890
+ style: styles.secondaryButton,
10891
+ children: [
10892
+ speed,
10893
+ "\xD7"
10894
+ ]
10895
+ }
10896
+ ),
10897
+ onRefresh && showRefreshButton && /* @__PURE__ */ jsx(
10898
+ "button",
10899
+ {
10900
+ type: "button",
10901
+ onClick: handleManualRefresh,
10902
+ disabled: refreshing,
10903
+ style: refreshing ? { ...styles.refreshButton, ...styles.disabled } : styles.refreshButton,
10904
+ children: refreshing ? "Refreshing\u2026" : "Refresh"
10905
+ }
10906
+ )
10907
+ ] })
10908
+ ] })
10909
+ ] });
10910
+ }
9708
10911
  function useMedia() {
9709
10912
  const { media: rawMedia, mediaPolicy: providerDefaultPolicy } = useScaleMule();
9710
10913
  const media = rawMedia;
@@ -10284,16 +11487,14 @@ function setStorageItem(storage, key, value) {
10284
11487
  } catch {
10285
11488
  }
10286
11489
  }
10287
- function getOrCreateIds(sessionStorageKey, anonymousStorageKey) {
11490
+ function getOrCreateSessionId(sessionStorageKey) {
10288
11491
  if (typeof window === "undefined") {
10289
11492
  return {
10290
11493
  sessionId: null,
10291
- anonymousId: null,
10292
11494
  sessionStart: Date.now()
10293
11495
  };
10294
11496
  }
10295
11497
  const storage = typeof sessionStorage !== "undefined" ? sessionStorage : void 0;
10296
- const localStorage_ = typeof localStorage !== "undefined" ? localStorage : void 0;
10297
11498
  let sessionId = getStorageItem(storage, sessionStorageKey);
10298
11499
  let sessionStartStr = getStorageItem(storage, SESSION_START_KEY);
10299
11500
  let sessionStart;
@@ -10305,12 +11506,7 @@ function getOrCreateIds(sessionStorageKey, anonymousStorageKey) {
10305
11506
  } else {
10306
11507
  sessionStart = parseInt(sessionStartStr, 10);
10307
11508
  }
10308
- let anonymousId = getStorageItem(localStorage_, anonymousStorageKey);
10309
- if (!anonymousId) {
10310
- anonymousId = generateUUID();
10311
- setStorageItem(localStorage_, anonymousStorageKey, anonymousId);
10312
- }
10313
- return { sessionId, anonymousId, sessionStart };
11509
+ return { sessionId, sessionStart };
10314
11510
  }
10315
11511
  function parseUtmParams() {
10316
11512
  if (typeof window === "undefined") return null;
@@ -10421,12 +11617,27 @@ function useAnalytics(options = {}) {
10421
11617
  autoCapturUtmParams,
10422
11618
  autoGenerateSessionId = true,
10423
11619
  sessionStorageKey = "sm_session_id",
10424
- anonymousStorageKey = "sm_anonymous_id",
11620
+ // anonymousStorageKey is intentionally ignored — see UseAnalyticsOptions.
11621
+ // Anonymous ID now lives on the ScaleMule client under the canonical
11622
+ // `scalemule_anonymous_id` key (PR consolidating the contract). Reading
11623
+ // an override here would re-fragment a visitor's identity between
11624
+ // analytics events and auth request headers.
10425
11625
  useV2 = true,
10426
11626
  eventDedupMs = DEFAULT_EVENT_DEDUP_MS
10427
11627
  } = options;
10428
11628
  const shouldAutoCaptureUtmParams = autoCaptureUtmParams ?? autoCapturUtmParams ?? true;
10429
- const { client, user, analyticsProxyUrl, publishableKey, gatewayUrl } = useScaleMule();
11629
+ const { client, user, analyticsProxyUrl, publishableKey, apiKey, gatewayUrl } = useScaleMule();
11630
+ const proxyModeMisconfigWarnedRef = useRef(false);
11631
+ useEffect(() => {
11632
+ if (process.env.NODE_ENV === "production") return;
11633
+ if (proxyModeMisconfigWarnedRef.current) return;
11634
+ if (apiKey === "proxy-mode" && !analyticsProxyUrl && !publishableKey) {
11635
+ proxyModeMisconfigWarnedRef.current = true;
11636
+ console.warn(
11637
+ '[ScaleMule] useAnalytics: apiKey="proxy-mode" but neither `analyticsProxyUrl` nor `publishableKey` is configured. Every trackEvent / trackPageView will silently 401 against the gateway. Add an analytics proxy route (createAnalyticsRoutes({ simpleProxy: true })) and pass `analyticsProxyUrl` to <ScaleMuleProvider>. See @scalemule/nextjs README \u2192 Proxy Mode.'
11638
+ );
11639
+ }
11640
+ }, [apiKey, analyticsProxyUrl, publishableKey]);
10430
11641
  const [loading, setLoading] = useState(false);
10431
11642
  const [error, setError] = useState(null);
10432
11643
  const [utmParams, setUtmParams] = useState(null);
@@ -10447,14 +11658,13 @@ function useAnalytics(options = {}) {
10447
11658
  idsReadyRef.current = true;
10448
11659
  return;
10449
11660
  }
10450
- const ids = getOrCreateIds(sessionStorageKey, anonymousStorageKey);
11661
+ const ids = getOrCreateSessionId(sessionStorageKey);
10451
11662
  sessionIdRef.current = ids.sessionId;
10452
- anonymousIdRef.current = ids.anonymousId;
10453
11663
  sessionStartRef.current = ids.sessionStart;
10454
- idsReadyRef.current = true;
10455
11664
  setSessionId(ids.sessionId);
10456
- setAnonymousId(ids.anonymousId);
10457
- if (eventQueue.current.length > 0) {
11665
+ let cancelled = false;
11666
+ const flushQueuedEvents = () => {
11667
+ if (eventQueue.current.length === 0) return;
10458
11668
  const queue = eventQueue.current;
10459
11669
  eventQueue.current = [];
10460
11670
  setTimeout(() => {
@@ -10462,8 +11672,22 @@ function useAnalytics(options = {}) {
10462
11672
  sendEventRef.current?.(event);
10463
11673
  }
10464
11674
  }, 0);
10465
- }
10466
- }, [autoGenerateSessionId, sessionStorageKey, anonymousStorageKey]);
11675
+ };
11676
+ client.ensureAnonymousId().then((anonId) => {
11677
+ if (cancelled) return;
11678
+ anonymousIdRef.current = anonId;
11679
+ setAnonymousId(anonId);
11680
+ idsReadyRef.current = true;
11681
+ flushQueuedEvents();
11682
+ }).catch(() => {
11683
+ if (cancelled) return;
11684
+ idsReadyRef.current = true;
11685
+ flushQueuedEvents();
11686
+ });
11687
+ return () => {
11688
+ cancelled = true;
11689
+ };
11690
+ }, [autoGenerateSessionId, sessionStorageKey, client]);
10467
11691
  useEffect(() => {
10468
11692
  if (typeof window === "undefined") return;
10469
11693
  if (shouldAutoCaptureUtmParams) {
@@ -11194,7 +12418,7 @@ function useVote({
11194
12418
  const [state, setState] = useState(initialState ?? ZERO);
11195
12419
  const [isLoading, setIsLoading] = useState(!initialState && enabled);
11196
12420
  const [error, setError] = useState(null);
11197
- const inFlight = useRef(null);
12421
+ const inFlight2 = useRef(null);
11198
12422
  const path = `/v1/social/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}/vote`;
11199
12423
  const refetch = useCallback(async () => {
11200
12424
  if (!enabled) return;
@@ -11229,10 +12453,10 @@ function useVote({
11229
12453
  setError(e);
11230
12454
  }
11231
12455
  };
11232
- const p = inFlight.current ? inFlight.current.then(send) : send();
11233
- inFlight.current = p;
12456
+ const p = inFlight2.current ? inFlight2.current.then(send) : send();
12457
+ inFlight2.current = p;
11234
12458
  await p;
11235
- if (inFlight.current === p) inFlight.current = null;
12459
+ if (inFlight2.current === p) inFlight2.current = null;
11236
12460
  },
11237
12461
  [client, path, state]
11238
12462
  );
@@ -12035,4 +13259,4 @@ function createSafeLogger(prefix) {
12035
13259
  };
12036
13260
  }
12037
13261
 
12038
- export { FeedbackWidget, ScaleMuleApiError, ScaleMuleClient2 as ScaleMuleClient, ScaleMuleMedia, ScaleMuleProvider, VoteButton, composePhone, createClient, createSafeLogger, normalizePhone, phoneCountries, sanitizeForLog, useAnalytics, useAudio, useAuth, useBilling, useContent, useFeatureFlags, useFeedback, useFileStatus, useMedia, useMoney, useMoneyClient, usePushNotifications, useRealtime, useScaleMule, useScaleMuleClient, useShare, useTtsJob, useUser, useVote, validateForm, validators };
13262
+ export { FeedbackWidget, NarrationPlayer, ScaleMuleApiError, ScaleMuleClient2 as ScaleMuleClient, ScaleMuleMedia, ScaleMuleProvider, VoteButton, composePhone, createClient, createSafeLogger, normalizePhone, phoneCountries, sanitizeForLog, useAnalytics, useAudio, useAuth, useBilling, useContent, useFeatureFlags, useFeedback, useFileStatus, useMedia, useMoney, useMoneyClient, usePushNotifications, useRealtime, useScaleMule, useScaleMuleClient, useShare, useTtsJob, useUser, useVote, validateForm, validators };