@sogni-ai/sogni-client 4.0.0-alpha.9 → 4.0.0

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 +268 -0
  2. package/README.md +262 -27
  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 +38 -10
  25. package/dist/Projects/Project.js.map +1 -1
  26. package/dist/Projects/createJobRequestMessage.js +170 -13
  27. package/dist/Projects/createJobRequestMessage.js.map +1 -1
  28. package/dist/Projects/index.d.ts +112 -11
  29. package/dist/Projects/index.js +473 -45
  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 +205 -38
  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 +95 -10
  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 +46 -13
  73. package/src/Projects/createJobRequestMessage.ts +222 -35
  74. package/src/Projects/index.ts +504 -49
  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 +243 -39
  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 -12
  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,24 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
303
426
  project._update({ status: 'processing' });
304
427
  }
305
428
  break;
429
+ case 'jobETA': {
430
+ // ETA updates keep the project alive (refreshes lastUpdated) and store the ETA value.
431
+ // This is critical for long-running jobs like video generation that can take several
432
+ // minutes and may not send frequent progress updates.
433
+ // We always call _keepAlive() to ensure lastUpdated is refreshed, preventing premature timeouts.
434
+ project._keepAlive();
435
+
436
+ const newEta = new Date(Date.now() + event.etaSeconds * 1000);
437
+ if (job.eta?.getTime() !== newEta?.getTime()) {
438
+ job._update({ eta: newEta });
439
+ const maxEta = project.jobs.reduce((max, j) => Math.max(max, j.eta?.getTime() || 0), 0);
440
+ const projectETA = maxEta ? new Date(maxEta) : undefined;
441
+ if (project.eta?.getTime() !== projectETA?.getTime()) {
442
+ project._update({ eta: projectETA });
443
+ }
444
+ }
445
+ break;
446
+ }
306
447
  case 'preview':
307
448
  job._update({ previewUrl: event.url });
308
449
  break;
@@ -319,6 +460,17 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
319
460
  }
320
461
  case 'error':
321
462
  job._update({ status: 'failed', error: event.error });
463
+ // Check if project should also fail when a job fails
464
+ // For video jobs (single image) or when all jobs have failed, propagate to project
465
+ const allJobsStarted = project.jobs.length >= project.params.numberOfMedia;
466
+ const allJobsFailed = allJobsStarted && project.jobs.every((j) => j.status === 'failed');
467
+ const isSingleJobProject = project.params.numberOfMedia === 1;
468
+ if (isSingleJobProject || allJobsFailed) {
469
+ project._update({
470
+ status: 'failed',
471
+ error: event.error
472
+ });
473
+ }
322
474
  break;
323
475
  }
324
476
  }
@@ -341,53 +493,95 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
341
493
  return Promise.resolve(this._availableModels);
342
494
  }
343
495
  return new Promise((resolve, reject) => {
496
+ let settled = false;
344
497
  const timeoutId = setTimeout(() => {
345
- reject(new Error('Timeout waiting for models'));
498
+ if (!settled) {
499
+ settled = true;
500
+ this.off('availableModels', handler);
501
+ reject(new Error('Timeout waiting for models'));
502
+ }
346
503
  }, timeout);
347
- this.once('availableModels', (models) => {
348
- clearTimeout(timeoutId);
349
- if (models.length) {
504
+
505
+ const handler = (models: AvailableModel[]) => {
506
+ // Only resolve when we get a non-empty models list
507
+ // Empty arrays may be emitted during disconnects/reconnects
508
+ if (models.length && !settled) {
509
+ settled = true;
510
+ clearTimeout(timeoutId);
511
+ this.off('availableModels', handler);
350
512
  resolve(models);
351
- } else {
352
- reject(new Error('No models available'));
353
513
  }
354
- });
514
+ };
515
+
516
+ this.on('availableModels', handler);
355
517
  });
356
518
  }
357
519
 
358
520
  /**
359
521
  * Send new project request to the network. Returns project instance which can be used to track
360
- * progress and get resulting images.
522
+ * progress and get resulting images or videos.
361
523
  * @param data
362
524
  */
363
525
  async create(data: ProjectParams): Promise<Project> {
364
526
  const project = new Project({ ...data }, { api: this, logger: this.client.logger });
527
+ const request = createJobRequestMessage(project.id, data);
528
+
529
+ switch (data.type) {
530
+ case 'image':
531
+ await this._processImageAssets(project, data);
532
+ break;
533
+ case 'video':
534
+ await this._processVideoAssets(project, data);
535
+ break;
536
+ }
537
+ await this.client.socket.send('jobRequest', request);
538
+ this.projects.push(project);
539
+ return project;
540
+ }
541
+
542
+ private async _processImageAssets(project: Project, data: ImageProjectParams) {
543
+ //Guide image
365
544
  if (data.startingImage && data.startingImage !== true) {
366
545
  await this.uploadGuideImage(project.id, data.startingImage);
367
546
  }
547
+
548
+ // ControlNet image
368
549
  if (data.controlNet?.image && data.controlNet.image !== true) {
369
550
  await this.uploadCNImage(project.id, data.controlNet.image);
370
551
  }
552
+
553
+ // Context images (Flux.2 Dev, Qwen Image Edit Plus support up to 3; Flux Kontext supports up to 2)
371
554
  if (data.contextImages?.length) {
372
- if (data.contextImages.length > 2) {
555
+ if (data.contextImages.length > 3) {
373
556
  throw new ApiError(500, {
374
557
  status: 'error',
375
558
  errorCode: 0,
376
- message: `Up to 2 context images are supported`
559
+ message: `Up to 3 context images are supported`
377
560
  });
378
561
  }
379
562
  await Promise.all(
380
563
  data.contextImages.map((image, index) => {
381
564
  if (image && image !== true) {
382
- return this.uploadContextImage(project.id, index as 0 | 1, image);
565
+ return this.uploadContextImage(project.id, index as 0 | 1 | 2, image);
383
566
  }
384
567
  })
385
568
  );
386
569
  }
387
- const request = createJobRequestMessage(project.id, data);
388
- await this.client.socket.send('jobRequest', request);
389
- this.projects.push(project);
390
- return project;
570
+ }
571
+
572
+ private async _processVideoAssets(project: Project, data: VideoProjectParams) {
573
+ if (data?.referenceImage && data.referenceImage !== true) {
574
+ await this.uploadReferenceImage(project.id, data.referenceImage);
575
+ }
576
+ if (data?.referenceImageEnd && data.referenceImageEnd !== true) {
577
+ await this.uploadReferenceImageEnd(project.id, data.referenceImageEnd);
578
+ }
579
+ if (data?.referenceAudio && data.referenceAudio !== true) {
580
+ await this.uploadReferenceAudio(project.id, data.referenceAudio);
581
+ }
582
+ if (data?.referenceVideo && data.referenceVideo !== true) {
583
+ await this.uploadReferenceVideo(project.id, data.referenceVideo);
584
+ }
391
585
  }
392
586
 
393
587
  /**
@@ -422,7 +616,6 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
422
616
  }
423
617
  // Remove project from the list to stop tracking it
424
618
  this.projects = this.projects.filter((p) => p.id !== projectId);
425
-
426
619
  // Cancel all jobs in the project
427
620
  project.jobs.forEach((job) => {
428
621
  if (!job.finished) {
@@ -438,13 +631,13 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
438
631
  private async uploadGuideImage(projectId: string, file: File | Buffer | Blob) {
439
632
  const imageId = getUUID();
440
633
  const presignedUrl = await this.uploadUrl({
441
- imageId: imageId,
634
+ imageId,
442
635
  jobId: projectId,
443
636
  type: 'startingImage'
444
637
  });
445
638
  const res = await fetch(presignedUrl, {
446
639
  method: 'PUT',
447
- body: file
640
+ body: toFetchBody(file)
448
641
  });
449
642
  if (!res.ok) {
450
643
  throw new ApiError(res.status, {
@@ -459,13 +652,13 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
459
652
  private async uploadCNImage(projectId: string, file: File | Buffer | Blob) {
460
653
  const imageId = getUUID();
461
654
  const presignedUrl = await this.uploadUrl({
462
- imageId: imageId,
655
+ imageId,
463
656
  jobId: projectId,
464
657
  type: 'cnImage'
465
658
  });
466
659
  const res = await fetch(presignedUrl, {
467
660
  method: 'PUT',
468
- body: file
661
+ body: toFetchBody(file)
469
662
  });
470
663
  if (!res.ok) {
471
664
  throw new ApiError(res.status, {
@@ -477,17 +670,22 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
477
670
  return imageId;
478
671
  }
479
672
 
480
- private async uploadContextImage(projectId: string, index: 0 | 1, file: File | Buffer | Blob) {
673
+ private async uploadContextImage(
674
+ projectId: string,
675
+ index: 0 | 1 | 2,
676
+ file: File | Buffer | Blob
677
+ ) {
481
678
  const imageId = getUUID();
482
- const imageIndex = (index + 1) as 1 | 2;
679
+ const imageIndex = (index + 1) as 1 | 2 | 3;
483
680
  const presignedUrl = await this.uploadUrl({
484
681
  imageId,
485
682
  jobId: projectId,
486
683
  type: `contextImage${imageIndex}`
487
684
  });
685
+ const body = toFetchBody(file);
488
686
  const res = await fetch(presignedUrl, {
489
687
  method: 'PUT',
490
- body: file
688
+ body
491
689
  });
492
690
  if (!res.ok) {
493
691
  throw new ApiError(res.status, {
@@ -499,8 +697,124 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
499
697
  return imageId;
500
698
  }
501
699
 
700
+ // ============================================
701
+ // VIDEO WORKFLOW UPLOADS (WAN 2.2)
702
+ // ============================================
703
+
704
+ /**
705
+ * Upload reference image for WAN video workflows
706
+ * @internal
707
+ */
708
+ private async uploadReferenceImage(projectId: string, file: File | Buffer | Blob) {
709
+ const imageId = getUUID();
710
+ const presignedUrl = await this.uploadUrl({
711
+ imageId,
712
+ jobId: projectId,
713
+ type: 'referenceImage'
714
+ });
715
+ const res = await fetch(presignedUrl, {
716
+ method: 'PUT',
717
+ body: toFetchBody(file)
718
+ });
719
+ if (!res.ok) {
720
+ throw new ApiError(res.status, {
721
+ status: 'error',
722
+ errorCode: 0,
723
+ message: 'Failed to upload reference image'
724
+ });
725
+ }
726
+ return imageId;
727
+ }
728
+
729
+ /**
730
+ * Upload reference image end for i2v interpolation
731
+ * @internal
732
+ */
733
+ private async uploadReferenceImageEnd(projectId: string, file: File | Buffer | Blob) {
734
+ const imageId = getUUID();
735
+ const presignedUrl = await this.uploadUrl({
736
+ imageId,
737
+ jobId: projectId,
738
+ type: 'referenceImageEnd'
739
+ });
740
+ const res = await fetch(presignedUrl, {
741
+ method: 'PUT',
742
+ body: toFetchBody(file)
743
+ });
744
+ if (!res.ok) {
745
+ throw new ApiError(res.status, {
746
+ status: 'error',
747
+ errorCode: 0,
748
+ message: 'Failed to upload reference image end'
749
+ });
750
+ }
751
+ return imageId;
752
+ }
753
+
502
754
  /**
503
- * Estimate project cost
755
+ * Upload reference audio for s2v workflows
756
+ * Supported formats: mp3, m4a, wav
757
+ * @internal
758
+ */
759
+ private async uploadReferenceAudio(projectId: string, file: File | Buffer | Blob) {
760
+ const contentType = getFileContentType(file);
761
+ const presignedUrl = await this.mediaUploadUrl({
762
+ jobId: projectId,
763
+ type: 'referenceAudio'
764
+ });
765
+ const headers: Record<string, string> = {};
766
+ if (contentType) {
767
+ headers['Content-Type'] = contentType;
768
+ }
769
+ const res = await fetch(presignedUrl, {
770
+ method: 'PUT',
771
+ body: toFetchBody(file),
772
+ headers
773
+ });
774
+ if (!res.ok) {
775
+ throw new ApiError(res.status, {
776
+ status: 'error',
777
+ errorCode: 0,
778
+ message: 'Failed to upload reference audio'
779
+ });
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Upload reference video for animate workflows
785
+ * Supported formats: mp4, mov
786
+ * @internal
787
+ */
788
+ private async uploadReferenceVideo(projectId: string, file: File | Buffer | Blob) {
789
+ const contentType = getFileContentType(file);
790
+ const presignedUrl = await this.mediaUploadUrl({
791
+ jobId: projectId,
792
+ type: 'referenceVideo'
793
+ });
794
+ const headers: Record<string, string> = {};
795
+ if (contentType) {
796
+ headers['Content-Type'] = contentType;
797
+ }
798
+ const res = await fetch(presignedUrl, {
799
+ method: 'PUT',
800
+ body: toFetchBody(file),
801
+ headers
802
+ });
803
+ if (!res.ok) {
804
+ throw new ApiError(res.status, {
805
+ status: 'error',
806
+ errorCode: 0,
807
+ message: 'Failed to upload reference video'
808
+ });
809
+ }
810
+ }
811
+
812
+ // ============================================
813
+ // COST ESTIMATION
814
+ // ============================================
815
+
816
+ /**
817
+ * Estimate image project cost
504
818
  */
505
819
  async estimateCost({
506
820
  network,
@@ -517,10 +831,10 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
517
831
  guidance,
518
832
  sampler,
519
833
  contextImages
520
- }: EstimateRequest) {
834
+ }: EstimateRequest): Promise<CostEstimation> {
521
835
  let apiVersion = 2;
522
836
  const pathParams = [
523
- tokenType || 'sogni',
837
+ tokenType || 'spark',
524
838
  network,
525
839
  model,
526
840
  imageCount,
@@ -544,7 +858,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
544
858
  if (sampler) {
545
859
  apiVersion = 3;
546
860
  pathParams.push(guidance || 0);
547
- pathParams.push(sampler || '');
861
+ pathParams.push(validateSampler(model, sampler)!);
548
862
  pathParams.push(contextImages || 0);
549
863
  }
550
864
  const r = await this.client.socket.get<EstimationResponse>(
@@ -552,11 +866,18 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
552
866
  );
553
867
  return {
554
868
  token: r.quote.project.costInToken,
555
- usd: r.quote.project.costInUSD
869
+ usd: r.quote.project.costInUSD,
870
+ spark: r.quote.project.costInSpark,
871
+ sogni: r.quote.project.costInSogni
556
872
  };
557
873
  }
558
874
 
559
- async estimateEnhancementCost(strength: EnhancementStrength, tokenType: TokenType = 'sogni') {
875
+ /**
876
+ * Estimate image enhancement cost
877
+ * @param strength
878
+ * @param tokenType
879
+ */
880
+ async estimateEnhancementCost(strength: EnhancementStrength, tokenType: TokenType = 'spark') {
560
881
  return this.estimateCost({
561
882
  network: enhancementDefaults.network,
562
883
  tokenType,
@@ -569,10 +890,53 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
569
890
  });
570
891
  }
571
892
 
893
+ /**
894
+ * Estimates the cost of generating a video based on the provided parameters.
895
+ *
896
+ * @param {VideoEstimateRequest} params - The parameters required for video cost estimation. This includes:
897
+ * - tokenType: The type of token to be used for generation.
898
+ * - model: The model to be used for video generation.
899
+ * - width: The width of the video in pixels.
900
+ * - height: The height of the video in pixels.
901
+ * - frames: The total number of frames in the video.
902
+ * - fps: The frames per second for the video.
903
+ * - steps: Number of steps.
904
+ * @return {Promise<Object>} Returns an object containing the estimated costs for the video in different units:
905
+ * - token: Cost in tokens.
906
+ * - usd: Cost in USD.
907
+ * - spark: Cost in Spark.
908
+ * - sogni: Cost in Sogni.
909
+ */
910
+ async estimateVideoCost(params: VideoEstimateRequest) {
911
+ const pathParams = [
912
+ params.tokenType,
913
+ params.model,
914
+ params.width,
915
+ params.height,
916
+ params.frames ? params.frames : params.duration * 16 + 1,
917
+ params.fps,
918
+ params.steps,
919
+ params.numberOfMedia
920
+ ];
921
+ const path = pathParams.map((p) => encodeURIComponent(p)).join('/');
922
+ const r = await this.client.socket.get<EstimationResponse>(
923
+ `/api/v1/job-video/estimate/${path}`
924
+ );
925
+ return {
926
+ token: r.quote.project.costInToken,
927
+ usd: r.quote.project.costInUSD,
928
+ spark: r.quote.project.costInSpark,
929
+ sogni: r.quote.project.costInSogni
930
+ };
931
+ }
932
+
933
+ // ============================================
934
+ // URL HELPERS
935
+ // ============================================
936
+
572
937
  /**
573
938
  * Get upload URL for image
574
939
  * @internal
575
- * @param params
576
940
  */
577
941
  async uploadUrl(params: ImageUrlParams) {
578
942
  const r = await this.client.rest.get<ApiResponse<{ uploadUrl: string }>>(
@@ -585,16 +949,49 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
585
949
  /**
586
950
  * Get download URL for image
587
951
  * @internal
588
- * @param params
589
952
  */
590
953
  async downloadUrl(params: ImageUrlParams) {
591
954
  const r = await this.client.rest.get<ApiResponse<{ downloadUrl: string }>>(
592
955
  `/v1/image/downloadUrl`,
593
956
  params
594
957
  );
958
+ if (!r?.data?.downloadUrl) {
959
+ throw new Error(`API returned no downloadUrl: ${JSON.stringify(r)}`);
960
+ }
961
+ return r.data.downloadUrl;
962
+ }
963
+
964
+ /**
965
+ * Get upload URL for media (video/audio)
966
+ * @internal
967
+ */
968
+ async mediaUploadUrl(params: MediaUrlParams) {
969
+ const r = await this.client.rest.get<ApiResponse<{ uploadUrl: string }>>(
970
+ `/v1/media/uploadUrl`,
971
+ params
972
+ );
973
+ return r.data.uploadUrl;
974
+ }
975
+
976
+ /**
977
+ * Get download URL for media (video/audio)
978
+ * @internal
979
+ */
980
+ async mediaDownloadUrl(params: MediaUrlParams) {
981
+ const r = await this.client.rest.get<ApiResponse<{ downloadUrl: string }>>(
982
+ `/v1/media/downloadUrl`,
983
+ params
984
+ );
985
+ if (!r?.data?.downloadUrl) {
986
+ throw new Error(`API returned no downloadUrl: ${JSON.stringify(r)}`);
987
+ }
595
988
  return r.data.downloadUrl;
596
989
  }
597
990
 
991
+ // ============================================
992
+ // MODEL/PRESET HELPERS
993
+ // ============================================
994
+
598
995
  async getSupportedModels(forceRefresh = false) {
599
996
  if (
600
997
  this._supportedModels.data &&
@@ -613,7 +1010,7 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
613
1010
  *
614
1011
  * @example
615
1012
  * ```ts
616
- * const presets = await client.projects.getSizePresets('fast', 'flux1-schnell-fp8');
1013
+ * const presets = await sogni.projects.getSizePresets('fast', 'flux1-schnell-fp8');
617
1014
  * console.log(presets);
618
1015
  * ```
619
1016
  *
@@ -642,6 +1039,49 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
642
1039
  return data;
643
1040
  }
644
1041
 
1042
+ /**
1043
+ * Retrieves the video asset configuration for a given video model identifier.
1044
+ * Validates whether the provided model ID corresponds to a video model. If it does,
1045
+ * returns the appropriate video asset configuration based on the workflow type.
1046
+ *
1047
+ * @example Returned object for a model that implements image to video workflow:
1048
+ * ```json
1049
+ * {
1050
+ * "workflowType": "i2v",
1051
+ * "assets": {
1052
+ * "referenceImage": "required",
1053
+ * "referenceImageEnd": "optional",
1054
+ * "referenceAudio": "forbidden",
1055
+ * "referenceVideo": "forbidden"
1056
+ * }
1057
+ * }
1058
+ * ```
1059
+ *
1060
+ * @param {string} modelId - The identifier of the video model to retrieve the configuration for.
1061
+ * @return {Object} The video asset configuration object where key is asset field and value is
1062
+ * either `required`, `forbidden` or `optional`. Returns `null` if no rules defined for the model.
1063
+ * @throws {ApiError} Throws an error if the provided model ID is not a video model.
1064
+ */
1065
+ async getVideoAssetConfig(modelId: string) {
1066
+ if (!this.isVideoModelId(modelId)) {
1067
+ throw new ApiError(400, {
1068
+ status: 'error',
1069
+ errorCode: 0,
1070
+ message: `Model ${modelId} is not a video model`
1071
+ });
1072
+ }
1073
+ const workflow = getVideoWorkflowType(modelId);
1074
+ if (!workflow) {
1075
+ return {
1076
+ workflowType: null
1077
+ };
1078
+ }
1079
+ return {
1080
+ workflowType: workflow,
1081
+ assets: VIDEO_WORKFLOW_ASSETS[workflow]
1082
+ };
1083
+ }
1084
+
645
1085
  /**
646
1086
  * Get available models and their worker counts. Normally, you would get list once you connect
647
1087
  * to the server, but you can also call this method to get the list of available models manually.
@@ -658,10 +1098,25 @@ class ProjectsApi extends ApiGroup<ProjectApiEvents> {
658
1098
  return {
659
1099
  id: model?.id || sid,
660
1100
  name: model?.name || sid.replace(/-/g, ' '),
661
- workerCount
1101
+ workerCount,
1102
+ media: model?.media || 'image'
662
1103
  };
663
1104
  });
664
1105
  }
1106
+
1107
+ async getSamplers(modelId: string) {
1108
+ if (isComfyModel(modelId)) {
1109
+ return Object.keys(SupportedComfySamplers) as ComfySampler[];
1110
+ }
1111
+ return Object.keys(SupportedForgeSamplers) as ForgeSampler[];
1112
+ }
1113
+
1114
+ async getSchedulers(modelId: string) {
1115
+ if (isComfyModel(modelId)) {
1116
+ return Object.keys(SupportedComfySchedulers) as ComfyScheduler[];
1117
+ }
1118
+ return Object.keys(SupportedForgeSchedulers) as ForgeScheduler[];
1119
+ }
665
1120
  }
666
1121
 
667
1122
  export default ProjectsApi;