@sogni-ai/sogni-client 4.0.0-alpha.4 → 4.0.0-alpha.41

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 (95) hide show
  1. package/CHANGELOG.md +282 -0
  2. package/README.md +262 -31
  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 +12 -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 +36 -8
  25. package/dist/Projects/Project.js.map +1 -1
  26. package/dist/Projects/createJobRequestMessage.js +161 -15
  27. package/dist/Projects/createJobRequestMessage.js.map +1 -1
  28. package/dist/Projects/index.d.ts +112 -11
  29. package/dist/Projects/index.js +478 -47
  30. package/dist/Projects/index.js.map +1 -1
  31. package/dist/Projects/types/ComfySamplerParams.d.ts +28 -0
  32. package/dist/Projects/types/ComfySamplerParams.js +36 -0
  33. package/dist/Projects/types/ComfySamplerParams.js.map +1 -0
  34. package/dist/Projects/types/ComfySchedulerParams.d.ts +17 -0
  35. package/dist/Projects/types/ComfySchedulerParams.js +23 -0
  36. package/dist/Projects/types/ComfySchedulerParams.js.map +1 -0
  37. package/dist/Projects/types/EstimationResponse.d.ts +2 -0
  38. package/dist/Projects/types/ForgeSamplerParams.d.ts +27 -0
  39. package/dist/Projects/types/ForgeSamplerParams.js +39 -0
  40. package/dist/Projects/types/ForgeSamplerParams.js.map +1 -0
  41. package/dist/Projects/types/ForgeSchedulerParams.d.ts +17 -0
  42. package/dist/Projects/types/ForgeSchedulerParams.js +28 -0
  43. package/dist/Projects/types/ForgeSchedulerParams.js.map +1 -0
  44. package/dist/Projects/types/events.d.ts +5 -1
  45. package/dist/Projects/types/index.d.ts +189 -40
  46. package/dist/Projects/types/index.js +17 -0
  47. package/dist/Projects/types/index.js.map +1 -1
  48. package/dist/Projects/utils.d.ts +19 -1
  49. package/dist/Projects/utils.js +68 -0
  50. package/dist/Projects/utils.js.map +1 -1
  51. package/dist/index.d.ts +12 -4
  52. package/dist/index.js +14 -4
  53. package/dist/index.js.map +1 -1
  54. package/dist/lib/AuthManager/TokenAuthManager.js +0 -2
  55. package/dist/lib/AuthManager/TokenAuthManager.js.map +1 -1
  56. package/dist/lib/DataEntity.js +4 -2
  57. package/dist/lib/DataEntity.js.map +1 -1
  58. package/dist/lib/RestClient.js +15 -2
  59. package/dist/lib/RestClient.js.map +1 -1
  60. package/dist/lib/validation.d.ts +26 -2
  61. package/dist/lib/validation.js +96 -11
  62. package/dist/lib/validation.js.map +1 -1
  63. package/package.json +4 -4
  64. package/src/Account/index.ts +30 -19
  65. package/src/ApiClient/WebSocketClient/BrowserWebSocketClient/ChannelCoordinator.ts +426 -0
  66. package/src/ApiClient/WebSocketClient/BrowserWebSocketClient/index.ts +237 -0
  67. package/src/ApiClient/WebSocketClient/events.ts +14 -0
  68. package/src/ApiClient/WebSocketClient/index.ts +15 -5
  69. package/src/ApiClient/WebSocketClient/types.ts +16 -0
  70. package/src/ApiClient/index.ts +30 -8
  71. package/src/Projects/Job.ts +97 -16
  72. package/src/Projects/Project.ts +44 -11
  73. package/src/Projects/createJobRequestMessage.ts +211 -37
  74. package/src/Projects/index.ts +507 -51
  75. package/src/Projects/types/ComfySamplerParams.ts +34 -0
  76. package/src/Projects/types/ComfySchedulerParams.ts +21 -0
  77. package/src/Projects/types/EstimationResponse.ts +2 -0
  78. package/src/Projects/types/ForgeSamplerParams.ts +37 -0
  79. package/src/Projects/types/ForgeSchedulerParams.ts +26 -0
  80. package/src/Projects/types/events.ts +6 -0
  81. package/src/Projects/types/index.ts +227 -41
  82. package/src/Projects/utils.ts +66 -1
  83. package/src/index.ts +60 -8
  84. package/src/lib/AuthManager/TokenAuthManager.ts +0 -2
  85. package/src/lib/DataEntity.ts +4 -2
  86. package/src/lib/RestClient.ts +16 -2
  87. package/src/lib/validation.ts +111 -14
  88. package/dist/Projects/types/SamplerParams.d.ts +0 -15
  89. package/dist/Projects/types/SamplerParams.js +0 -21
  90. package/dist/Projects/types/SamplerParams.js.map +0 -1
  91. package/dist/Projects/types/SchedulerParams.d.ts +0 -13
  92. package/dist/Projects/types/SchedulerParams.js +0 -19
  93. package/dist/Projects/types/SchedulerParams.js.map +0 -1
  94. package/src/Projects/types/SamplerParams.ts +0 -19
  95. package/src/Projects/types/SchedulerParams.ts +0 -17
@@ -4,12 +4,26 @@ 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,
15
+ SupportedComfySamplers,
16
+ SupportedForgeSamplers,
17
+ SupportedComfySchedulers,
18
+ SupportedForgeSchedulers,
19
+ ComfyScheduler,
20
+ ForgeScheduler,
21
+ ComfySampler,
22
+ ForgeSampler
10
23
  } from './types';
11
24
  import {
12
25
  JobErrorData,
26
+ JobETAData,
13
27
  JobProgressData,
14
28
  JobResultData,
15
29
  JobStateData,
@@ -26,13 +40,48 @@ import ErrorData from '../types/ErrorData';
26
40
  import { SupernetType } from '../ApiClient/WebSocketClient/types';
27
41
  import Cache from '../lib/Cache';
28
42
  import { enhancementDefaults } from './Job';
29
- import { getEnhacementStrength } from './utils';
43
+ import {
44
+ getEnhacementStrength,
45
+ getVideoWorkflowType,
46
+ isVideoModel,
47
+ VIDEO_WORKFLOW_ASSETS
48
+ } from './utils';
30
49
  import { TokenType } from '../types/token';
50
+ import { isComfyModel, validateSampler } from '../lib/validation';
31
51
 
32
52
  const sizePresetCache = new Cache<SizePreset[]>(10 * 60 * 1000);
33
53
  const GARBAGE_COLLECT_TIMEOUT = 30000;
34
54
  const MODELS_REFRESH_INTERVAL = 1000 * 60 * 60 * 24; // 24 hours
35
55
 
56
+ /**
57
+ * Detect content type from a file object.
58
+ * For File objects in browser, uses the type property.
59
+ * Returns undefined if content type cannot be detected.
60
+ */
61
+ function getFileContentType(file: File | Buffer | Blob): string | undefined {
62
+ if (file instanceof Blob && 'type' in file && file.type) {
63
+ return file.type;
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /**
69
+ * Convert file to a format compatible with fetch body.
70
+ * Converts Node.js Buffer to Blob for cross-platform compatibility.
71
+ */
72
+ function toFetchBody(file: File | Buffer | Blob): BodyInit {
73
+ // Node.js Buffer is not supported in browsers, so we can skip this conversion
74
+ if (typeof Buffer === 'undefined') {
75
+ return file as BodyInit;
76
+ }
77
+ if (Buffer.isBuffer(file)) {
78
+ // Copy Buffer data to a new ArrayBuffer to ensure type compatibility
79
+ const arrayBuffer = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength);
80
+ return new Blob([arrayBuffer as ArrayBuffer]);
81
+ }
82
+ return file as BodyInit;
83
+ }
84
+
36
85
  function mapErrorCodes(code: string): number {
37
86
  switch (code) {
38
87
  case 'serverRestarting':
@@ -62,6 +111,20 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
62
111
  return this._availableModels;
63
112
  }
64
113
 
114
+ /**
115
+ * Check if a model produces video output using the cached models list.
116
+ * Uses the `media` property from the models API when available,
117
+ * falls back to model ID prefix check if models aren't loaded yet.
118
+ */
119
+ isVideoModelId(modelId: string): boolean {
120
+ const model = this._supportedModels.data?.find((m) => m.id === modelId);
121
+ if (model) {
122
+ return model.media === 'video';
123
+ }
124
+ // Fallback to prefix check if models not loaded
125
+ return isVideoModel(modelId);
126
+ }
127
+
65
128
  constructor(config: ApiConfig) {
66
129
  super(config);
67
130
  // Listen to server events and emit them as project and job events
@@ -69,8 +132,13 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
69
132
  this.client.socket.on('swarmModels', this.handleSwarmModels.bind(this));
70
133
  this.client.socket.on('jobState', this.handleJobState.bind(this));
71
134
  this.client.socket.on('jobProgress', this.handleJobProgress.bind(this));
135
+ this.client.socket.on('jobETA', this.handleJobETA.bind(this));
72
136
  this.client.socket.on('jobError', this.handleJobError.bind(this));
73
- this.client.socket.on('jobResult', this.handleJobResult.bind(this));
137
+ this.client.socket.on('jobResult', (data: any) => {
138
+ this.handleJobResult(data).catch((err) => {
139
+ this.client.logger.error('Error in handleJobResult:', err);
140
+ });
141
+ });
74
142
  // Listen to the server disconnect event
75
143
  this.client.on('disconnected', this.handleServerDisconnected.bind(this));
76
144
  // Listen to project and job events and update project and job instances
@@ -78,6 +146,17 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
78
146
  this.on('job', this.handleJobEvent.bind(this));
79
147
  }
80
148
 
149
+ /**
150
+ * Retrieves a list of projects created and tracked by this SogniClient instance.
151
+ *
152
+ * Note: When a project is finished, it will be removed from this list after 30 seconds
153
+ *
154
+ * @return {Array} A copy of the array containing the tracked projects.
155
+ */
156
+ get trackedProjects() {
157
+ return this.projects.slice(0);
158
+ }
159
+
81
160
  private handleChangeNetwork() {
82
161
  this._availableModels = [];
83
162
  this.emit('availableModels', this._availableModels);
@@ -97,7 +176,8 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
97
176
  this._availableModels = Object.entries(data).map(([id, workerCount]) => ({
98
177
  id,
99
178
  name: modelIndex[id]?.name || id.replace(/-/g, ' '),
100
- workerCount
179
+ workerCount,
180
+ media: modelIndex[id]?.media || 'image'
101
181
  }));
102
182
  this.emit('availableModels', this._availableModels);
103
183
  }
@@ -165,20 +245,59 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
165
245
  }
166
246
  }
167
247
 
248
+ private async handleJobETA(data: JobETAData) {
249
+ this.emit('job', {
250
+ type: 'jobETA',
251
+ projectId: data.jobID,
252
+ jobId: data.imgID || '',
253
+ etaSeconds: data.etaSeconds
254
+ });
255
+ }
256
+
168
257
  private async handleJobResult(data: JobResultData) {
169
258
  const project = this.projects.find((p) => p.id === data.jobID);
170
259
  const passNSFWCheck = !data.triggeredNSFWFilter || !project || project.params.disableNSFWFilter;
171
- let downloadUrl = null;
172
- // If NSFW filter is triggered, image will be only available for download if user explicitly
173
- // disabled the filter for this project
174
- if (passNSFWCheck && !data.userCanceled) {
175
- downloadUrl = await this.downloadUrl({
176
- jobId: data.jobID,
177
- imageId: data.imgID,
178
- type: 'complete'
179
- });
260
+ let downloadUrl = data.resultUrl || null; // Use resultUrl from event if provided
261
+
262
+ // If no resultUrl provided and NSFW check passes, generate download URL
263
+ if (!downloadUrl && passNSFWCheck && !data.userCanceled) {
264
+ // Use media endpoint for video models, image endpoint for image models
265
+ const isVideo = project && this.isVideoModelId(project.params.modelId);
266
+ try {
267
+ if (isVideo) {
268
+ downloadUrl = await this.mediaDownloadUrl({
269
+ jobId: data.jobID,
270
+ id: data.imgID,
271
+ type: 'complete'
272
+ });
273
+ } else {
274
+ downloadUrl = await this.downloadUrl({
275
+ jobId: data.jobID,
276
+ imageId: data.imgID,
277
+ type: 'complete'
278
+ });
279
+ }
280
+ } catch (error: any) {
281
+ // Continue with null downloadUrl - the event will indicate failure
282
+ }
180
283
  }
181
284
 
285
+ // Update the job directly with the result URL to prevent duplicate API calls
286
+ if (project) {
287
+ const job = project.job(data.imgID);
288
+ if (job) {
289
+ job._update({
290
+ status: data.userCanceled ? 'canceled' : 'completed',
291
+ step: data.performedStepCount,
292
+ seed: Number(data.lastSeed),
293
+ resultUrl: downloadUrl,
294
+ isNSFW: data.triggeredNSFWFilter,
295
+ userCanceled: data.userCanceled
296
+ });
297
+ }
298
+ }
299
+
300
+ // Emit job completion event with the generated download URL
182
301
  this.emit('job', {
183
302
  type: 'completed',
184
303
  projectId: data.jobID,
@@ -248,7 +367,11 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
248
367
  if (project.finished) {
249
368
  // Sync project data with the server and remove it from the list after some time
250
369
  project._syncToServer().catch((e) => {
251
- this.client.logger.error(e);
370
+ // 404 errors are expected when project is still initializing
371
+ // Only log non-404 errors to avoid confusing users
372
+ if (e.status !== 404) {
373
+ this.client.logger.error(e);
374
+ }
252
375
  });
253
376
  setTimeout(() => {
254
377
  this.projects = this.projects.filter((p) => !p.finished);
@@ -268,7 +391,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
268
391
  projectId: event.projectId,
269
392
  status: 'pending',
270
393
  step: 0,
271
- stepCount: project.params.steps
394
+ stepCount: project.params.steps ?? 0
272
395
  });
273
396
  }
274
397
  switch (event.type) {
@@ -295,7 +418,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
295
418
  case 'progress':
296
419
  job._update({
297
420
  status: 'processing',
298
- // Jus in case event comes out of order
421
+ // Just in case event comes out of order
299
422
  step: Math.max(event.step, job.step),
300
423
  stepCount: event.stepCount
301
424
  });
@@ -303,6 +426,25 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
303
426
  project._update({ status: 'processing' });
304
427
  }
305
428
  break;
429
+ case 'jobETA': {
430
+ const newEta = new Date(Date.now() + event.etaSeconds * 1000);
431
+ if (job.eta?.getTime() !== newEta?.getTime()) {
432
+ job._update({ eta: newEta });
433
+ const maxEta = project.jobs.reduce((max, j) => Math.max(max, j.eta?.getTime() || 0), 0);
434
+ const projectETA = maxEta ? new Date(maxEta) : undefined;
435
+ if (project.eta?.getTime() !== projectETA?.getTime()) {
436
+ project._update({ eta: projectETA });
437
+ }
438
+ } else {
439
+ // ETA updates keep the project alive (refreshes lastUpdated) and store the ETA value.
440
+ // This is critical for long-running jobs like video generation that can take several
441
+ // minutes and may not send frequent progress updates.
442
+ // We call _keepAlive() directly to ensure lastUpdated is refreshed even if the ETA
443
+ // value hasn't changed (job._update only emits 'updated' when values actually change).
444
+ project._keepAlive();
445
+ }
446
+ break;
447
+ }
306
448
  case 'preview':
307
449
  job._update({ previewUrl: event.url });
308
450
  break;
@@ -319,6 +461,17 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
319
461
  }
320
462
  case 'error':
321
463
  job._update({ status: 'failed', error: event.error });
464
+ // Check if project should also fail when a job fails
465
+ // For video jobs (single image) or when all jobs have failed, propagate to project
466
+ const allJobsStarted = project.jobs.length >= project.params.numberOfMedia;
467
+ const allJobsFailed = allJobsStarted && project.jobs.every((j) => j.status === 'failed');
468
+ const isSingleJobProject = project.params.numberOfMedia === 1;
469
+ if (isSingleJobProject || allJobsFailed) {
470
+ project._update({
471
+ status: 'failed',
472
+ error: event.error
473
+ });
474
+ }
322
475
  break;
323
476
  }
324
477
  }
@@ -341,53 +494,95 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
341
494
  return Promise.resolve(this._availableModels);
342
495
  }
343
496
  return new Promise((resolve, reject) => {
497
+ let settled = false;
344
498
  const timeoutId = setTimeout(() => {
345
- reject(new Error('Timeout waiting for models'));
499
+ if (!settled) {
500
+ settled = true;
501
+ this.off('availableModels', handler);
502
+ reject(new Error('Timeout waiting for models'));
503
+ }
346
504
  }, timeout);
347
- this.once('availableModels', (models) => {
348
- clearTimeout(timeoutId);
349
- if (models.length) {
505
+
506
+ const handler = (models: AvailableModel[]) => {
507
+ // Only resolve when we get a non-empty models list
508
+ // Empty arrays may be emitted during disconnects/reconnects
509
+ if (models.length && !settled) {
510
+ settled = true;
511
+ clearTimeout(timeoutId);
512
+ this.off('availableModels', handler);
350
513
  resolve(models);
351
- } else {
352
- reject(new Error('No models available'));
353
514
  }
354
- });
515
+ };
516
+
517
+ this.on('availableModels', handler);
355
518
  });
356
519
  }
357
520
 
358
521
  /**
359
522
  * Send new project request to the network. Returns project instance which can be used to track
360
- * progress and get resulting images.
523
+ * progress and get resulting images or videos.
361
524
  * @param data
362
525
  */
363
526
  async create(data: ProjectParams): Promise<Project> {
364
527
  const project = new Project({ ...data }, { api: this, logger: this.client.logger });
528
+ const request = createJobRequestMessage(project.id, data);
529
+
530
+ switch (data.type) {
531
+ case 'image':
532
+ await this._processImageAssets(project, data);
533
+ break;
534
+ case 'video':
535
+ await this._processVideoAssets(project, data);
536
+ break;
537
+ }
538
+ await this.client.socket.send('jobRequest', request);
539
+ this.projects.push(project);
540
+ return project;
541
+ }
542
+
543
+ private async _processImageAssets(project: Project, data: ImageProjectParams) {
544
+ //Guide image
365
545
  if (data.startingImage && data.startingImage !== true) {
366
546
  await this.uploadGuideImage(project.id, data.startingImage);
367
547
  }
548
+
549
+ // ControlNet image
368
550
  if (data.controlNet?.image && data.controlNet.image !== true) {
369
551
  await this.uploadCNImage(project.id, data.controlNet.image);
370
552
  }
553
+
554
+ // Context images (Flux.2 Dev, Qwen Image Edit Plus support up to 3; Flux Kontext supports up to 2)
371
555
  if (data.contextImages?.length) {
372
- if (data.contextImages.length > 2) {
556
+ if (data.contextImages.length > 3) {
373
557
  throw new ApiError(500, {
374
558
  status: 'error',
375
559
  errorCode: 0,
376
- message: `Up to 2 context images are supported`
560
+ message: `Up to 3 context images are supported`
377
561
  });
378
562
  }
379
563
  await Promise.all(
380
564
  data.contextImages.map((image, index) => {
381
565
  if (image && image !== true) {
382
- return this.uploadContextImage(project.id, index as 0 | 1, image);
566
+ return this.uploadContextImage(project.id, index as 0 | 1 | 2, image);
383
567
  }
384
568
  })
385
569
  );
386
570
  }
387
- const request = createJobRequestMessage(project.id, data);
388
- await this.client.socket.send('jobRequest', request);
389
- this.projects.push(project);
390
- return project;
571
+ }
572
+
573
+ private async _processVideoAssets(project: Project, data: VideoProjectParams) {
574
+ if (data?.referenceImage && data.referenceImage !== true) {
575
+ await this.uploadReferenceImage(project.id, data.referenceImage);
576
+ }
577
+ if (data?.referenceImageEnd && data.referenceImageEnd !== true) {
578
+ await this.uploadReferenceImageEnd(project.id, data.referenceImageEnd);
579
+ }
580
+ if (data?.referenceAudio && data.referenceAudio !== true) {
581
+ await this.uploadReferenceAudio(project.id, data.referenceAudio);
582
+ }
583
+ if (data?.referenceVideo && data.referenceVideo !== true) {
584
+ await this.uploadReferenceVideo(project.id, data.referenceVideo);
585
+ }
391
586
  }
392
587
 
393
588
  /**
@@ -422,7 +617,6 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
422
617
  }
423
618
  // Remove project from the list to stop tracking it
424
619
  this.projects = this.projects.filter((p) => p.id !== projectId);
425
-
426
620
  // Cancel all jobs in the project
427
621
  project.jobs.forEach((job) => {
428
622
  if (!job.finished) {
@@ -438,13 +632,13 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
438
632
  private async uploadGuideImage(projectId: string, file: File | Buffer | Blob) {
439
633
  const imageId = getUUID();
440
634
  const presignedUrl = await this.uploadUrl({
441
- imageId: imageId,
635
+ imageId,
442
636
  jobId: projectId,
443
637
  type: 'startingImage'
444
638
  });
445
639
  const res = await fetch(presignedUrl, {
446
640
  method: 'PUT',
447
- body: file
641
+ body: toFetchBody(file)
448
642
  });
449
643
  if (!res.ok) {
450
644
  throw new ApiError(res.status, {
@@ -459,13 +653,13 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
459
653
  private async uploadCNImage(projectId: string, file: File | Buffer | Blob) {
460
654
  const imageId = getUUID();
461
655
  const presignedUrl = await this.uploadUrl({
462
- imageId: imageId,
656
+ imageId,
463
657
  jobId: projectId,
464
658
  type: 'cnImage'
465
659
  });
466
660
  const res = await fetch(presignedUrl, {
467
661
  method: 'PUT',
468
- body: file
662
+ body: toFetchBody(file)
469
663
  });
470
664
  if (!res.ok) {
471
665
  throw new ApiError(res.status, {
@@ -477,17 +671,22 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
477
671
  return imageId;
478
672
  }
479
673
 
480
- private async uploadContextImage(projectId: string, index: 0 | 1, file: File | Buffer | Blob) {
674
+ private async uploadContextImage(
675
+ projectId: string,
676
+ index: 0 | 1 | 2,
677
+ file: File | Buffer | Blob
678
+ ) {
481
679
  const imageId = getUUID();
482
- const imageIndex = (index + 1) as 1 | 2;
680
+ const imageIndex = (index + 1) as 1 | 2 | 3;
483
681
  const presignedUrl = await this.uploadUrl({
484
682
  imageId,
485
683
  jobId: projectId,
486
684
  type: `contextImage${imageIndex}`
487
685
  });
686
+ const body = toFetchBody(file);
488
687
  const res = await fetch(presignedUrl, {
489
688
  method: 'PUT',
490
- body: file
689
+ body
491
690
  });
492
691
  if (!res.ok) {
493
692
  throw new ApiError(res.status, {
@@ -499,8 +698,124 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
499
698
  return imageId;
500
699
  }
501
700
 
701
+ // ============================================
702
+ // VIDEO WORKFLOW UPLOADS (WAN 2.2)
703
+ // ============================================
704
+
502
705
  /**
503
- * Estimate project cost
706
+ * Upload reference image for WAN video workflows
707
+ * @internal
708
+ */
709
+ private async uploadReferenceImage(projectId: string, file: File | Buffer | Blob) {
710
+ const imageId = getUUID();
711
+ const presignedUrl = await this.uploadUrl({
712
+ imageId,
713
+ jobId: projectId,
714
+ type: 'referenceImage'
715
+ });
716
+ const res = await fetch(presignedUrl, {
717
+ method: 'PUT',
718
+ body: toFetchBody(file)
719
+ });
720
+ if (!res.ok) {
721
+ throw new ApiError(res.status, {
722
+ status: 'error',
723
+ errorCode: 0,
724
+ message: 'Failed to upload reference image'
725
+ });
726
+ }
727
+ return imageId;
728
+ }
729
+
730
+ /**
731
+ * Upload reference image end for i2v interpolation
732
+ * @internal
733
+ */
734
+ private async uploadReferenceImageEnd(projectId: string, file: File | Buffer | Blob) {
735
+ const imageId = getUUID();
736
+ const presignedUrl = await this.uploadUrl({
737
+ imageId,
738
+ jobId: projectId,
739
+ type: 'referenceImageEnd'
740
+ });
741
+ const res = await fetch(presignedUrl, {
742
+ method: 'PUT',
743
+ body: toFetchBody(file)
744
+ });
745
+ if (!res.ok) {
746
+ throw new ApiError(res.status, {
747
+ status: 'error',
748
+ errorCode: 0,
749
+ message: 'Failed to upload reference image end'
750
+ });
751
+ }
752
+ return imageId;
753
+ }
754
+
755
+ /**
756
+ * Upload reference audio for s2v workflows
757
+ * Supported formats: mp3, m4a, wav
758
+ * @internal
759
+ */
760
+ private async uploadReferenceAudio(projectId: string, file: File | Buffer | Blob) {
761
+ const contentType = getFileContentType(file);
762
+ const presignedUrl = await this.mediaUploadUrl({
763
+ jobId: projectId,
764
+ type: 'referenceAudio'
765
+ });
766
+ const headers: Record<string, string> = {};
767
+ if (contentType) {
768
+ headers['Content-Type'] = contentType;
769
+ }
770
+ const res = await fetch(presignedUrl, {
771
+ method: 'PUT',
772
+ body: toFetchBody(file),
773
+ headers
774
+ });
775
+ if (!res.ok) {
776
+ throw new ApiError(res.status, {
777
+ status: 'error',
778
+ errorCode: 0,
779
+ message: 'Failed to upload reference audio'
780
+ });
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Upload reference video for animate workflows
786
+ * Supported formats: mp4, mov
787
+ * @internal
788
+ */
789
+ private async uploadReferenceVideo(projectId: string, file: File | Buffer | Blob) {
790
+ const contentType = getFileContentType(file);
791
+ const presignedUrl = await this.mediaUploadUrl({
792
+ jobId: projectId,
793
+ type: 'referenceVideo'
794
+ });
795
+ const headers: Record<string, string> = {};
796
+ if (contentType) {
797
+ headers['Content-Type'] = contentType;
798
+ }
799
+ const res = await fetch(presignedUrl, {
800
+ method: 'PUT',
801
+ body: toFetchBody(file),
802
+ headers
803
+ });
804
+ if (!res.ok) {
805
+ throw new ApiError(res.status, {
806
+ status: 'error',
807
+ errorCode: 0,
808
+ message: 'Failed to upload reference video'
809
+ });
810
+ }
811
+ }
812
+
813
+ // ============================================
814
+ // COST ESTIMATION
815
+ // ============================================
816
+
817
+ /**
818
+ * Estimate image project cost
504
819
  */
505
820
  async estimateCost({
506
821
  network,
@@ -515,12 +830,12 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
515
830
  height,
516
831
  sizePreset,
517
832
  guidance,
518
- scheduler,
833
+ sampler,
519
834
  contextImages
520
- }: EstimateRequest) {
835
+ }: EstimateRequest): Promise<CostEstimation> {
521
836
  let apiVersion = 2;
522
837
  const pathParams = [
523
- tokenType || 'sogni',
838
+ tokenType || 'spark',
524
839
  network,
525
840
  model,
526
841
  imageCount,
@@ -541,10 +856,10 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
541
856
  } else {
542
857
  pathParams.push(0, 0);
543
858
  }
544
- if (scheduler) {
859
+ if (sampler) {
545
860
  apiVersion = 3;
546
861
  pathParams.push(guidance || 0);
547
- pathParams.push(scheduler || '');
862
+ pathParams.push(validateSampler(model, sampler)!);
548
863
  pathParams.push(contextImages || 0);
549
864
  }
550
865
  const r = await this.client.socket.get<EstimationResponse>(
@@ -552,11 +867,18 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
552
867
  );
553
868
  return {
554
869
  token: r.quote.project.costInToken,
555
- usd: r.quote.project.costInUSD
870
+ usd: r.quote.project.costInUSD,
871
+ spark: r.quote.project.costInSpark,
872
+ sogni: r.quote.project.costInSogni
556
873
  };
557
874
  }
558
875
 
559
- async estimateEnhancementCost(strength: EnhancementStrength, tokenType: TokenType = 'sogni') {
876
+ /**
877
+ * Estimate image enhancement cost
878
+ * @param strength
879
+ * @param tokenType
880
+ */
881
+ async estimateEnhancementCost(strength: EnhancementStrength, tokenType: TokenType = 'spark') {
560
882
  return this.estimateCost({
561
883
  network: enhancementDefaults.network,
562
884
  tokenType,
@@ -569,10 +891,53 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
569
891
  });
570
892
  }
571
893
 
894
+ /**
895
+ * Estimates the cost of generating a video based on the provided parameters.
896
+ *
897
+ * @param {VideoEstimateRequest} params - The parameters required for video cost estimation. This includes:
898
+ * - tokenType: The type of token to be used for generation.
899
+ * - model: The model to be used for video generation.
900
+ * - width: The width of the video in pixels.
901
+ * - height: The height of the video in pixels.
902
+ * - frames: The total number of frames in the video.
903
+ * - fps: The frames per second for the video.
904
+ * - steps: Number of steps.
905
+ * @return {Promise<Object>} Returns an object containing the estimated costs for the video in different units:
906
+ * - token: Cost in tokens.
907
+ * - usd: Cost in USD.
908
+ * - spark: Cost in Spark.
909
+ * - sogni: Cost in Sogni.
910
+ */
911
+ async estimateVideoCost(params: VideoEstimateRequest) {
912
+ const pathParams = [
913
+ params.tokenType,
914
+ params.model,
915
+ params.width,
916
+ params.height,
917
+ params.frames ? params.frames : params.duration * 16 + 1,
918
+ params.fps,
919
+ params.steps,
920
+ params.numberOfMedia
921
+ ];
922
+ const path = pathParams.map((p) => encodeURIComponent(p)).join('/');
923
+ const r = await this.client.socket.get<EstimationResponse>(
924
+ `/api/v1/job-video/estimate/${path}`
925
+ );
926
+ return {
927
+ token: r.quote.project.costInToken,
928
+ usd: r.quote.project.costInUSD,
929
+ spark: r.quote.project.costInSpark,
930
+ sogni: r.quote.project.costInSogni
931
+ };
932
+ }
933
+
934
+ // ============================================
935
+ // URL HELPERS
936
+ // ============================================
937
+
572
938
  /**
573
939
  * Get upload URL for image
574
940
  * @internal
575
- * @param params
576
941
  */
577
942
  async uploadUrl(params: ImageUrlParams) {
578
943
  const r = await this.client.rest.get<ApiResponse<{ uploadUrl: string }>>(
@@ -585,16 +950,49 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
585
950
  /**
586
951
  * Get download URL for image
587
952
  * @internal
588
- * @param params
589
953
  */
590
954
  async downloadUrl(params: ImageUrlParams) {
591
955
  const r = await this.client.rest.get<ApiResponse<{ downloadUrl: string }>>(
592
956
  `/v1/image/downloadUrl`,
593
957
  params
594
958
  );
959
+ if (!r?.data?.downloadUrl) {
960
+ throw new Error(`API returned no downloadUrl: ${JSON.stringify(r)}`);
961
+ }
962
+ return r.data.downloadUrl;
963
+ }
964
+
965
+ /**
966
+ * Get upload URL for media (video/audio)
967
+ * @internal
968
+ */
969
+ async mediaUploadUrl(params: MediaUrlParams) {
970
+ const r = await this.client.rest.get<ApiResponse<{ uploadUrl: string }>>(
971
+ `/v1/media/uploadUrl`,
972
+ params
973
+ );
974
+ return r.data.uploadUrl;
975
+ }
976
+
977
+ /**
978
+ * Get download URL for media (video/audio)
979
+ * @internal
980
+ */
981
+ async mediaDownloadUrl(params: MediaUrlParams) {
982
+ const r = await this.client.rest.get<ApiResponse<{ downloadUrl: string }>>(
983
+ `/v1/media/downloadUrl`,
984
+ params
985
+ );
986
+ if (!r?.data?.downloadUrl) {
987
+ throw new Error(`API returned no downloadUrl: ${JSON.stringify(r)}`);
988
+ }
595
989
  return r.data.downloadUrl;
596
990
  }
597
991
 
992
+ // ============================================
993
+ // MODEL/PRESET HELPERS
994
+ // ============================================
995
+
598
996
  async getSupportedModels(forceRefresh = false) {
599
997
  if (
600
998
  this._supportedModels.data &&
@@ -613,7 +1011,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
613
1011
  *
614
1012
  * @example
615
1013
  * ```ts
616
- * const presets = await client.projects.getSizePresets('fast', 'flux1-schnell-fp8');
1014
+ * const presets = await sogni.projects.getSizePresets('fast', 'flux1-schnell-fp8');
617
1015
  * console.log(presets);
618
1016
  * ```
619
1017
  *
@@ -642,6 +1040,49 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
642
1040
  return data;
643
1041
  }
644
1042
 
1043
+ /**
1044
+ * Retrieves the video asset configuration for a given video model identifier.
1045
+ * Validates whether the provided model ID corresponds to a video model. If it does,
1046
+ * returns the appropriate video asset configuration based on the workflow type.
1047
+ *
1048
+ * @example Returned object for a model that implements image to video workflow:
1049
+ * ```json
1050
+ * {
1051
+ * "workflowType": "i2v",
1052
+ * "assets": {
1053
+ * "referenceImage": "required",
1054
+ * "referenceImageEnd": "optional",
1055
+ * "referenceAudio": "forbidden",
1056
+ * "referenceVideo": "forbidden"
1057
+ * }
1058
+ * }
1059
+ * ```
1060
+ *
1061
+ * @param {string} modelId - The identifier of the video model to retrieve the configuration for.
1062
+ * @return {Object} The video asset configuration object where key is asset field and value is
1063
+ * either `required`, `forbidden` or `optional`. Returns `null` if no rules defined for the model.
1064
+ * @throws {ApiError} Throws an error if the provided model ID is not a video model.
1065
+ */
1066
+ async getVideoAssetConfig(modelId: string) {
1067
+ if (!this.isVideoModelId(modelId)) {
1068
+ throw new ApiError(400, {
1069
+ status: 'error',
1070
+ errorCode: 0,
1071
+ message: `Model ${modelId} is not a video model`
1072
+ });
1073
+ }
1074
+ const workflow = getVideoWorkflowType(modelId);
1075
+ if (!workflow) {
1076
+ return {
1077
+ workflowType: null
1078
+ };
1079
+ }
1080
+ return {
1081
+ workflowType: workflow,
1082
+ assets: VIDEO_WORKFLOW_ASSETS[workflow]
1083
+ };
1084
+ }
1085
+
645
1086
  /**
646
1087
  * Get available models and their worker counts. Normally, you would get list once you connect
647
1088
  * to the server, but you can also call this method to get the list of available models manually.
@@ -658,10 +1099,25 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
658
1099
  return {
659
1100
  id: model?.id || sid,
660
1101
  name: model?.name || sid.replace(/-/g, ' '),
661
- workerCount
1102
+ workerCount,
1103
+ media: model?.media || 'image'
662
1104
  };
663
1105
  });
664
1106
  }
1107
+
1108
+ async getSamplers(modelId: string) {
1109
+ if (isComfyModel(modelId)) {
1110
+ return Object.keys(SupportedComfySamplers) as ComfySampler[];
1111
+ }
1112
+ return Object.keys(SupportedForgeSamplers) as ForgeSampler[];
1113
+ }
1114
+
1115
+ async getSchedulers(modelId: string) {
1116
+ if (isComfyModel(modelId)) {
1117
+ return Object.keys(SupportedComfySchedulers) as ComfyScheduler[];
1118
+ }
1119
+ return Object.keys(SupportedForgeSchedulers) as ForgeScheduler[];
1120
+ }
665
1121
  }
666
1122
 
667
1123
  export default ProjectsApi;