@scalemule/nextjs 0.1.2 → 0.1.4

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.d.mts CHANGED
@@ -2,7 +2,7 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { ReactNode, ReactElement } from 'react';
3
3
  import { MoneyClient } from '@scalemule/money';
4
4
  export { MoneyClient, MoneyClientConfig, createMoneyClient } from '@scalemule/money';
5
- import { RealtimeService, StorageService, PhotoService, ApiError as ApiError$1 } from '@scalemule/sdk';
5
+ import { RealtimeService, StorageService, PhotoService, VideoService, ApiError as ApiError$1, FileStatus } from '@scalemule/sdk';
6
6
  import { ScaleMuleClient } from './client.mjs';
7
7
  export { ClientConfig, RequestOptions, createClient } from './client.mjs';
8
8
  import { S as ScaleMuleConfig, U as User, L as LoginResponse, A as ApiError, a as UseAuthReturn, b as UseBillingReturn, c as ListFilesParams, d as UseContentReturn, e as UseUserReturn, f as UseAnalyticsOptions, g as UseAnalyticsReturn } from './index-BIIUrnPr.mjs';
@@ -19,6 +19,8 @@ interface ScaleMuleContextValue {
19
19
  storage: StorageService;
20
20
  /** Base SDK photo service — exposed for `useMedia()` and `photo.uploadViaStorage()` */
21
21
  photo: PhotoService;
22
+ /** Base SDK video service — exposed for `useMedia()` and `video.uploadViaStorage()` */
23
+ video: VideoService;
22
24
  /** Current authenticated user */
23
25
  user: User | null;
24
26
  /** Set the current user */
@@ -266,6 +268,70 @@ interface UseMediaReturn {
266
268
  */
267
269
  declare function useMedia(): UseMediaReturn;
268
270
 
271
+ interface UseFileStatusOptions {
272
+ /** Storage file_id to read status for. */
273
+ fileId: string | null | undefined;
274
+ /**
275
+ * Optional poll interval in milliseconds. If set, the hook re-fetches
276
+ * status every `pollIntervalMs`. Useful while waiting for transcode /
277
+ * optimization to complete. Pass `null` (default) for a one-shot read.
278
+ *
279
+ * Polling stops automatically once `scan.status === 'clean'` AND the
280
+ * caller's expected pipeline is done (`urls.optimized` returns 200 for
281
+ * images, `urls.hls` returns 200 for videos). Today the hook can only
282
+ * detect scan clean — broader pipeline-done detection lands when Phase 3
283
+ * enriches the optimize/transcode response fields.
284
+ */
285
+ pollIntervalMs?: number | null;
286
+ /**
287
+ * Disable the hook (don't fetch). Useful when `fileId` is conditional.
288
+ */
289
+ disabled?: boolean;
290
+ }
291
+ interface UseFileStatusReturn {
292
+ /** The latest status response, or null on first render / disabled. */
293
+ status: FileStatus | null;
294
+ /** True while a fetch is in flight. */
295
+ loading: boolean;
296
+ /** Last error, or null. */
297
+ error: ApiError$1 | null;
298
+ /**
299
+ * Convenience: scan is clean. For images/videos, this means the file
300
+ * is *safe to render*; the optimized / HLS variants may still be
301
+ * processing. The caller should attempt the constructed URLs and
302
+ * fall back to `urls.original` if the pipeline-specific URL 404s.
303
+ */
304
+ isReady: boolean;
305
+ /** Force-refresh the status. Promise resolves when the new state is committed. */
306
+ refresh: () => Promise<void>;
307
+ }
308
+ /**
309
+ * Subscribes to {@link FileStatus} for a single file. Today this is a
310
+ * pull-only hook — single fetch by default, optional polling.
311
+ *
312
+ * Phase 3 of the realtime-chat media pipeline ADR will add a push variant
313
+ * for chat surfaces — `useFileStatus({ messageId })` will subscribe to
314
+ * `file.status` events on the existing per-conversation realtime channel
315
+ * via the `scalemule-chat` translation bridge (P5'). Until that lands,
316
+ * customers using this hook from chat surfaces should pass `pollIntervalMs`
317
+ * in the 1–3 second range while a media pipeline is expected to be running,
318
+ * then drop the polling once `isReady` is true.
319
+ *
320
+ * @example
321
+ * ```tsx
322
+ * function ChatImage({ fileId }: { fileId: string }) {
323
+ * const { status, isReady } = useFileStatus({
324
+ * fileId,
325
+ * pollIntervalMs: 2000,
326
+ * });
327
+ * if (!isReady) return <div>Scanning…</div>;
328
+ * const src = status?.urls.optimized ?? status?.urls.original;
329
+ * return <img src={src} />;
330
+ * }
331
+ * ```
332
+ */
333
+ declare function useFileStatus(options: UseFileStatusOptions): UseFileStatusReturn;
334
+
269
335
  declare const useMoney: typeof useMoneyClient;
270
336
 
271
337
  /**
@@ -749,4 +815,4 @@ declare function createSafeLogger(prefix: string): {
749
815
  error: (message: string, data?: unknown) => void;
750
816
  };
751
817
 
752
- export { ApiError, type FeatureFlagEvaluation, type FeatureFlagEvaluation as FeatureFlagResult, type FeedbackItem, type FeedbackItemInput, type FeedbackPriority, type FeedbackStatus, type FeedbackType, FeedbackWidget, type FeedbackWidgetConfig, type FeedbackWidgetProps, ListFilesParams, LoginResponse, type MediaUploadResult, type PasswordValidationResult, type PhoneCountry, type PhoneValidationResult, type RealtimeEvent, type RealtimeMessage, type RealtimeStatus, ScaleMuleClient, ScaleMuleConfig, ScaleMuleProvider, type ScaleMuleProviderProps, UseAnalyticsOptions, UseAnalyticsReturn, UseAuthReturn, UseBillingReturn, UseContentReturn, type UseFeatureFlagsOptions, type UseFeatureFlagsReturn, type UseFeedbackOptions, type UseFeedbackResult, type UseFeatureFlagsOptions as UseFlagsOptions, type UseFeatureFlagsReturn as UseFlagsReturn, type UseMediaReturn, type UseMediaUploadOptions, type UsePushNotificationsOptions, type UsePushNotificationsReturn, type UseRealtimeOptions, type UseRealtimeReturn, type UseShareOptions, type UseShareReturn, UseUserReturn, User, type UsernameValidationResult, composePhone, createSafeLogger, normalizePhone, phoneCountries, sanitizeForLog, useAnalytics, useAuth, useBilling, useContent, useFeatureFlags, useFeedback, useMedia, useMoney, useMoneyClient, usePushNotifications, useRealtime, useScaleMule, useScaleMuleClient, useShare, useUser, validateForm, validators };
818
+ export { ApiError, type FeatureFlagEvaluation, type FeatureFlagEvaluation as FeatureFlagResult, type FeedbackItem, type FeedbackItemInput, type FeedbackPriority, type FeedbackStatus, type FeedbackType, FeedbackWidget, type FeedbackWidgetConfig, type FeedbackWidgetProps, ListFilesParams, LoginResponse, type MediaUploadResult, type PasswordValidationResult, type PhoneCountry, type PhoneValidationResult, type RealtimeEvent, type RealtimeMessage, type RealtimeStatus, ScaleMuleClient, ScaleMuleConfig, ScaleMuleProvider, type ScaleMuleProviderProps, UseAnalyticsOptions, UseAnalyticsReturn, UseAuthReturn, UseBillingReturn, UseContentReturn, type UseFeatureFlagsOptions, type UseFeatureFlagsReturn, type UseFeedbackOptions, type UseFeedbackResult, type UseFileStatusOptions, type UseFileStatusReturn, type UseFeatureFlagsOptions as UseFlagsOptions, type UseFeatureFlagsReturn as UseFlagsReturn, type UseMediaReturn, type UseMediaUploadOptions, type UsePushNotificationsOptions, type UsePushNotificationsReturn, type UseRealtimeOptions, type UseRealtimeReturn, type UseShareOptions, type UseShareReturn, UseUserReturn, User, type UsernameValidationResult, composePhone, createSafeLogger, normalizePhone, phoneCountries, sanitizeForLog, useAnalytics, useAuth, useBilling, useContent, useFeatureFlags, useFeedback, useFileStatus, useMedia, useMoney, useMoneyClient, usePushNotifications, useRealtime, useScaleMule, useScaleMuleClient, useShare, useUser, validateForm, validators };
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { ReactNode, ReactElement } from 'react';
3
3
  import { MoneyClient } from '@scalemule/money';
4
4
  export { MoneyClient, MoneyClientConfig, createMoneyClient } from '@scalemule/money';
5
- import { RealtimeService, StorageService, PhotoService, ApiError as ApiError$1 } from '@scalemule/sdk';
5
+ import { RealtimeService, StorageService, PhotoService, VideoService, ApiError as ApiError$1, FileStatus } from '@scalemule/sdk';
6
6
  import { ScaleMuleClient } from './client.js';
7
7
  export { ClientConfig, RequestOptions, createClient } from './client.js';
8
8
  import { S as ScaleMuleConfig, U as User, L as LoginResponse, A as ApiError, a as UseAuthReturn, b as UseBillingReturn, c as ListFilesParams, d as UseContentReturn, e as UseUserReturn, f as UseAnalyticsOptions, g as UseAnalyticsReturn } from './index-BIIUrnPr.js';
@@ -19,6 +19,8 @@ interface ScaleMuleContextValue {
19
19
  storage: StorageService;
20
20
  /** Base SDK photo service — exposed for `useMedia()` and `photo.uploadViaStorage()` */
21
21
  photo: PhotoService;
22
+ /** Base SDK video service — exposed for `useMedia()` and `video.uploadViaStorage()` */
23
+ video: VideoService;
22
24
  /** Current authenticated user */
23
25
  user: User | null;
24
26
  /** Set the current user */
@@ -266,6 +268,70 @@ interface UseMediaReturn {
266
268
  */
267
269
  declare function useMedia(): UseMediaReturn;
268
270
 
271
+ interface UseFileStatusOptions {
272
+ /** Storage file_id to read status for. */
273
+ fileId: string | null | undefined;
274
+ /**
275
+ * Optional poll interval in milliseconds. If set, the hook re-fetches
276
+ * status every `pollIntervalMs`. Useful while waiting for transcode /
277
+ * optimization to complete. Pass `null` (default) for a one-shot read.
278
+ *
279
+ * Polling stops automatically once `scan.status === 'clean'` AND the
280
+ * caller's expected pipeline is done (`urls.optimized` returns 200 for
281
+ * images, `urls.hls` returns 200 for videos). Today the hook can only
282
+ * detect scan clean — broader pipeline-done detection lands when Phase 3
283
+ * enriches the optimize/transcode response fields.
284
+ */
285
+ pollIntervalMs?: number | null;
286
+ /**
287
+ * Disable the hook (don't fetch). Useful when `fileId` is conditional.
288
+ */
289
+ disabled?: boolean;
290
+ }
291
+ interface UseFileStatusReturn {
292
+ /** The latest status response, or null on first render / disabled. */
293
+ status: FileStatus | null;
294
+ /** True while a fetch is in flight. */
295
+ loading: boolean;
296
+ /** Last error, or null. */
297
+ error: ApiError$1 | null;
298
+ /**
299
+ * Convenience: scan is clean. For images/videos, this means the file
300
+ * is *safe to render*; the optimized / HLS variants may still be
301
+ * processing. The caller should attempt the constructed URLs and
302
+ * fall back to `urls.original` if the pipeline-specific URL 404s.
303
+ */
304
+ isReady: boolean;
305
+ /** Force-refresh the status. Promise resolves when the new state is committed. */
306
+ refresh: () => Promise<void>;
307
+ }
308
+ /**
309
+ * Subscribes to {@link FileStatus} for a single file. Today this is a
310
+ * pull-only hook — single fetch by default, optional polling.
311
+ *
312
+ * Phase 3 of the realtime-chat media pipeline ADR will add a push variant
313
+ * for chat surfaces — `useFileStatus({ messageId })` will subscribe to
314
+ * `file.status` events on the existing per-conversation realtime channel
315
+ * via the `scalemule-chat` translation bridge (P5'). Until that lands,
316
+ * customers using this hook from chat surfaces should pass `pollIntervalMs`
317
+ * in the 1–3 second range while a media pipeline is expected to be running,
318
+ * then drop the polling once `isReady` is true.
319
+ *
320
+ * @example
321
+ * ```tsx
322
+ * function ChatImage({ fileId }: { fileId: string }) {
323
+ * const { status, isReady } = useFileStatus({
324
+ * fileId,
325
+ * pollIntervalMs: 2000,
326
+ * });
327
+ * if (!isReady) return <div>Scanning…</div>;
328
+ * const src = status?.urls.optimized ?? status?.urls.original;
329
+ * return <img src={src} />;
330
+ * }
331
+ * ```
332
+ */
333
+ declare function useFileStatus(options: UseFileStatusOptions): UseFileStatusReturn;
334
+
269
335
  declare const useMoney: typeof useMoneyClient;
270
336
 
271
337
  /**
@@ -749,4 +815,4 @@ declare function createSafeLogger(prefix: string): {
749
815
  error: (message: string, data?: unknown) => void;
750
816
  };
751
817
 
752
- export { ApiError, type FeatureFlagEvaluation, type FeatureFlagEvaluation as FeatureFlagResult, type FeedbackItem, type FeedbackItemInput, type FeedbackPriority, type FeedbackStatus, type FeedbackType, FeedbackWidget, type FeedbackWidgetConfig, type FeedbackWidgetProps, ListFilesParams, LoginResponse, type MediaUploadResult, type PasswordValidationResult, type PhoneCountry, type PhoneValidationResult, type RealtimeEvent, type RealtimeMessage, type RealtimeStatus, ScaleMuleClient, ScaleMuleConfig, ScaleMuleProvider, type ScaleMuleProviderProps, UseAnalyticsOptions, UseAnalyticsReturn, UseAuthReturn, UseBillingReturn, UseContentReturn, type UseFeatureFlagsOptions, type UseFeatureFlagsReturn, type UseFeedbackOptions, type UseFeedbackResult, type UseFeatureFlagsOptions as UseFlagsOptions, type UseFeatureFlagsReturn as UseFlagsReturn, type UseMediaReturn, type UseMediaUploadOptions, type UsePushNotificationsOptions, type UsePushNotificationsReturn, type UseRealtimeOptions, type UseRealtimeReturn, type UseShareOptions, type UseShareReturn, UseUserReturn, User, type UsernameValidationResult, composePhone, createSafeLogger, normalizePhone, phoneCountries, sanitizeForLog, useAnalytics, useAuth, useBilling, useContent, useFeatureFlags, useFeedback, useMedia, useMoney, useMoneyClient, usePushNotifications, useRealtime, useScaleMule, useScaleMuleClient, useShare, useUser, validateForm, validators };
818
+ export { ApiError, type FeatureFlagEvaluation, type FeatureFlagEvaluation as FeatureFlagResult, type FeedbackItem, type FeedbackItemInput, type FeedbackPriority, type FeedbackStatus, type FeedbackType, FeedbackWidget, type FeedbackWidgetConfig, type FeedbackWidgetProps, ListFilesParams, LoginResponse, type MediaUploadResult, type PasswordValidationResult, type PhoneCountry, type PhoneValidationResult, type RealtimeEvent, type RealtimeMessage, type RealtimeStatus, ScaleMuleClient, ScaleMuleConfig, ScaleMuleProvider, type ScaleMuleProviderProps, UseAnalyticsOptions, UseAnalyticsReturn, UseAuthReturn, UseBillingReturn, UseContentReturn, type UseFeatureFlagsOptions, type UseFeatureFlagsReturn, type UseFeedbackOptions, type UseFeedbackResult, type UseFileStatusOptions, type UseFileStatusReturn, type UseFeatureFlagsOptions as UseFlagsOptions, type UseFeatureFlagsReturn as UseFlagsReturn, type UseMediaReturn, type UseMediaUploadOptions, type UsePushNotificationsOptions, type UsePushNotificationsReturn, type UseRealtimeOptions, type UseRealtimeReturn, type UseShareOptions, type UseShareReturn, UseUserReturn, User, type UsernameValidationResult, composePhone, createSafeLogger, normalizePhone, phoneCountries, sanitizeForLog, useAnalytics, useAuth, useBilling, useContent, useFeatureFlags, useFeedback, useFileStatus, useMedia, useMoney, useMoneyClient, usePushNotifications, useRealtime, useScaleMule, useScaleMuleClient, useShare, useUser, validateForm, validators };
package/dist/index.js CHANGED
@@ -2707,6 +2707,31 @@ var StorageService = class extends ServiceModule {
2707
2707
  async getInfo(fileId, options) {
2708
2708
  return this._get(`/files/${fileId}/info`, options);
2709
2709
  }
2710
+ /**
2711
+ * Aggregate status for a file — single call returns scan + reserved
2712
+ * optimize/transcode slots + the canonical view URL paths for image
2713
+ * (transform endpoint) and video (HLS playlist) MIME types.
2714
+ *
2715
+ * Foundational primitive for the chat / progressive media read side.
2716
+ * `useFileStatus()` (in `@scalemule/nextjs`) consumes this for both
2717
+ * first-paint and refresh-on-demand scenarios.
2718
+ *
2719
+ * `optimize` and `transcode` are reserved for Phase 3 enrichment. Until
2720
+ * the photo/video services expose internal status endpoints, callers
2721
+ * should attempt the constructed URLs directly to discover readiness
2722
+ * (404 means the pipeline is still running).
2723
+ *
2724
+ * @example
2725
+ * ```ts
2726
+ * const r = await client.storage.getFileStatus(fileId);
2727
+ * if (r.data?.scan.status === 'clean' && r.data.urls.optimized) {
2728
+ * // image: try transform URL for an optimized variant
2729
+ * }
2730
+ * ```
2731
+ */
2732
+ async getFileStatus(fileId, options) {
2733
+ return this._get(`/files/${fileId}/status`, options);
2734
+ }
2710
2735
  /**
2711
2736
  * Get a signed view URL for inline display (img src, thumbnails).
2712
2737
  * Returns CloudFront signed URL (fast, ~1us) or S3 presigned fallback.
@@ -3505,8 +3530,13 @@ var RealtimeService = class extends ServiceModule {
3505
3530
  var DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
3506
3531
  var MIN_CHUNK_SIZE = 5 * 1024 * 1024;
3507
3532
  var VideoService = class extends ServiceModule {
3508
- constructor() {
3509
- super(...arguments);
3533
+ /**
3534
+ * @param storage Required for {@link uploadViaStorage}. Wired up by the
3535
+ * top-level {@link ScaleMule} constructor.
3536
+ */
3537
+ constructor(client, storage) {
3538
+ super(client);
3539
+ this.storage = storage;
3510
3540
  this.basePath = "/v1/videos";
3511
3541
  }
3512
3542
  // --------------------------------------------------------------------------
@@ -3580,6 +3610,115 @@ var VideoService = class extends ServiceModule {
3580
3610
  async get(videoId, options) {
3581
3611
  return super._get(`/${videoId}`, options);
3582
3612
  }
3613
+ /**
3614
+ * Register a video from a file already uploaded to scalemule-storage.
3615
+ *
3616
+ * The synchronous handshake for storage-uploaded videos. Verifies the
3617
+ * caller's app owns the storage `file_id` and reads scan status:
3618
+ * - clean → 201, video record created with status='uploading',
3619
+ * transcode worker advances on `scan.file.completed`
3620
+ * - pending → 202, video record created with status='pending_scan',
3621
+ * worker advances when scan completes
3622
+ * - threat → 409 (rejected; row not created)
3623
+ *
3624
+ * Idempotent: calling twice with the same file_id returns the existing
3625
+ * row's data (the underlying INSERT uses ON DUPLICATE KEY UPDATE).
3626
+ *
3627
+ * For storage-backed videos `video_id == file_id` — the response's
3628
+ * `video_id` and `file_id` are identical, and either can be used with
3629
+ * `getStreamUrl()` / `get()`.
3630
+ *
3631
+ * Most callers should use {@link uploadViaStorage} which composes
3632
+ * `storage.uploadPrivate()` + `register()` in a single call.
3633
+ */
3634
+ async register(request, options) {
3635
+ return this.post(
3636
+ "/register",
3637
+ {
3638
+ file_id: request.fileId,
3639
+ sm_user_id: request.userId
3640
+ },
3641
+ options
3642
+ );
3643
+ }
3644
+ /**
3645
+ * Upload a video to storage (browser → S3 direct, private, no
3646
+ * compression) and register it with the video service so transcoding
3647
+ * + HLS streaming work.
3648
+ *
3649
+ * The canonical chat-attachment / progressive-video upload primitive.
3650
+ * Same private-direct-to-S3 path as `storage.uploadPrivate()`, plus a
3651
+ * follow-up `video.register()` call so HLS playback URLs become live
3652
+ * once the transcoder finishes.
3653
+ *
3654
+ * The returned `hls_url_promise` resolves once the video's status
3655
+ * flips to `ready` (or after a 30s timeout — caller can still serve
3656
+ * `original_view_url` as the fallback). Phase 3 of the realtime-chat
3657
+ * media pipeline ADR replaces the poll with a realtime subscription.
3658
+ *
3659
+ * If `register()` fails after a successful storage upload, the file
3660
+ * is *not* lost: the returned `file_id` is still valid as a generic
3661
+ * storage file. SDK logs a warning and resolves `hls_url_promise`
3662
+ * to `null`.
3663
+ */
3664
+ async uploadViaStorage(file, uploadOptions, requestOptions) {
3665
+ const uploadResult = await this.storage.uploadPrivate(file, {
3666
+ filename: uploadOptions?.filename,
3667
+ metadata: uploadOptions?.metadata,
3668
+ onProgress: uploadOptions?.onProgress,
3669
+ signal: uploadOptions?.signal
3670
+ });
3671
+ if (uploadResult.error || !uploadResult.data) {
3672
+ return { data: null, error: uploadResult.error };
3673
+ }
3674
+ const fileInfo = uploadResult.data;
3675
+ const fileId = fileInfo.id;
3676
+ const originalViewUrl = fileInfo.url ?? null;
3677
+ const registerResult = await this.register({ fileId, userId: uploadOptions?.userId }, requestOptions);
3678
+ if (registerResult.error || !registerResult.data) {
3679
+ console.warn(
3680
+ "[scalemule-sdk] video.register() failed after storage upload; HLS variants unavailable.",
3681
+ registerResult.error
3682
+ );
3683
+ return {
3684
+ data: {
3685
+ file_id: fileId,
3686
+ video_id: fileId,
3687
+ original_view_url: originalViewUrl,
3688
+ hls_url_promise: Promise.resolve(null)
3689
+ },
3690
+ error: null
3691
+ };
3692
+ }
3693
+ const videoId = registerResult.data.video_id;
3694
+ const hlsUrlPromise = this.pollTranscodeComplete(videoId, requestOptions);
3695
+ return {
3696
+ data: {
3697
+ file_id: fileId,
3698
+ video_id: videoId,
3699
+ original_view_url: originalViewUrl,
3700
+ hls_url_promise: hlsUrlPromise
3701
+ },
3702
+ error: null
3703
+ };
3704
+ }
3705
+ /**
3706
+ * Poll {@link get} until the video's status is `ready` or the 30s
3707
+ * timeout fires. Resolves to the HLS master playlist URL on success;
3708
+ * `null` on timeout (caller should fall back to `original_view_url`).
3709
+ */
3710
+ async pollTranscodeComplete(videoId, requestOptions) {
3711
+ const intervalMs = 1e3;
3712
+ const maxAttempts = 30;
3713
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
3714
+ const result = await this.get(videoId, requestOptions);
3715
+ if (result.data?.status === "ready") {
3716
+ return `${this.client.getBaseUrl()}${this.basePath}/${videoId}/playlist.m3u8`;
3717
+ }
3718
+ await new Promise((r) => setTimeout(r, intervalMs));
3719
+ }
3720
+ return null;
3721
+ }
3583
3722
  /**
3584
3723
  * Get the HLS master playlist URL for streaming.
3585
3724
  * Returns the playlist URL that can be passed to a video player.
@@ -6310,7 +6449,7 @@ var ScaleMule = class {
6310
6449
  this.auth = new AuthService(this._client);
6311
6450
  this.storage = new StorageService(this._client);
6312
6451
  this.realtime = new RealtimeService(this._client);
6313
- this.video = new VideoService(this._client);
6452
+ this.video = new VideoService(this._client, this.storage);
6314
6453
  this.data = new DataService(this._client);
6315
6454
  this.chat = new ChatService(this._client);
6316
6455
  this.conference = new ConferenceService(this._client);
@@ -7424,6 +7563,7 @@ function ScaleMuleProvider({
7424
7563
  realtime: baseClient.realtime,
7425
7564
  storage: baseClient.storage,
7426
7565
  photo: baseClient.photo,
7566
+ video: baseClient.video,
7427
7567
  user,
7428
7568
  setUser: handleSetUser,
7429
7569
  initializing,
@@ -8737,7 +8877,7 @@ function useContent(options = {}) {
8737
8877
  );
8738
8878
  }
8739
8879
  function useMedia() {
8740
- const { storage, photo } = useScaleMule();
8880
+ const { storage, photo, video } = useScaleMule();
8741
8881
  const [uploading, setUploading] = react.useState(false);
8742
8882
  const [error, setError] = react.useState(null);
8743
8883
  const upload = react.useCallback(
@@ -8768,6 +8908,21 @@ function useMedia() {
8768
8908
  is_public: false
8769
8909
  };
8770
8910
  }
8911
+ if (mimeType.startsWith("video/") && !isPublic) {
8912
+ const r2 = await video.uploadViaStorage(file, sharedOpts);
8913
+ if (r2.error || !r2.data) {
8914
+ throw r2.error ?? { code: "upload_error", message: "Upload failed", status: 0 };
8915
+ }
8916
+ return {
8917
+ file_id: r2.data.file_id,
8918
+ photo_id: null,
8919
+ original_view_url: r2.data.original_view_url,
8920
+ optimized_url_promise: Promise.resolve(null),
8921
+ hls_url_promise: r2.data.hls_url_promise,
8922
+ mime_type: mimeType,
8923
+ is_public: false
8924
+ };
8925
+ }
8771
8926
  if (mimeType.startsWith("image/") && isPublic) {
8772
8927
  const r2 = await storage.upload(file, {
8773
8928
  ...sharedOpts,
@@ -8831,6 +8986,50 @@ function useMedia() {
8831
8986
  );
8832
8987
  return { upload, cancelUpload, error, uploading };
8833
8988
  }
8989
+ function useFileStatus(options) {
8990
+ const { storage } = useScaleMule();
8991
+ const { fileId, pollIntervalMs = null, disabled = false } = options;
8992
+ const [status, setStatus] = react.useState(null);
8993
+ const [loading, setLoading] = react.useState(false);
8994
+ const [error, setError] = react.useState(null);
8995
+ const requestSeqRef = react.useRef(0);
8996
+ const fetchStatus = react.useCallback(async () => {
8997
+ if (!fileId || disabled) return;
8998
+ const seq = ++requestSeqRef.current;
8999
+ setLoading(true);
9000
+ setError(null);
9001
+ try {
9002
+ const r = await storage.getFileStatus(fileId);
9003
+ if (seq !== requestSeqRef.current) return;
9004
+ if (r.error || !r.data) {
9005
+ setError(r.error);
9006
+ return;
9007
+ }
9008
+ setStatus(r.data);
9009
+ } catch (err) {
9010
+ if (seq !== requestSeqRef.current) return;
9011
+ setError(err);
9012
+ } finally {
9013
+ if (seq === requestSeqRef.current) {
9014
+ setLoading(false);
9015
+ }
9016
+ }
9017
+ }, [storage, fileId, disabled]);
9018
+ react.useEffect(() => {
9019
+ void fetchStatus();
9020
+ }, [fetchStatus]);
9021
+ react.useEffect(() => {
9022
+ if (!pollIntervalMs || disabled || !fileId) return;
9023
+ const isClean = status?.scan.status === "clean";
9024
+ if (isClean) return;
9025
+ const id = setInterval(() => {
9026
+ void fetchStatus();
9027
+ }, pollIntervalMs);
9028
+ return () => clearInterval(id);
9029
+ }, [pollIntervalMs, disabled, fileId, status?.scan.status, fetchStatus]);
9030
+ const isReady = status?.scan.status === "clean";
9031
+ return { status, loading, error, isReady, refresh: fetchStatus };
9032
+ }
8834
9033
 
8835
9034
  // src/hooks/useMoney.ts
8836
9035
  var useMoney = useMoneyClient;
@@ -10641,6 +10840,7 @@ exports.useBilling = useBilling;
10641
10840
  exports.useContent = useContent;
10642
10841
  exports.useFeatureFlags = useFeatureFlags;
10643
10842
  exports.useFeedback = useFeedback;
10843
+ exports.useFileStatus = useFileStatus;
10644
10844
  exports.useMedia = useMedia;
10645
10845
  exports.useMoney = useMoney;
10646
10846
  exports.useMoneyClient = useMoneyClient;
package/dist/index.mjs CHANGED
@@ -2706,6 +2706,31 @@ var StorageService = class extends ServiceModule {
2706
2706
  async getInfo(fileId, options) {
2707
2707
  return this._get(`/files/${fileId}/info`, options);
2708
2708
  }
2709
+ /**
2710
+ * Aggregate status for a file — single call returns scan + reserved
2711
+ * optimize/transcode slots + the canonical view URL paths for image
2712
+ * (transform endpoint) and video (HLS playlist) MIME types.
2713
+ *
2714
+ * Foundational primitive for the chat / progressive media read side.
2715
+ * `useFileStatus()` (in `@scalemule/nextjs`) consumes this for both
2716
+ * first-paint and refresh-on-demand scenarios.
2717
+ *
2718
+ * `optimize` and `transcode` are reserved for Phase 3 enrichment. Until
2719
+ * the photo/video services expose internal status endpoints, callers
2720
+ * should attempt the constructed URLs directly to discover readiness
2721
+ * (404 means the pipeline is still running).
2722
+ *
2723
+ * @example
2724
+ * ```ts
2725
+ * const r = await client.storage.getFileStatus(fileId);
2726
+ * if (r.data?.scan.status === 'clean' && r.data.urls.optimized) {
2727
+ * // image: try transform URL for an optimized variant
2728
+ * }
2729
+ * ```
2730
+ */
2731
+ async getFileStatus(fileId, options) {
2732
+ return this._get(`/files/${fileId}/status`, options);
2733
+ }
2709
2734
  /**
2710
2735
  * Get a signed view URL for inline display (img src, thumbnails).
2711
2736
  * Returns CloudFront signed URL (fast, ~1us) or S3 presigned fallback.
@@ -3504,8 +3529,13 @@ var RealtimeService = class extends ServiceModule {
3504
3529
  var DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
3505
3530
  var MIN_CHUNK_SIZE = 5 * 1024 * 1024;
3506
3531
  var VideoService = class extends ServiceModule {
3507
- constructor() {
3508
- super(...arguments);
3532
+ /**
3533
+ * @param storage Required for {@link uploadViaStorage}. Wired up by the
3534
+ * top-level {@link ScaleMule} constructor.
3535
+ */
3536
+ constructor(client, storage) {
3537
+ super(client);
3538
+ this.storage = storage;
3509
3539
  this.basePath = "/v1/videos";
3510
3540
  }
3511
3541
  // --------------------------------------------------------------------------
@@ -3579,6 +3609,115 @@ var VideoService = class extends ServiceModule {
3579
3609
  async get(videoId, options) {
3580
3610
  return super._get(`/${videoId}`, options);
3581
3611
  }
3612
+ /**
3613
+ * Register a video from a file already uploaded to scalemule-storage.
3614
+ *
3615
+ * The synchronous handshake for storage-uploaded videos. Verifies the
3616
+ * caller's app owns the storage `file_id` and reads scan status:
3617
+ * - clean → 201, video record created with status='uploading',
3618
+ * transcode worker advances on `scan.file.completed`
3619
+ * - pending → 202, video record created with status='pending_scan',
3620
+ * worker advances when scan completes
3621
+ * - threat → 409 (rejected; row not created)
3622
+ *
3623
+ * Idempotent: calling twice with the same file_id returns the existing
3624
+ * row's data (the underlying INSERT uses ON DUPLICATE KEY UPDATE).
3625
+ *
3626
+ * For storage-backed videos `video_id == file_id` — the response's
3627
+ * `video_id` and `file_id` are identical, and either can be used with
3628
+ * `getStreamUrl()` / `get()`.
3629
+ *
3630
+ * Most callers should use {@link uploadViaStorage} which composes
3631
+ * `storage.uploadPrivate()` + `register()` in a single call.
3632
+ */
3633
+ async register(request, options) {
3634
+ return this.post(
3635
+ "/register",
3636
+ {
3637
+ file_id: request.fileId,
3638
+ sm_user_id: request.userId
3639
+ },
3640
+ options
3641
+ );
3642
+ }
3643
+ /**
3644
+ * Upload a video to storage (browser → S3 direct, private, no
3645
+ * compression) and register it with the video service so transcoding
3646
+ * + HLS streaming work.
3647
+ *
3648
+ * The canonical chat-attachment / progressive-video upload primitive.
3649
+ * Same private-direct-to-S3 path as `storage.uploadPrivate()`, plus a
3650
+ * follow-up `video.register()` call so HLS playback URLs become live
3651
+ * once the transcoder finishes.
3652
+ *
3653
+ * The returned `hls_url_promise` resolves once the video's status
3654
+ * flips to `ready` (or after a 30s timeout — caller can still serve
3655
+ * `original_view_url` as the fallback). Phase 3 of the realtime-chat
3656
+ * media pipeline ADR replaces the poll with a realtime subscription.
3657
+ *
3658
+ * If `register()` fails after a successful storage upload, the file
3659
+ * is *not* lost: the returned `file_id` is still valid as a generic
3660
+ * storage file. SDK logs a warning and resolves `hls_url_promise`
3661
+ * to `null`.
3662
+ */
3663
+ async uploadViaStorage(file, uploadOptions, requestOptions) {
3664
+ const uploadResult = await this.storage.uploadPrivate(file, {
3665
+ filename: uploadOptions?.filename,
3666
+ metadata: uploadOptions?.metadata,
3667
+ onProgress: uploadOptions?.onProgress,
3668
+ signal: uploadOptions?.signal
3669
+ });
3670
+ if (uploadResult.error || !uploadResult.data) {
3671
+ return { data: null, error: uploadResult.error };
3672
+ }
3673
+ const fileInfo = uploadResult.data;
3674
+ const fileId = fileInfo.id;
3675
+ const originalViewUrl = fileInfo.url ?? null;
3676
+ const registerResult = await this.register({ fileId, userId: uploadOptions?.userId }, requestOptions);
3677
+ if (registerResult.error || !registerResult.data) {
3678
+ console.warn(
3679
+ "[scalemule-sdk] video.register() failed after storage upload; HLS variants unavailable.",
3680
+ registerResult.error
3681
+ );
3682
+ return {
3683
+ data: {
3684
+ file_id: fileId,
3685
+ video_id: fileId,
3686
+ original_view_url: originalViewUrl,
3687
+ hls_url_promise: Promise.resolve(null)
3688
+ },
3689
+ error: null
3690
+ };
3691
+ }
3692
+ const videoId = registerResult.data.video_id;
3693
+ const hlsUrlPromise = this.pollTranscodeComplete(videoId, requestOptions);
3694
+ return {
3695
+ data: {
3696
+ file_id: fileId,
3697
+ video_id: videoId,
3698
+ original_view_url: originalViewUrl,
3699
+ hls_url_promise: hlsUrlPromise
3700
+ },
3701
+ error: null
3702
+ };
3703
+ }
3704
+ /**
3705
+ * Poll {@link get} until the video's status is `ready` or the 30s
3706
+ * timeout fires. Resolves to the HLS master playlist URL on success;
3707
+ * `null` on timeout (caller should fall back to `original_view_url`).
3708
+ */
3709
+ async pollTranscodeComplete(videoId, requestOptions) {
3710
+ const intervalMs = 1e3;
3711
+ const maxAttempts = 30;
3712
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
3713
+ const result = await this.get(videoId, requestOptions);
3714
+ if (result.data?.status === "ready") {
3715
+ return `${this.client.getBaseUrl()}${this.basePath}/${videoId}/playlist.m3u8`;
3716
+ }
3717
+ await new Promise((r) => setTimeout(r, intervalMs));
3718
+ }
3719
+ return null;
3720
+ }
3582
3721
  /**
3583
3722
  * Get the HLS master playlist URL for streaming.
3584
3723
  * Returns the playlist URL that can be passed to a video player.
@@ -6309,7 +6448,7 @@ var ScaleMule = class {
6309
6448
  this.auth = new AuthService(this._client);
6310
6449
  this.storage = new StorageService(this._client);
6311
6450
  this.realtime = new RealtimeService(this._client);
6312
- this.video = new VideoService(this._client);
6451
+ this.video = new VideoService(this._client, this.storage);
6313
6452
  this.data = new DataService(this._client);
6314
6453
  this.chat = new ChatService(this._client);
6315
6454
  this.conference = new ConferenceService(this._client);
@@ -7423,6 +7562,7 @@ function ScaleMuleProvider({
7423
7562
  realtime: baseClient.realtime,
7424
7563
  storage: baseClient.storage,
7425
7564
  photo: baseClient.photo,
7565
+ video: baseClient.video,
7426
7566
  user,
7427
7567
  setUser: handleSetUser,
7428
7568
  initializing,
@@ -8736,7 +8876,7 @@ function useContent(options = {}) {
8736
8876
  );
8737
8877
  }
8738
8878
  function useMedia() {
8739
- const { storage, photo } = useScaleMule();
8879
+ const { storage, photo, video } = useScaleMule();
8740
8880
  const [uploading, setUploading] = useState(false);
8741
8881
  const [error, setError] = useState(null);
8742
8882
  const upload = useCallback(
@@ -8767,6 +8907,21 @@ function useMedia() {
8767
8907
  is_public: false
8768
8908
  };
8769
8909
  }
8910
+ if (mimeType.startsWith("video/") && !isPublic) {
8911
+ const r2 = await video.uploadViaStorage(file, sharedOpts);
8912
+ if (r2.error || !r2.data) {
8913
+ throw r2.error ?? { code: "upload_error", message: "Upload failed", status: 0 };
8914
+ }
8915
+ return {
8916
+ file_id: r2.data.file_id,
8917
+ photo_id: null,
8918
+ original_view_url: r2.data.original_view_url,
8919
+ optimized_url_promise: Promise.resolve(null),
8920
+ hls_url_promise: r2.data.hls_url_promise,
8921
+ mime_type: mimeType,
8922
+ is_public: false
8923
+ };
8924
+ }
8770
8925
  if (mimeType.startsWith("image/") && isPublic) {
8771
8926
  const r2 = await storage.upload(file, {
8772
8927
  ...sharedOpts,
@@ -8830,6 +8985,50 @@ function useMedia() {
8830
8985
  );
8831
8986
  return { upload, cancelUpload, error, uploading };
8832
8987
  }
8988
+ function useFileStatus(options) {
8989
+ const { storage } = useScaleMule();
8990
+ const { fileId, pollIntervalMs = null, disabled = false } = options;
8991
+ const [status, setStatus] = useState(null);
8992
+ const [loading, setLoading] = useState(false);
8993
+ const [error, setError] = useState(null);
8994
+ const requestSeqRef = useRef(0);
8995
+ const fetchStatus = useCallback(async () => {
8996
+ if (!fileId || disabled) return;
8997
+ const seq = ++requestSeqRef.current;
8998
+ setLoading(true);
8999
+ setError(null);
9000
+ try {
9001
+ const r = await storage.getFileStatus(fileId);
9002
+ if (seq !== requestSeqRef.current) return;
9003
+ if (r.error || !r.data) {
9004
+ setError(r.error);
9005
+ return;
9006
+ }
9007
+ setStatus(r.data);
9008
+ } catch (err) {
9009
+ if (seq !== requestSeqRef.current) return;
9010
+ setError(err);
9011
+ } finally {
9012
+ if (seq === requestSeqRef.current) {
9013
+ setLoading(false);
9014
+ }
9015
+ }
9016
+ }, [storage, fileId, disabled]);
9017
+ useEffect(() => {
9018
+ void fetchStatus();
9019
+ }, [fetchStatus]);
9020
+ useEffect(() => {
9021
+ if (!pollIntervalMs || disabled || !fileId) return;
9022
+ const isClean = status?.scan.status === "clean";
9023
+ if (isClean) return;
9024
+ const id = setInterval(() => {
9025
+ void fetchStatus();
9026
+ }, pollIntervalMs);
9027
+ return () => clearInterval(id);
9028
+ }, [pollIntervalMs, disabled, fileId, status?.scan.status, fetchStatus]);
9029
+ const isReady = status?.scan.status === "clean";
9030
+ return { status, loading, error, isReady, refresh: fetchStatus };
9031
+ }
8833
9032
 
8834
9033
  // src/hooks/useMoney.ts
8835
9034
  var useMoney = useMoneyClient;
@@ -10616,4 +10815,4 @@ function createSafeLogger(prefix) {
10616
10815
  };
10617
10816
  }
10618
10817
 
10619
- export { FeedbackWidget, ScaleMuleApiError, ScaleMuleClient2 as ScaleMuleClient, ScaleMuleProvider, composePhone, createClient, createSafeLogger, normalizePhone, phoneCountries, sanitizeForLog, useAnalytics, useAuth, useBilling, useContent, useFeatureFlags, useFeedback, useMedia, useMoney, useMoneyClient, usePushNotifications, useRealtime, useScaleMule, useScaleMuleClient, useShare, useUser, validateForm, validators };
10818
+ export { FeedbackWidget, ScaleMuleApiError, ScaleMuleClient2 as ScaleMuleClient, ScaleMuleProvider, composePhone, createClient, createSafeLogger, normalizePhone, phoneCountries, sanitizeForLog, useAnalytics, useAuth, useBilling, useContent, useFeatureFlags, useFeedback, useFileStatus, useMedia, useMoney, useMoneyClient, usePushNotifications, useRealtime, useScaleMule, useScaleMuleClient, useShare, useUser, validateForm, validators };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/nextjs",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "ScaleMule SDK for Next.js applications - authentication, storage, and user management",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@scalemule/money": "^0.0.1",
56
- "@scalemule/sdk": "^0.0.38"
56
+ "@scalemule/sdk": "^0.0.41"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "next": ">=14.0.0",