@sogni-ai/sogni-client 4.0.0-alpha.3 → 4.0.0-alpha.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +220 -0
  2. package/README.md +279 -28
  3. package/dist/Account/index.d.ts +18 -16
  4. package/dist/Account/index.js +31 -20
  5. package/dist/Account/index.js.map +1 -1
  6. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/ChannelCoordinator.d.ts +66 -0
  7. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/ChannelCoordinator.js +332 -0
  8. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/ChannelCoordinator.js.map +1 -0
  9. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.d.ts +28 -0
  10. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.js +203 -0
  11. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.js.map +1 -0
  12. package/dist/ApiClient/WebSocketClient/events.d.ts +11 -0
  13. package/dist/ApiClient/WebSocketClient/index.d.ts +2 -2
  14. package/dist/ApiClient/WebSocketClient/index.js +13 -3
  15. package/dist/ApiClient/WebSocketClient/index.js.map +1 -1
  16. package/dist/ApiClient/WebSocketClient/types.d.ts +13 -0
  17. package/dist/ApiClient/index.d.ts +4 -4
  18. package/dist/ApiClient/index.js +23 -4
  19. package/dist/ApiClient/index.js.map +1 -1
  20. package/dist/Projects/Job.d.ts +44 -4
  21. package/dist/Projects/Job.js +83 -16
  22. package/dist/Projects/Job.js.map +1 -1
  23. package/dist/Projects/Project.d.ts +18 -0
  24. package/dist/Projects/Project.js +34 -6
  25. package/dist/Projects/Project.js.map +1 -1
  26. package/dist/Projects/createJobRequestMessage.js +109 -15
  27. package/dist/Projects/createJobRequestMessage.js.map +1 -1
  28. package/dist/Projects/index.d.ts +110 -11
  29. package/dist/Projects/index.js +423 -42
  30. package/dist/Projects/index.js.map +1 -1
  31. package/dist/Projects/types/EstimationResponse.d.ts +2 -0
  32. package/dist/Projects/types/SamplerParams.d.ts +13 -0
  33. package/dist/Projects/types/SamplerParams.js +26 -0
  34. package/dist/Projects/types/SamplerParams.js.map +1 -0
  35. package/dist/Projects/types/SchedulerParams.d.ts +14 -0
  36. package/dist/Projects/types/SchedulerParams.js +24 -0
  37. package/dist/Projects/types/SchedulerParams.js.map +1 -0
  38. package/dist/Projects/types/events.d.ts +5 -1
  39. package/dist/Projects/types/index.d.ts +150 -39
  40. package/dist/Projects/types/index.js +13 -0
  41. package/dist/Projects/types/index.js.map +1 -1
  42. package/dist/Projects/utils.d.ts +19 -1
  43. package/dist/Projects/utils.js +68 -0
  44. package/dist/Projects/utils.js.map +1 -1
  45. package/dist/index.d.ts +12 -4
  46. package/dist/index.js +12 -4
  47. package/dist/index.js.map +1 -1
  48. package/dist/lib/AuthManager/TokenAuthManager.js +0 -2
  49. package/dist/lib/AuthManager/TokenAuthManager.js.map +1 -1
  50. package/dist/lib/DataEntity.js +4 -2
  51. package/dist/lib/DataEntity.js.map +1 -1
  52. package/dist/lib/validation.d.ts +7 -0
  53. package/dist/lib/validation.js +36 -0
  54. package/dist/lib/validation.js.map +1 -1
  55. package/package.json +4 -4
  56. package/src/Account/index.ts +30 -19
  57. package/src/ApiClient/WebSocketClient/BrowserWebSocketClient/ChannelCoordinator.ts +426 -0
  58. package/src/ApiClient/WebSocketClient/BrowserWebSocketClient/index.ts +237 -0
  59. package/src/ApiClient/WebSocketClient/events.ts +13 -0
  60. package/src/ApiClient/WebSocketClient/index.ts +15 -5
  61. package/src/ApiClient/WebSocketClient/types.ts +16 -0
  62. package/src/ApiClient/index.ts +30 -8
  63. package/src/Projects/Job.ts +97 -16
  64. package/src/Projects/Project.ts +42 -9
  65. package/src/Projects/createJobRequestMessage.ts +155 -36
  66. package/src/Projects/index.ts +447 -46
  67. package/src/Projects/types/EstimationResponse.ts +2 -0
  68. package/src/Projects/types/SamplerParams.ts +24 -0
  69. package/src/Projects/types/SchedulerParams.ts +22 -0
  70. package/src/Projects/types/events.ts +6 -0
  71. package/src/Projects/types/index.ts +181 -47
  72. package/src/Projects/utils.ts +66 -1
  73. package/src/index.ts +38 -11
  74. package/src/lib/AuthManager/TokenAuthManager.ts +0 -2
  75. package/src/lib/DataEntity.ts +4 -2
  76. package/src/lib/validation.ts +41 -0
@@ -4,12 +4,18 @@ import {
4
4
  EnhancementStrength,
5
5
  EstimateRequest,
6
6
  ImageUrlParams,
7
+ MediaUrlParams,
8
+ CostEstimation,
7
9
  ProjectParams,
8
10
  SizePreset,
9
- SupportedModel
11
+ SupportedModel,
12
+ ImageProjectParams,
13
+ VideoProjectParams,
14
+ VideoEstimateRequest
10
15
  } from './types';
11
16
  import {
12
17
  JobErrorData,
18
+ JobETAData,
13
19
  JobProgressData,
14
20
  JobResultData,
15
21
  JobStateData,
@@ -26,13 +32,48 @@ import ErrorData from '../types/ErrorData';
26
32
  import { SupernetType } from '../ApiClient/WebSocketClient/types';
27
33
  import Cache from '../lib/Cache';
28
34
  import { enhancementDefaults } from './Job';
29
- import { getEnhacementStrength } from './utils';
35
+ import {
36
+ getEnhacementStrength,
37
+ getVideoWorkflowType,
38
+ isVideoModel,
39
+ VIDEO_WORKFLOW_ASSETS
40
+ } from './utils';
30
41
  import { TokenType } from '../types/token';
42
+ import { validateSampler } from '../lib/validation';
31
43
 
32
44
  const sizePresetCache = new Cache<SizePreset[]>(10 * 60 * 1000);
33
45
  const GARBAGE_COLLECT_TIMEOUT = 30000;
34
46
  const MODELS_REFRESH_INTERVAL = 1000 * 60 * 60 * 24; // 24 hours
35
47
 
48
+ /**
49
+ * Detect content type from a file object.
50
+ * For File objects in browser, uses the type property.
51
+ * Returns undefined if content type cannot be detected.
52
+ */
53
+ function getFileContentType(file: File | Buffer | Blob): string | undefined {
54
+ if (file instanceof Blob && 'type' in file && file.type) {
55
+ return file.type;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /**
61
+ * Convert file to a format compatible with fetch body.
62
+ * Converts Node.js Buffer to Blob for cross-platform compatibility.
63
+ */
64
+ function toFetchBody(file: File | Buffer | Blob) {
65
+ // Node.js Buffer is not supported in browsers, so we can skip this conversion
66
+ if (typeof Buffer === 'undefined') {
67
+ return file;
68
+ }
69
+ if (Buffer.isBuffer(file)) {
70
+ // Copy Buffer data to a new ArrayBuffer to ensure type compatibility
71
+ const arrayBuffer = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength);
72
+ return new Blob([arrayBuffer as ArrayBuffer]);
73
+ }
74
+ return file;
75
+ }
76
+
36
77
  function mapErrorCodes(code: string): number {
37
78
  switch (code) {
38
79
  case 'serverRestarting':
@@ -62,6 +103,20 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
62
103
  return this._availableModels;
63
104
  }
64
105
 
106
+ /**
107
+ * Check if a model produces video output using the cached models list.
108
+ * Uses the `media` property from the models API when available,
109
+ * falls back to model ID prefix check if models aren't loaded yet.
110
+ */
111
+ isVideoModelId(modelId: string): boolean {
112
+ const model = this._supportedModels.data?.find((m) => m.id === modelId);
113
+ if (model) {
114
+ return model.media === 'video';
115
+ }
116
+ // Fallback to prefix check if models not loaded
117
+ return isVideoModel(modelId);
118
+ }
119
+
65
120
  constructor(config: ApiConfig) {
66
121
  super(config);
67
122
  // Listen to server events and emit them as project and job events
@@ -69,6 +124,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
69
124
  this.client.socket.on('swarmModels', this.handleSwarmModels.bind(this));
70
125
  this.client.socket.on('jobState', this.handleJobState.bind(this));
71
126
  this.client.socket.on('jobProgress', this.handleJobProgress.bind(this));
127
+ this.client.socket.on('jobETA', this.handleJobETA.bind(this));
72
128
  this.client.socket.on('jobError', this.handleJobError.bind(this));
73
129
  this.client.socket.on('jobResult', this.handleJobResult.bind(this));
74
130
  // Listen to the server disconnect event
@@ -78,6 +134,17 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
78
134
  this.on('job', this.handleJobEvent.bind(this));
79
135
  }
80
136
 
137
+ /**
138
+ * Retrieves a list of projects created and tracked by this SogniClient instance.
139
+ *
140
+ * Note: When a project is finished, it will be removed from this list after 30 seconds
141
+ *
142
+ * @return {Array} A copy of the array containing the tracked projects.
143
+ */
144
+ get trackedProjects() {
145
+ return this.projects.slice(0);
146
+ }
147
+
81
148
  private handleChangeNetwork() {
82
149
  this._availableModels = [];
83
150
  this.emit('availableModels', this._availableModels);
@@ -97,7 +164,8 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
97
164
  this._availableModels = Object.entries(data).map(([id, workerCount]) => ({
98
165
  id,
99
166
  name: modelIndex[id]?.name || id.replace(/-/g, ' '),
100
- workerCount
167
+ workerCount,
168
+ media: modelIndex[id]?.media || 'image'
101
169
  }));
102
170
  this.emit('availableModels', this._availableModels);
103
171
  }
@@ -165,6 +233,15 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
165
233
  }
166
234
  }
167
235
 
236
+ private async handleJobETA(data: JobETAData) {
237
+ this.emit('job', {
238
+ type: 'jobETA',
239
+ projectId: data.jobID,
240
+ jobId: data.imgID || '',
241
+ etaSeconds: data.etaSeconds
242
+ });
243
+ }
244
+
168
245
  private async handleJobResult(data: JobResultData) {
169
246
  const project = this.projects.find((p) => p.id === data.jobID);
170
247
  const passNSFWCheck = !data.triggeredNSFWFilter || !project || project.params.disableNSFWFilter;
@@ -172,11 +249,21 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
172
249
  // If NSFW filter is triggered, image will be only available for download if user explicitly
173
250
  // disabled the filter for this project
174
251
  if (passNSFWCheck && !data.userCanceled) {
175
- downloadUrl = await this.downloadUrl({
176
- jobId: data.jobID,
177
- imageId: data.imgID,
178
- type: 'complete'
179
- });
252
+ // Use media endpoint for video models, image endpoint for image models
253
+ const isVideo = project && this.isVideoModelId(project.params.modelId);
254
+ if (isVideo) {
255
+ downloadUrl = await this.mediaDownloadUrl({
256
+ jobId: data.jobID,
257
+ id: data.imgID,
258
+ type: 'complete'
259
+ });
260
+ } else {
261
+ downloadUrl = await this.downloadUrl({
262
+ jobId: data.jobID,
263
+ imageId: data.imgID,
264
+ type: 'complete'
265
+ });
266
+ }
180
267
  }
181
268
 
182
269
  this.emit('job', {
@@ -248,7 +335,11 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
248
335
  if (project.finished) {
249
336
  // Sync project data with the server and remove it from the list after some time
250
337
  project._syncToServer().catch((e) => {
251
- this.client.logger.error(e);
338
+ // 404 errors are expected when project is still initializing
339
+ // Only log non-404 errors to avoid confusing users
340
+ if (e.status !== 404) {
341
+ this.client.logger.error(e);
342
+ }
252
343
  });
253
344
  setTimeout(() => {
254
345
  this.projects = this.projects.filter((p) => !p.finished);
@@ -268,7 +359,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
268
359
  projectId: event.projectId,
269
360
  status: 'pending',
270
361
  step: 0,
271
- stepCount: project.params.steps
362
+ stepCount: project.params.steps ?? 0
272
363
  });
273
364
  }
274
365
  switch (event.type) {
@@ -295,7 +386,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
295
386
  case 'progress':
296
387
  job._update({
297
388
  status: 'processing',
298
- // Jus in case event comes out of order
389
+ // Just in case event comes out of order
299
390
  step: Math.max(event.step, job.step),
300
391
  stepCount: event.stepCount
301
392
  });
@@ -303,6 +394,25 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
303
394
  project._update({ status: 'processing' });
304
395
  }
305
396
  break;
397
+ case 'jobETA': {
398
+ const newEta = new Date(Date.now() + event.etaSeconds * 1000);
399
+ if (job.eta?.getTime() !== newEta?.getTime()) {
400
+ job._update({ eta: newEta });
401
+ const maxEta = project.jobs.reduce((max, j) => Math.max(max, j.eta?.getTime() || 0), 0);
402
+ const projectETA = maxEta ? new Date(maxEta) : undefined;
403
+ if (project.eta?.getTime() !== projectETA?.getTime()) {
404
+ project._update({ eta: projectETA });
405
+ }
406
+ } else {
407
+ // ETA updates keep the project alive (refreshes lastUpdated) and store the ETA value.
408
+ // This is critical for long-running jobs like video generation that can take several
409
+ // minutes and may not send frequent progress updates.
410
+ // We call _keepAlive() directly to ensure lastUpdated is refreshed even if the ETA
411
+ // value hasn't changed (job._update only emits 'updated' when values actually change).
412
+ project._keepAlive();
413
+ }
414
+ break;
415
+ }
306
416
  case 'preview':
307
417
  job._update({ previewUrl: event.url });
308
418
  break;
@@ -319,6 +429,17 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
319
429
  }
320
430
  case 'error':
321
431
  job._update({ status: 'failed', error: event.error });
432
+ // Check if project should also fail when a job fails
433
+ // For video jobs (single image) or when all jobs have failed, propagate to project
434
+ const allJobsStarted = project.jobs.length >= project.params.numberOfMedia;
435
+ const allJobsFailed = allJobsStarted && project.jobs.every((j) => j.status === 'failed');
436
+ const isSingleJobProject = project.params.numberOfMedia === 1;
437
+ if (isSingleJobProject || allJobsFailed) {
438
+ project._update({
439
+ status: 'failed',
440
+ error: event.error
441
+ });
442
+ }
322
443
  break;
323
444
  }
324
445
  }
@@ -341,53 +462,94 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
341
462
  return Promise.resolve(this._availableModels);
342
463
  }
343
464
  return new Promise((resolve, reject) => {
465
+ let settled = false;
344
466
  const timeoutId = setTimeout(() => {
345
- reject(new Error('Timeout waiting for models'));
467
+ if (!settled) {
468
+ settled = true;
469
+ this.off('availableModels', handler);
470
+ reject(new Error('Timeout waiting for models'));
471
+ }
346
472
  }, timeout);
347
- this.once('availableModels', (models) => {
348
- clearTimeout(timeoutId);
349
- if (models.length) {
473
+
474
+ const handler = (models: AvailableModel[]) => {
475
+ // Only resolve when we get a non-empty models list
476
+ // Empty arrays may be emitted during disconnects/reconnects
477
+ if (models.length && !settled) {
478
+ settled = true;
479
+ clearTimeout(timeoutId);
480
+ this.off('availableModels', handler);
350
481
  resolve(models);
351
- } else {
352
- reject(new Error('No models available'));
353
482
  }
354
- });
483
+ };
484
+
485
+ this.on('availableModels', handler);
355
486
  });
356
487
  }
357
488
 
358
489
  /**
359
490
  * Send new project request to the network. Returns project instance which can be used to track
360
- * progress and get resulting images.
491
+ * progress and get resulting images or videos.
361
492
  * @param data
362
493
  */
363
494
  async create(data: ProjectParams): Promise<Project> {
364
495
  const project = new Project({ ...data }, { api: this, logger: this.client.logger });
496
+ const request = createJobRequestMessage(project.id, data);
497
+ switch (data.type) {
498
+ case 'image':
499
+ await this._processImageAssets(project, data);
500
+ break;
501
+ case 'video':
502
+ await this._processVideoAssets(project, data);
503
+ break;
504
+ }
505
+ await this.client.socket.send('jobRequest', request);
506
+ this.projects.push(project);
507
+ return project;
508
+ }
509
+
510
+ private async _processImageAssets(project: Project, data: ImageProjectParams) {
511
+ //Guide image
365
512
  if (data.startingImage && data.startingImage !== true) {
366
513
  await this.uploadGuideImage(project.id, data.startingImage);
367
514
  }
515
+
516
+ // ControlNet image
368
517
  if (data.controlNet?.image && data.controlNet.image !== true) {
369
518
  await this.uploadCNImage(project.id, data.controlNet.image);
370
519
  }
520
+
521
+ // Context images (Flux.2 Dev, Qwen Image Edit Plus support up to 3; Flux Kontext supports up to 2)
371
522
  if (data.contextImages?.length) {
372
- if (data.contextImages.length > 2) {
523
+ if (data.contextImages.length > 3) {
373
524
  throw new ApiError(500, {
374
525
  status: 'error',
375
526
  errorCode: 0,
376
- message: `Up to 2 context images are supported`
527
+ message: `Up to 3 context images are supported`
377
528
  });
378
529
  }
379
530
  await Promise.all(
380
531
  data.contextImages.map((image, index) => {
381
532
  if (image && image !== true) {
382
- return this.uploadContextImage(project.id, index as 0 | 1, image);
533
+ return this.uploadContextImage(project.id, index as 0 | 1 | 2, image);
383
534
  }
384
535
  })
385
536
  );
386
537
  }
387
- const request = createJobRequestMessage(project.id, data);
388
- await this.client.socket.send('jobRequest', request);
389
- this.projects.push(project);
390
- return project;
538
+ }
539
+
540
+ private async _processVideoAssets(project: Project, data: VideoProjectParams) {
541
+ if (data?.referenceImage && data.referenceImage !== true) {
542
+ await this.uploadReferenceImage(project.id, data.referenceImage);
543
+ }
544
+ if (data?.referenceImageEnd && data.referenceImageEnd !== true) {
545
+ await this.uploadReferenceImageEnd(project.id, data.referenceImageEnd);
546
+ }
547
+ if (data?.referenceAudio && data.referenceAudio !== true) {
548
+ await this.uploadReferenceAudio(project.id, data.referenceAudio);
549
+ }
550
+ if (data?.referenceVideo && data.referenceVideo !== true) {
551
+ await this.uploadReferenceVideo(project.id, data.referenceVideo);
552
+ }
391
553
  }
392
554
 
393
555
  /**
@@ -422,7 +584,6 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
422
584
  }
423
585
  // Remove project from the list to stop tracking it
424
586
  this.projects = this.projects.filter((p) => p.id !== projectId);
425
-
426
587
  // Cancel all jobs in the project
427
588
  project.jobs.forEach((job) => {
428
589
  if (!job.finished) {
@@ -438,13 +599,13 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
438
599
  private async uploadGuideImage(projectId: string, file: File | Buffer | Blob) {
439
600
  const imageId = getUUID();
440
601
  const presignedUrl = await this.uploadUrl({
441
- imageId: imageId,
602
+ imageId,
442
603
  jobId: projectId,
443
604
  type: 'startingImage'
444
605
  });
445
606
  const res = await fetch(presignedUrl, {
446
607
  method: 'PUT',
447
- body: file
608
+ body: toFetchBody(file)
448
609
  });
449
610
  if (!res.ok) {
450
611
  throw new ApiError(res.status, {
@@ -459,13 +620,13 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
459
620
  private async uploadCNImage(projectId: string, file: File | Buffer | Blob) {
460
621
  const imageId = getUUID();
461
622
  const presignedUrl = await this.uploadUrl({
462
- imageId: imageId,
623
+ imageId,
463
624
  jobId: projectId,
464
625
  type: 'cnImage'
465
626
  });
466
627
  const res = await fetch(presignedUrl, {
467
628
  method: 'PUT',
468
- body: file
629
+ body: toFetchBody(file)
469
630
  });
470
631
  if (!res.ok) {
471
632
  throw new ApiError(res.status, {
@@ -477,9 +638,13 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
477
638
  return imageId;
478
639
  }
479
640
 
480
- private async uploadContextImage(projectId: string, index: 0 | 1, file: File | Buffer | Blob) {
641
+ private async uploadContextImage(
642
+ projectId: string,
643
+ index: 0 | 1 | 2,
644
+ file: File | Buffer | Blob
645
+ ) {
481
646
  const imageId = getUUID();
482
- const imageIndex = (index + 1) as 1 | 2;
647
+ const imageIndex = (index + 1) as 1 | 2 | 3;
483
648
  const presignedUrl = await this.uploadUrl({
484
649
  imageId,
485
650
  jobId: projectId,
@@ -487,7 +652,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
487
652
  });
488
653
  const res = await fetch(presignedUrl, {
489
654
  method: 'PUT',
490
- body: file
655
+ body: toFetchBody(file)
491
656
  });
492
657
  if (!res.ok) {
493
658
  throw new ApiError(res.status, {
@@ -499,8 +664,124 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
499
664
  return imageId;
500
665
  }
501
666
 
667
+ // ============================================
668
+ // VIDEO WORKFLOW UPLOADS (WAN 2.2)
669
+ // ============================================
670
+
671
+ /**
672
+ * Upload reference image for WAN video workflows
673
+ * @internal
674
+ */
675
+ private async uploadReferenceImage(projectId: string, file: File | Buffer | Blob) {
676
+ const imageId = getUUID();
677
+ const presignedUrl = await this.uploadUrl({
678
+ imageId,
679
+ jobId: projectId,
680
+ type: 'referenceImage'
681
+ });
682
+ const res = await fetch(presignedUrl, {
683
+ method: 'PUT',
684
+ body: toFetchBody(file)
685
+ });
686
+ if (!res.ok) {
687
+ throw new ApiError(res.status, {
688
+ status: 'error',
689
+ errorCode: 0,
690
+ message: 'Failed to upload reference image'
691
+ });
692
+ }
693
+ return imageId;
694
+ }
695
+
696
+ /**
697
+ * Upload reference image end for i2v interpolation
698
+ * @internal
699
+ */
700
+ private async uploadReferenceImageEnd(projectId: string, file: File | Buffer | Blob) {
701
+ const imageId = getUUID();
702
+ const presignedUrl = await this.uploadUrl({
703
+ imageId,
704
+ jobId: projectId,
705
+ type: 'referenceImageEnd'
706
+ });
707
+ const res = await fetch(presignedUrl, {
708
+ method: 'PUT',
709
+ body: toFetchBody(file)
710
+ });
711
+ if (!res.ok) {
712
+ throw new ApiError(res.status, {
713
+ status: 'error',
714
+ errorCode: 0,
715
+ message: 'Failed to upload reference image end'
716
+ });
717
+ }
718
+ return imageId;
719
+ }
720
+
502
721
  /**
503
- * Estimate project cost
722
+ * Upload reference audio for s2v workflows
723
+ * Supported formats: mp3, m4a, wav
724
+ * @internal
725
+ */
726
+ private async uploadReferenceAudio(projectId: string, file: File | Buffer | Blob) {
727
+ const contentType = getFileContentType(file);
728
+ const presignedUrl = await this.mediaUploadUrl({
729
+ jobId: projectId,
730
+ type: 'referenceAudio'
731
+ });
732
+ const headers: Record<string, string> = {};
733
+ if (contentType) {
734
+ headers['Content-Type'] = contentType;
735
+ }
736
+ const res = await fetch(presignedUrl, {
737
+ method: 'PUT',
738
+ body: toFetchBody(file),
739
+ headers
740
+ });
741
+ if (!res.ok) {
742
+ throw new ApiError(res.status, {
743
+ status: 'error',
744
+ errorCode: 0,
745
+ message: 'Failed to upload reference audio'
746
+ });
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Upload reference video for animate workflows
752
+ * Supported formats: mp4, mov
753
+ * @internal
754
+ */
755
+ private async uploadReferenceVideo(projectId: string, file: File | Buffer | Blob) {
756
+ const contentType = getFileContentType(file);
757
+ const presignedUrl = await this.mediaUploadUrl({
758
+ jobId: projectId,
759
+ type: 'referenceVideo'
760
+ });
761
+ const headers: Record<string, string> = {};
762
+ if (contentType) {
763
+ headers['Content-Type'] = contentType;
764
+ }
765
+ const res = await fetch(presignedUrl, {
766
+ method: 'PUT',
767
+ body: toFetchBody(file),
768
+ headers
769
+ });
770
+ if (!res.ok) {
771
+ throw new ApiError(res.status, {
772
+ status: 'error',
773
+ errorCode: 0,
774
+ message: 'Failed to upload reference video'
775
+ });
776
+ }
777
+ }
778
+
779
+ // ============================================
780
+ // COST ESTIMATION
781
+ // ============================================
782
+
783
+ /**
784
+ * Estimate image project cost
504
785
  */
505
786
  async estimateCost({
506
787
  network,
@@ -515,12 +796,12 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
515
796
  height,
516
797
  sizePreset,
517
798
  guidance,
518
- scheduler,
799
+ sampler,
519
800
  contextImages
520
- }: EstimateRequest) {
801
+ }: EstimateRequest): Promise<CostEstimation> {
521
802
  let apiVersion = 2;
522
803
  const pathParams = [
523
- tokenType || 'sogni',
804
+ tokenType || 'spark',
524
805
  network,
525
806
  model,
526
807
  imageCount,
@@ -541,10 +822,10 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
541
822
  } else {
542
823
  pathParams.push(0, 0);
543
824
  }
544
- if (scheduler) {
825
+ if (sampler) {
545
826
  apiVersion = 3;
546
827
  pathParams.push(guidance || 0);
547
- pathParams.push(scheduler || '');
828
+ pathParams.push(validateSampler(sampler)!);
548
829
  pathParams.push(contextImages || 0);
549
830
  }
550
831
  const r = await this.client.socket.get<EstimationResponse>(
@@ -552,11 +833,18 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
552
833
  );
553
834
  return {
554
835
  token: r.quote.project.costInToken,
555
- usd: r.quote.project.costInUSD
836
+ usd: r.quote.project.costInUSD,
837
+ spark: r.quote.project.costInSpark,
838
+ sogni: r.quote.project.costInSogni
556
839
  };
557
840
  }
558
841
 
559
- async estimateEnhancementCost(strength: EnhancementStrength, tokenType: TokenType = 'sogni') {
842
+ /**
843
+ * Estimate image enhancement cost
844
+ * @param strength
845
+ * @param tokenType
846
+ */
847
+ async estimateEnhancementCost(strength: EnhancementStrength, tokenType: TokenType = 'spark') {
560
848
  return this.estimateCost({
561
849
  network: enhancementDefaults.network,
562
850
  tokenType,
@@ -569,10 +857,52 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
569
857
  });
570
858
  }
571
859
 
860
+ /**
861
+ * Estimates the cost of generating a video based on the provided parameters.
862
+ *
863
+ * @param {VideoEstimateRequest} params - The parameters required for video cost estimation. This includes:
864
+ * - tokenType: The type of token to be used for generation.
865
+ * - model: The model to be used for video generation.
866
+ * - width: The width of the video in pixels.
867
+ * - height: The height of the video in pixels.
868
+ * - frames: The total number of frames in the video.
869
+ * - fps: The frames per second for the video.
870
+ * - steps: Number of steps.
871
+ * @return {Promise<Object>} Returns an object containing the estimated costs for the video in different units:
872
+ * - token: Cost in tokens.
873
+ * - usd: Cost in USD.
874
+ * - spark: Cost in Spark.
875
+ * - sogni: Cost in Sogni.
876
+ */
877
+ async estimateVideoCost(params: VideoEstimateRequest) {
878
+ const pathParams = [
879
+ params.tokenType,
880
+ params.model,
881
+ params.width,
882
+ params.height,
883
+ params.frames,
884
+ params.fps,
885
+ params.steps
886
+ ];
887
+ const path = pathParams.map((p) => encodeURIComponent(p)).join('/');
888
+ const r = await this.client.socket.get<EstimationResponse>(
889
+ `/api/v1/job-video/estimate/${path}`
890
+ );
891
+ return {
892
+ token: r.quote.project.costInToken,
893
+ usd: r.quote.project.costInUSD,
894
+ spark: r.quote.project.costInSpark,
895
+ sogni: r.quote.project.costInSogni
896
+ };
897
+ }
898
+
899
+ // ============================================
900
+ // URL HELPERS
901
+ // ============================================
902
+
572
903
  /**
573
904
  * Get upload URL for image
574
905
  * @internal
575
- * @param params
576
906
  */
577
907
  async uploadUrl(params: ImageUrlParams) {
578
908
  const r = await this.client.rest.get<ApiResponse<{ uploadUrl: string }>>(
@@ -585,7 +915,6 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
585
915
  /**
586
916
  * Get download URL for image
587
917
  * @internal
588
- * @param params
589
918
  */
590
919
  async downloadUrl(params: ImageUrlParams) {
591
920
  const r = await this.client.rest.get<ApiResponse<{ downloadUrl: string }>>(
@@ -595,6 +924,34 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
595
924
  return r.data.downloadUrl;
596
925
  }
597
926
 
927
+ /**
928
+ * Get upload URL for media (video/audio)
929
+ * @internal
930
+ */
931
+ async mediaUploadUrl(params: MediaUrlParams) {
932
+ const r = await this.client.rest.get<ApiResponse<{ uploadUrl: string }>>(
933
+ `/v1/media/uploadUrl`,
934
+ params
935
+ );
936
+ return r.data.uploadUrl;
937
+ }
938
+
939
+ /**
940
+ * Get download URL for media (video/audio)
941
+ * @internal
942
+ */
943
+ async mediaDownloadUrl(params: MediaUrlParams) {
944
+ const r = await this.client.rest.get<ApiResponse<{ downloadUrl: string }>>(
945
+ `/v1/media/downloadUrl`,
946
+ params
947
+ );
948
+ return r.data.downloadUrl;
949
+ }
950
+
951
+ // ============================================
952
+ // MODEL/PRESET HELPERS
953
+ // ============================================
954
+
598
955
  async getSupportedModels(forceRefresh = false) {
599
956
  if (
600
957
  this._supportedModels.data &&
@@ -613,7 +970,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
613
970
  *
614
971
  * @example
615
972
  * ```ts
616
- * const presets = await client.projects.getSizePresets('fast', 'flux1-schnell-fp8');
973
+ * const presets = await sogni.projects.getSizePresets('fast', 'flux1-schnell-fp8');
617
974
  * console.log(presets);
618
975
  * ```
619
976
  *
@@ -642,6 +999,49 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
642
999
  return data;
643
1000
  }
644
1001
 
1002
+ /**
1003
+ * Retrieves the video asset configuration for a given video model identifier.
1004
+ * Validates whether the provided model ID corresponds to a video model. If it does,
1005
+ * returns the appropriate video asset configuration based on the workflow type.
1006
+ *
1007
+ * @example Returned object for a model that implements image to video workflow:
1008
+ * ```json
1009
+ * {
1010
+ * "workflowType": "i2v",
1011
+ * "assets": {
1012
+ * "referenceImage": "required",
1013
+ * "referenceImageEnd": "optional",
1014
+ * "referenceAudio": "forbidden",
1015
+ * "referenceVideo": "forbidden"
1016
+ * }
1017
+ * }
1018
+ * ```
1019
+ *
1020
+ * @param {string} modelId - The identifier of the video model to retrieve the configuration for.
1021
+ * @return {Object} The video asset configuration object where key is asset field and value is
1022
+ * either `required`, `forbidden` or `optional`. Returns `null` if no rules defined for the model.
1023
+ * @throws {ApiError} Throws an error if the provided model ID is not a video model.
1024
+ */
1025
+ async getVideoAssetConfig(modelId: string) {
1026
+ if (!this.isVideoModelId(modelId)) {
1027
+ throw new ApiError(400, {
1028
+ status: 'error',
1029
+ errorCode: 0,
1030
+ message: `Model ${modelId} is not a video model`
1031
+ });
1032
+ }
1033
+ const workflow = getVideoWorkflowType(modelId);
1034
+ if (!workflow) {
1035
+ return {
1036
+ workflowType: null
1037
+ };
1038
+ }
1039
+ return {
1040
+ workflowType: workflow,
1041
+ assets: VIDEO_WORKFLOW_ASSETS[workflow]
1042
+ };
1043
+ }
1044
+
645
1045
  /**
646
1046
  * Get available models and their worker counts. Normally, you would get list once you connect
647
1047
  * to the server, but you can also call this method to get the list of available models manually.
@@ -658,7 +1058,8 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
658
1058
  return {
659
1059
  id: model?.id || sid,
660
1060
  name: model?.name || sid.replace(/-/g, ' '),
661
- workerCount
1061
+ workerCount,
1062
+ media: model?.media || 'image'
662
1063
  };
663
1064
  });
664
1065
  }