@sogni-ai/sogni-client 4.0.0-alpha.21 → 4.0.0-alpha.23

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +26 -15
  3. package/dist/Account/index.d.ts +15 -15
  4. package/dist/Account/index.js +15 -15
  5. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.js +0 -4
  6. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.js.map +1 -1
  7. package/dist/ApiClient/WebSocketClient/events.d.ts +10 -0
  8. package/dist/ApiClient/WebSocketClient/index.js +12 -2
  9. package/dist/ApiClient/WebSocketClient/index.js.map +1 -1
  10. package/dist/ApiClient/index.js +1 -1
  11. package/dist/ApiClient/index.js.map +1 -1
  12. package/dist/Projects/Job.d.ts +12 -3
  13. package/dist/Projects/Job.js +50 -16
  14. package/dist/Projects/Job.js.map +1 -1
  15. package/dist/Projects/Project.d.ts +1 -0
  16. package/dist/Projects/Project.js +10 -3
  17. package/dist/Projects/Project.js.map +1 -1
  18. package/dist/Projects/createJobRequestMessage.js +105 -12
  19. package/dist/Projects/createJobRequestMessage.js.map +1 -1
  20. package/dist/Projects/index.d.ts +74 -5
  21. package/dist/Projects/index.js +337 -33
  22. package/dist/Projects/index.js.map +1 -1
  23. package/dist/Projects/types/events.d.ts +5 -1
  24. package/dist/Projects/types/index.d.ts +113 -28
  25. package/dist/Projects/types/index.js +8 -0
  26. package/dist/Projects/types/index.js.map +1 -1
  27. package/dist/Projects/utils.d.ts +19 -1
  28. package/dist/Projects/utils.js +68 -0
  29. package/dist/Projects/utils.js.map +1 -1
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/lib/AuthManager/TokenAuthManager.js +0 -2
  33. package/dist/lib/AuthManager/TokenAuthManager.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/Account/index.ts +15 -15
  36. package/src/ApiClient/WebSocketClient/BrowserWebSocketClient/index.ts +0 -4
  37. package/src/ApiClient/WebSocketClient/events.ts +11 -0
  38. package/src/ApiClient/WebSocketClient/index.ts +12 -2
  39. package/src/ApiClient/index.ts +1 -1
  40. package/src/Projects/Job.ts +50 -16
  41. package/src/Projects/Project.ts +12 -6
  42. package/src/Projects/createJobRequestMessage.ts +143 -33
  43. package/src/Projects/index.ts +351 -33
  44. package/src/Projects/types/events.ts +6 -0
  45. package/src/Projects/types/index.ts +141 -30
  46. package/src/Projects/utils.ts +66 -1
  47. package/src/index.ts +16 -4
  48. package/src/lib/AuthManager/TokenAuthManager.ts +0 -2
@@ -4,13 +4,17 @@ import {
4
4
  EnhancementStrength,
5
5
  EstimateRequest,
6
6
  ImageUrlParams,
7
+ MediaUrlParams,
7
8
  CostEstimation,
8
9
  ProjectParams,
9
10
  SizePreset,
10
- SupportedModel
11
+ SupportedModel,
12
+ ImageProjectParams,
13
+ VideoProjectParams
11
14
  } from './types';
12
15
  import {
13
16
  JobErrorData,
17
+ JobETAData,
14
18
  JobProgressData,
15
19
  JobResultData,
16
20
  JobStateData,
@@ -27,7 +31,12 @@ import ErrorData from '../types/ErrorData';
27
31
  import { SupernetType } from '../ApiClient/WebSocketClient/types';
28
32
  import Cache from '../lib/Cache';
29
33
  import { enhancementDefaults } from './Job';
30
- import { getEnhacementStrength } from './utils';
34
+ import {
35
+ getEnhacementStrength,
36
+ getVideoWorkflowType,
37
+ isVideoModel,
38
+ VIDEO_WORKFLOW_ASSETS
39
+ } from './utils';
31
40
  import { TokenType } from '../types/token';
32
41
  import { validateSampler } from '../lib/validation';
33
42
 
@@ -35,6 +44,35 @@ const sizePresetCache = new Cache<SizePreset[]>(10 * 60 * 1000);
35
44
  const GARBAGE_COLLECT_TIMEOUT = 30000;
36
45
  const MODELS_REFRESH_INTERVAL = 1000 * 60 * 60 * 24; // 24 hours
37
46
 
47
+ /**
48
+ * Detect content type from a file object.
49
+ * For File objects in browser, uses the type property.
50
+ * Returns undefined if content type cannot be detected.
51
+ */
52
+ function getFileContentType(file: File | Buffer | Blob): string | undefined {
53
+ if (file instanceof Blob && 'type' in file && file.type) {
54
+ return file.type;
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ /**
60
+ * Convert file to a format compatible with fetch body.
61
+ * Converts Node.js Buffer to Blob for cross-platform compatibility.
62
+ */
63
+ function toFetchBody(file: File | Buffer | Blob) {
64
+ // Node.js Buffer is not supported in browsers, so we can skip this conversion
65
+ if (typeof Buffer === 'undefined') {
66
+ return file;
67
+ }
68
+ if (Buffer.isBuffer(file)) {
69
+ // Copy Buffer data to a new ArrayBuffer to ensure type compatibility
70
+ const arrayBuffer = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength);
71
+ return new Blob([arrayBuffer as ArrayBuffer]);
72
+ }
73
+ return file;
74
+ }
75
+
38
76
  function mapErrorCodes(code: string): number {
39
77
  switch (code) {
40
78
  case 'serverRestarting':
@@ -64,6 +102,20 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
64
102
  return this._availableModels;
65
103
  }
66
104
 
105
+ /**
106
+ * Check if a model produces video output using the cached models list.
107
+ * Uses the `media` property from the models API when available,
108
+ * falls back to model ID prefix check if models aren't loaded yet.
109
+ */
110
+ isVideoModelId(modelId: string): boolean {
111
+ const model = this._supportedModels.data?.find((m) => m.id === modelId);
112
+ if (model) {
113
+ return model.media === 'video';
114
+ }
115
+ // Fallback to prefix check if models not loaded
116
+ return isVideoModel(modelId);
117
+ }
118
+
67
119
  constructor(config: ApiConfig) {
68
120
  super(config);
69
121
  // Listen to server events and emit them as project and job events
@@ -71,6 +123,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
71
123
  this.client.socket.on('swarmModels', this.handleSwarmModels.bind(this));
72
124
  this.client.socket.on('jobState', this.handleJobState.bind(this));
73
125
  this.client.socket.on('jobProgress', this.handleJobProgress.bind(this));
126
+ this.client.socket.on('jobETA', this.handleJobETA.bind(this));
74
127
  this.client.socket.on('jobError', this.handleJobError.bind(this));
75
128
  this.client.socket.on('jobResult', this.handleJobResult.bind(this));
76
129
  // Listen to the server disconnect event
@@ -99,7 +152,8 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
99
152
  this._availableModels = Object.entries(data).map(([id, workerCount]) => ({
100
153
  id,
101
154
  name: modelIndex[id]?.name || id.replace(/-/g, ' '),
102
- workerCount
155
+ workerCount,
156
+ media: modelIndex[id]?.media || 'image'
103
157
  }));
104
158
  this.emit('availableModels', this._availableModels);
105
159
  }
@@ -167,6 +221,15 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
167
221
  }
168
222
  }
169
223
 
224
+ private async handleJobETA(data: JobETAData) {
225
+ this.emit('job', {
226
+ type: 'jobETA',
227
+ projectId: data.jobID,
228
+ jobId: data.imgID || '',
229
+ etaSeconds: data.etaSeconds
230
+ });
231
+ }
232
+
170
233
  private async handleJobResult(data: JobResultData) {
171
234
  const project = this.projects.find((p) => p.id === data.jobID);
172
235
  const passNSFWCheck = !data.triggeredNSFWFilter || !project || project.params.disableNSFWFilter;
@@ -174,11 +237,21 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
174
237
  // If NSFW filter is triggered, image will be only available for download if user explicitly
175
238
  // disabled the filter for this project
176
239
  if (passNSFWCheck && !data.userCanceled) {
177
- downloadUrl = await this.downloadUrl({
178
- jobId: data.jobID,
179
- imageId: data.imgID,
180
- type: 'complete'
181
- });
240
+ // Use media endpoint for video models, image endpoint for image models
241
+ const isVideo = project && this.isVideoModelId(project.params.modelId);
242
+ if (isVideo) {
243
+ downloadUrl = await this.mediaDownloadUrl({
244
+ jobId: data.jobID,
245
+ id: data.imgID,
246
+ type: 'complete'
247
+ });
248
+ } else {
249
+ downloadUrl = await this.downloadUrl({
250
+ jobId: data.jobID,
251
+ imageId: data.imgID,
252
+ type: 'complete'
253
+ });
254
+ }
182
255
  }
183
256
 
184
257
  this.emit('job', {
@@ -297,7 +370,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
297
370
  case 'progress':
298
371
  job._update({
299
372
  status: 'processing',
300
- // Jus in case event comes out of order
373
+ // Just in case event comes out of order
301
374
  step: Math.max(event.step, job.step),
302
375
  stepCount: event.stepCount
303
376
  });
@@ -305,6 +378,10 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
305
378
  project._update({ status: 'processing' });
306
379
  }
307
380
  break;
381
+ case 'jobETA':
382
+ // ETA updates don't change job state, just pass through to listeners
383
+ // The event is already emitted, no need to update job data
384
+ break;
308
385
  case 'preview':
309
386
  job._update({ previewUrl: event.url });
310
387
  break;
@@ -321,6 +398,17 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
321
398
  }
322
399
  case 'error':
323
400
  job._update({ status: 'failed', error: event.error });
401
+ // Check if project should also fail when a job fails
402
+ // For video jobs (single image) or when all jobs have failed, propagate to project
403
+ const allJobsStarted = project.jobs.length >= project.params.numberOfMedia;
404
+ const allJobsFailed = allJobsStarted && project.jobs.every((j) => j.status === 'failed');
405
+ const isSingleJobProject = project.params.numberOfMedia === 1;
406
+ if (isSingleJobProject || allJobsFailed) {
407
+ project._update({
408
+ status: 'failed',
409
+ error: event.error
410
+ });
411
+ }
324
412
  break;
325
413
  }
326
414
  }
@@ -343,33 +431,63 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
343
431
  return Promise.resolve(this._availableModels);
344
432
  }
345
433
  return new Promise((resolve, reject) => {
434
+ let settled = false;
346
435
  const timeoutId = setTimeout(() => {
347
- reject(new Error('Timeout waiting for models'));
436
+ if (!settled) {
437
+ settled = true;
438
+ this.off('availableModels', handler);
439
+ reject(new Error('Timeout waiting for models'));
440
+ }
348
441
  }, timeout);
349
- this.once('availableModels', (models) => {
350
- clearTimeout(timeoutId);
351
- if (models.length) {
442
+
443
+ const handler = (models: AvailableModel[]) => {
444
+ // Only resolve when we get a non-empty models list
445
+ // Empty arrays may be emitted during disconnects/reconnects
446
+ if (models.length && !settled) {
447
+ settled = true;
448
+ clearTimeout(timeoutId);
449
+ this.off('availableModels', handler);
352
450
  resolve(models);
353
- } else {
354
- reject(new Error('No models available'));
355
451
  }
356
- });
452
+ };
453
+
454
+ this.on('availableModels', handler);
357
455
  });
358
456
  }
359
457
 
360
458
  /**
361
459
  * Send new project request to the network. Returns project instance which can be used to track
362
- * progress and get resulting images.
460
+ * progress and get resulting images or videos.
363
461
  * @param data
364
462
  */
365
463
  async create(data: ProjectParams): Promise<Project> {
366
464
  const project = new Project({ ...data }, { api: this, logger: this.client.logger });
465
+ const request = createJobRequestMessage(project.id, data);
466
+ switch (data.type) {
467
+ case 'image':
468
+ await this._processImageAssets(project, data);
469
+ break;
470
+ case 'video':
471
+ await this._processVideoAssets(project, data);
472
+ break;
473
+ }
474
+ await this.client.socket.send('jobRequest', request);
475
+ this.projects.push(project);
476
+ return project;
477
+ }
478
+
479
+ private async _processImageAssets(project: Project, data: ImageProjectParams) {
480
+ //Guide image
367
481
  if (data.startingImage && data.startingImage !== true) {
368
482
  await this.uploadGuideImage(project.id, data.startingImage);
369
483
  }
484
+
485
+ // ControlNet image
370
486
  if (data.controlNet?.image && data.controlNet.image !== true) {
371
487
  await this.uploadCNImage(project.id, data.controlNet.image);
372
488
  }
489
+
490
+ // Context images (Flux Kontext)
373
491
  if (data.contextImages?.length) {
374
492
  if (data.contextImages.length > 2) {
375
493
  throw new ApiError(500, {
@@ -386,10 +504,21 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
386
504
  })
387
505
  );
388
506
  }
389
- const request = createJobRequestMessage(project.id, data);
390
- await this.client.socket.send('jobRequest', request);
391
- this.projects.push(project);
392
- return project;
507
+ }
508
+
509
+ private async _processVideoAssets(project: Project, data: VideoProjectParams) {
510
+ if (data?.referenceImage && data.referenceImage !== true) {
511
+ await this.uploadReferenceImage(project.id, data.referenceImage);
512
+ }
513
+ if (data?.referenceImageEnd && data.referenceImageEnd !== true) {
514
+ await this.uploadReferenceImageEnd(project.id, data.referenceImageEnd);
515
+ }
516
+ if (data?.referenceAudio && data.referenceAudio !== true) {
517
+ await this.uploadReferenceAudio(project.id, data.referenceAudio);
518
+ }
519
+ if (data?.referenceVideo && data.referenceVideo !== true) {
520
+ await this.uploadReferenceVideo(project.id, data.referenceVideo);
521
+ }
393
522
  }
394
523
 
395
524
  /**
@@ -424,7 +553,6 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
424
553
  }
425
554
  // Remove project from the list to stop tracking it
426
555
  this.projects = this.projects.filter((p) => p.id !== projectId);
427
-
428
556
  // Cancel all jobs in the project
429
557
  project.jobs.forEach((job) => {
430
558
  if (!job.finished) {
@@ -440,13 +568,13 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
440
568
  private async uploadGuideImage(projectId: string, file: File | Buffer | Blob) {
441
569
  const imageId = getUUID();
442
570
  const presignedUrl = await this.uploadUrl({
443
- imageId: imageId,
571
+ imageId,
444
572
  jobId: projectId,
445
573
  type: 'startingImage'
446
574
  });
447
575
  const res = await fetch(presignedUrl, {
448
576
  method: 'PUT',
449
- body: file
577
+ body: toFetchBody(file)
450
578
  });
451
579
  if (!res.ok) {
452
580
  throw new ApiError(res.status, {
@@ -461,13 +589,13 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
461
589
  private async uploadCNImage(projectId: string, file: File | Buffer | Blob) {
462
590
  const imageId = getUUID();
463
591
  const presignedUrl = await this.uploadUrl({
464
- imageId: imageId,
592
+ imageId,
465
593
  jobId: projectId,
466
594
  type: 'cnImage'
467
595
  });
468
596
  const res = await fetch(presignedUrl, {
469
597
  method: 'PUT',
470
- body: file
598
+ body: toFetchBody(file)
471
599
  });
472
600
  if (!res.ok) {
473
601
  throw new ApiError(res.status, {
@@ -489,7 +617,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
489
617
  });
490
618
  const res = await fetch(presignedUrl, {
491
619
  method: 'PUT',
492
- body: file
620
+ body: toFetchBody(file)
493
621
  });
494
622
  if (!res.ok) {
495
623
  throw new ApiError(res.status, {
@@ -501,6 +629,122 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
501
629
  return imageId;
502
630
  }
503
631
 
632
+ // ============================================
633
+ // VIDEO WORKFLOW UPLOADS (WAN 2.2)
634
+ // ============================================
635
+
636
+ /**
637
+ * Upload reference image for WAN video workflows
638
+ * @internal
639
+ */
640
+ private async uploadReferenceImage(projectId: string, file: File | Buffer | Blob) {
641
+ const imageId = getUUID();
642
+ const presignedUrl = await this.uploadUrl({
643
+ imageId,
644
+ jobId: projectId,
645
+ type: 'referenceImage'
646
+ });
647
+ const res = await fetch(presignedUrl, {
648
+ method: 'PUT',
649
+ body: toFetchBody(file)
650
+ });
651
+ if (!res.ok) {
652
+ throw new ApiError(res.status, {
653
+ status: 'error',
654
+ errorCode: 0,
655
+ message: 'Failed to upload reference image'
656
+ });
657
+ }
658
+ return imageId;
659
+ }
660
+
661
+ /**
662
+ * Upload reference image end for i2v interpolation
663
+ * @internal
664
+ */
665
+ private async uploadReferenceImageEnd(projectId: string, file: File | Buffer | Blob) {
666
+ const imageId = getUUID();
667
+ const presignedUrl = await this.uploadUrl({
668
+ imageId,
669
+ jobId: projectId,
670
+ type: 'referenceImageEnd'
671
+ });
672
+ const res = await fetch(presignedUrl, {
673
+ method: 'PUT',
674
+ body: toFetchBody(file)
675
+ });
676
+ if (!res.ok) {
677
+ throw new ApiError(res.status, {
678
+ status: 'error',
679
+ errorCode: 0,
680
+ message: 'Failed to upload reference image end'
681
+ });
682
+ }
683
+ return imageId;
684
+ }
685
+
686
+ /**
687
+ * Upload reference audio for s2v workflows
688
+ * Supported formats: mp3, m4a, wav
689
+ * @internal
690
+ */
691
+ private async uploadReferenceAudio(projectId: string, file: File | Buffer | Blob) {
692
+ const contentType = getFileContentType(file);
693
+ const presignedUrl = await this.mediaUploadUrl({
694
+ jobId: projectId,
695
+ type: 'referenceAudio'
696
+ });
697
+ const headers: Record<string, string> = {};
698
+ if (contentType) {
699
+ headers['Content-Type'] = contentType;
700
+ }
701
+ const res = await fetch(presignedUrl, {
702
+ method: 'PUT',
703
+ body: toFetchBody(file),
704
+ headers
705
+ });
706
+ if (!res.ok) {
707
+ throw new ApiError(res.status, {
708
+ status: 'error',
709
+ errorCode: 0,
710
+ message: 'Failed to upload reference audio'
711
+ });
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Upload reference video for animate workflows
717
+ * Supported formats: mp4, mov
718
+ * @internal
719
+ */
720
+ private async uploadReferenceVideo(projectId: string, file: File | Buffer | Blob) {
721
+ const contentType = getFileContentType(file);
722
+ const presignedUrl = await this.mediaUploadUrl({
723
+ jobId: projectId,
724
+ type: 'referenceVideo'
725
+ });
726
+ const headers: Record<string, string> = {};
727
+ if (contentType) {
728
+ headers['Content-Type'] = contentType;
729
+ }
730
+ const res = await fetch(presignedUrl, {
731
+ method: 'PUT',
732
+ body: toFetchBody(file),
733
+ headers
734
+ });
735
+ if (!res.ok) {
736
+ throw new ApiError(res.status, {
737
+ status: 'error',
738
+ errorCode: 0,
739
+ message: 'Failed to upload reference video'
740
+ });
741
+ }
742
+ }
743
+
744
+ // ============================================
745
+ // COST ESTIMATION
746
+ // ============================================
747
+
504
748
  /**
505
749
  * Estimate project cost
506
750
  */
@@ -522,7 +766,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
522
766
  }: EstimateRequest): Promise<CostEstimation> {
523
767
  let apiVersion = 2;
524
768
  const pathParams = [
525
- tokenType || 'sogni',
769
+ tokenType || 'spark',
526
770
  network,
527
771
  model,
528
772
  imageCount,
@@ -560,7 +804,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
560
804
  };
561
805
  }
562
806
 
563
- async estimateEnhancementCost(strength: EnhancementStrength, tokenType: TokenType = 'sogni') {
807
+ async estimateEnhancementCost(strength: EnhancementStrength, tokenType: TokenType = 'spark') {
564
808
  return this.estimateCost({
565
809
  network: enhancementDefaults.network,
566
810
  tokenType,
@@ -573,10 +817,13 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
573
817
  });
574
818
  }
575
819
 
820
+ // ============================================
821
+ // URL HELPERS
822
+ // ============================================
823
+
576
824
  /**
577
825
  * Get upload URL for image
578
826
  * @internal
579
- * @param params
580
827
  */
581
828
  async uploadUrl(params: ImageUrlParams) {
582
829
  const r = await this.client.rest.get<ApiResponse<{ uploadUrl: string }>>(
@@ -589,7 +836,6 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
589
836
  /**
590
837
  * Get download URL for image
591
838
  * @internal
592
- * @param params
593
839
  */
594
840
  async downloadUrl(params: ImageUrlParams) {
595
841
  const r = await this.client.rest.get<ApiResponse<{ downloadUrl: string }>>(
@@ -599,6 +845,34 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
599
845
  return r.data.downloadUrl;
600
846
  }
601
847
 
848
+ /**
849
+ * Get upload URL for media (video/audio)
850
+ * @internal
851
+ */
852
+ async mediaUploadUrl(params: MediaUrlParams) {
853
+ const r = await this.client.rest.get<ApiResponse<{ uploadUrl: string }>>(
854
+ `/v1/media/uploadUrl`,
855
+ params
856
+ );
857
+ return r.data.uploadUrl;
858
+ }
859
+
860
+ /**
861
+ * Get download URL for media (video/audio)
862
+ * @internal
863
+ */
864
+ async mediaDownloadUrl(params: MediaUrlParams) {
865
+ const r = await this.client.rest.get<ApiResponse<{ downloadUrl: string }>>(
866
+ `/v1/media/downloadUrl`,
867
+ params
868
+ );
869
+ return r.data.downloadUrl;
870
+ }
871
+
872
+ // ============================================
873
+ // MODEL/PRESET HELPERS
874
+ // ============================================
875
+
602
876
  async getSupportedModels(forceRefresh = false) {
603
877
  if (
604
878
  this._supportedModels.data &&
@@ -617,7 +891,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
617
891
  *
618
892
  * @example
619
893
  * ```ts
620
- * const presets = await client.projects.getSizePresets('fast', 'flux1-schnell-fp8');
894
+ * const presets = await sogni.projects.getSizePresets('fast', 'flux1-schnell-fp8');
621
895
  * console.log(presets);
622
896
  * ```
623
897
  *
@@ -646,6 +920,49 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
646
920
  return data;
647
921
  }
648
922
 
923
+ /**
924
+ * Retrieves the video asset configuration for a given video model identifier.
925
+ * Validates whether the provided model ID corresponds to a video model. If it does,
926
+ * returns the appropriate video asset configuration based on the workflow type.
927
+ *
928
+ * @example Returned object for a model that implements image to video workflow:
929
+ * ```json
930
+ * {
931
+ * "workflowType": "i2v",
932
+ * "assets": {
933
+ * "referenceImage": "required",
934
+ * "referenceImageEnd": "optional",
935
+ * "referenceAudio": "forbidden",
936
+ * "referenceVideo": "forbidden"
937
+ * }
938
+ * }
939
+ * ```
940
+ *
941
+ * @param {string} modelId - The identifier of the video model to retrieve the configuration for.
942
+ * @return {Object} The video asset configuration object where key is asset field and value is
943
+ * either `required`, `forbidden` or `optional`. Returns `null` if no rules defined for the model.
944
+ * @throws {ApiError} Throws an error if the provided model ID is not a video model.
945
+ */
946
+ async getVideoAssetConfig(modelId: string) {
947
+ if (!this.isVideoModelId(modelId)) {
948
+ throw new ApiError(400, {
949
+ status: 'error',
950
+ errorCode: 0,
951
+ message: `Model ${modelId} is not a video model`
952
+ });
953
+ }
954
+ const workflow = getVideoWorkflowType(modelId);
955
+ if (!workflow) {
956
+ return {
957
+ workflowType: null
958
+ };
959
+ }
960
+ return {
961
+ workflowType: workflow,
962
+ assets: VIDEO_WORKFLOW_ASSETS[workflow]
963
+ };
964
+ }
965
+
649
966
  /**
650
967
  * Get available models and their worker counts. Normally, you would get list once you connect
651
968
  * to the server, but you can also call this method to get the list of available models manually.
@@ -662,7 +979,8 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
662
979
  return {
663
980
  id: model?.id || sid,
664
981
  name: model?.name || sid.replace(/-/g, ' '),
665
- workerCount
982
+ workerCount,
983
+ media: model?.media || 'image'
666
984
  };
667
985
  });
668
986
  }
@@ -48,6 +48,11 @@ export interface JobProgress extends JobEventBase {
48
48
  stepCount: number;
49
49
  }
50
50
 
51
+ export interface JobETA extends JobEventBase {
52
+ type: 'jobETA';
53
+ etaSeconds: number;
54
+ }
55
+
51
56
  export interface JobPreview extends JobEventBase {
52
57
  type: 'preview';
53
58
  url: string;
@@ -75,6 +80,7 @@ export type JobEvent =
75
80
  | JobInitiating
76
81
  | JobStarted
77
82
  | JobProgress
83
+ | JobETA
78
84
  | JobPreview
79
85
  | JobCompleted
80
86
  | JobError;